From 64fe312f01f439954e71b8756b05a03e0ba54bb7 Mon Sep 17 00:00:00 2001 From: Paul Bailey Date: Sun, 16 Nov 2025 20:01:26 +0000 Subject: [PATCH] feat: implement report submission with photo upload and map display - Fix Prisma client generation and database schema sync issues - Implement user auto-creation from Clerk authentication - Add raw SQL queries to handle MySQL POINT spatial fields - Make password_hash optional for Clerk-only authentication - Update MapPage to fetch real reports from API - Add photo display in map popups with full-size modal view - Configure CORS and Helmet for cross-origin image loading - Add cleanup tracking task to implementation plan Fixes report submission 500 errors and enables end-to-end workflow from submission to map display. --- server/package.json | 9 ++- server/prisma/schema.prisma | 2 +- server/src/controllers/reportController.ts | 42 ++++++++++- server/src/index.ts | 5 +- server/src/services/reportService.ts | 58 +++++++++------ src/components/map/MapView.tsx | 16 +++- src/pages/MapPage.tsx | 87 +++++++++++++++++++--- 7 files changed, 180 insertions(+), 39 deletions(-) diff --git a/server/package.json b/server/package.json index 167426c..88015cb 100644 --- a/server/package.json +++ b/server/package.json @@ -11,7 +11,12 @@ "prisma:migrate": "prisma migrate dev", "prisma:studio": "prisma studio" }, - "keywords": ["beach", "litter", "environment", "api"], + "keywords": [ + "beach", + "litter", + "environment", + "api" + ], "author": "", "license": "MIT", "dependencies": { @@ -19,9 +24,9 @@ "@prisma/client": "^5.22.0", "cors": "^2.8.5", "dotenv": "^16.4.5", + "exifr": "^7.1.3", "express": "^4.21.1", "express-validator": "^7.2.0", - "exifr": "^7.1.3", "helmet": "^8.0.0", "multer": "^1.4.5-lts.1", "zod": "^3.23.8" diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 658df95..c1d364a 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -10,7 +10,7 @@ datasource db { model User { id String @id @default(dbgenerated("(uuid())")) @db.VarChar(36) email String @unique(map: "email") @db.VarChar(255) - password_hash String @db.VarChar(255) + password_hash String? @db.VarChar(255) username String @unique(map: "username") @db.VarChar(100) createdAt DateTime? @default(now()) @map("created_at") @db.Timestamp(0) updatedAt DateTime? @default(now()) @updatedAt @map("updated_at") @db.Timestamp(0) diff --git a/server/src/controllers/reportController.ts b/server/src/controllers/reportController.ts index 2331725..36b9530 100644 --- a/server/src/controllers/reportController.ts +++ b/server/src/controllers/reportController.ts @@ -2,9 +2,44 @@ import { Response } from 'express'; import { AuthRequest } from '../middleware/auth'; import { reportService } from '../services/reportService'; import { extractExifData } from '../utils/exif'; -import path from 'path'; +import prisma from '../config/database'; +import { clerkClient } from '@clerk/clerk-sdk-node'; export class ReportController { + // Helper to find or create user from Clerk ID + private async findOrCreateUser(clerkId: string): Promise { + // Check if user exists + let user = await prisma.user.findUnique({ + where: { clerkId }, + select: { id: true }, + }); + + if (user) { + return user.id; + } + + // User doesn't exist, fetch from Clerk and create + try { + const clerkUser = await clerkClient.users.getUser(clerkId); + + user = await prisma.user.create({ + data: { + clerkId, + email: clerkUser.emailAddresses[0]?.emailAddress || `${clerkId}@temp.com`, + username: clerkUser.username || `user_${clerkId.substring(0, 8)}`, + firstName: clerkUser.firstName, + lastName: clerkUser.lastName, + avatarUrl: clerkUser.imageUrl, + }, + select: { id: true }, + }); + + return user.id; + } catch (error) { + console.error('Error creating user from Clerk:', error); + throw new Error('Failed to create user'); + } + } async uploadPhoto(req: AuthRequest, res: Response) { try { if (!req.file) { @@ -88,8 +123,11 @@ export class ReportController { }); } + // Find or create user in database + const dbUserId = await this.findOrCreateUser(req.userId); + const report = await reportService.createReport({ - userId: req.userId, + userId: dbUserId, latitude: parseFloat(latitude), longitude: parseFloat(longitude), locationSource, diff --git a/server/src/index.ts b/server/src/index.ts index 0e65741..a2e1cbf 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -12,7 +12,10 @@ const app = express(); const PORT = process.env.PORT || 3005; // Middleware -app.use(helmet()); +app.use(helmet({ + crossOriginResourcePolicy: { policy: "cross-origin" }, + contentSecurityPolicy: false, // Disable CSP for development +})); app.use(cors({ origin: process.env.CORS_ORIGIN || 'http://localhost:5173', credentials: true, diff --git a/server/src/services/reportService.ts b/server/src/services/reportService.ts index bfc75ea..d879164 100644 --- a/server/src/services/reportService.ts +++ b/server/src/services/reportService.ts @@ -20,27 +20,43 @@ export interface CreateReportData { export class ReportService { async createReport(data: CreateReportData) { - const report = await prisma.report.create({ - data: { - userId: data.userId, - latitude: new Prisma.Decimal(data.latitude), - longitude: new Prisma.Decimal(data.longitude), - locationSource: data.locationSource, - photoUrls: data.photoUrls, - photoTimestamp: data.photoTimestamp, - litterType: data.litterType, - quantity: data.quantity, - description: data.description, - environmentalConcerns: data.environmentalConcerns - ? { - create: data.environmentalConcerns.map((concern) => ({ - concernType: concern.type, - severity: concern.severity, - description: concern.description, - })), - } - : undefined, - }, + // Create report using raw SQL to handle the POINT field + const reportId = await prisma.$queryRaw>` + INSERT INTO reports ( + user_id, location, latitude, longitude, location_source, + photo_urls, photo_timestamp, litter_type, quantity, description + ) VALUES ( + ${data.userId}, + ST_GeomFromText(${`POINT(${data.longitude} ${data.latitude})`}, 4326), + ${data.latitude}, + ${data.longitude}, + ${data.locationSource}, + ${JSON.stringify(data.photoUrls)}, + ${data.photoTimestamp || null}, + ${data.litterType}, + ${data.quantity}, + ${data.description || null} + ) + `; + + // Get the created report ID + const createdId = await prisma.$queryRaw>` + SELECT id FROM reports ORDER BY created_at DESC LIMIT 1 + `; + + // Create environmental concerns if provided + if (data.environmentalConcerns && data.environmentalConcerns.length > 0) { + for (const concern of data.environmentalConcerns) { + await prisma.$queryRaw` + INSERT INTO environmental_concerns (report_id, concern_type, severity, description) + VALUES (${createdId[0].id}, ${concern.type}, ${concern.severity}, ${concern.description}) + `; + } + } + + // Fetch and return the complete report + const report = await prisma.report.findUnique({ + where: { id: createdId[0].id }, include: { user: { select: { diff --git a/src/components/map/MapView.tsx b/src/components/map/MapView.tsx index 052eeb5..ffae70f 100644 --- a/src/components/map/MapView.tsx +++ b/src/components/map/MapView.tsx @@ -185,8 +185,22 @@ export function MapView({ const marker = L.marker([report.latitude, report.longitude], { icon }); // Add popup with report details + const photoUrl = report.photoUrls && report.photoUrls.length > 0 ? report.photoUrls[0] : null; + // Remove /api from the base URL for uploads since they're served at the root level + const serverBaseUrl = (import.meta.env.VITE_API_BASE_URL || 'http://localhost:3005/api').replace('/api', ''); + const fullPhotoUrl = photoUrl ? (photoUrl.startsWith('http') ? photoUrl : `${serverBaseUrl}${photoUrl}`) : null; + marker.bindPopup(` -
+
+ ${fullPhotoUrl ? ` + Litter photo + ` : ''}

${getLitterTypeLabel(report.litterType)}

Quantity: ${report.quantity}

Status: ${report.verificationStatus}

diff --git a/src/pages/MapPage.tsx b/src/pages/MapPage.tsx index cd0b342..f2c0063 100644 --- a/src/pages/MapPage.tsx +++ b/src/pages/MapPage.tsx @@ -1,6 +1,7 @@ import { useState, useEffect } from 'react'; import { MapView, MapFilters } from '@/components/map'; import { analyticsService } from '@/services/analyticsService'; +import { reportService } from '@/services/reportService'; import type { Report, FilterState, HotspotWithSeverity } from '@/types'; /** @@ -14,8 +15,40 @@ export function MapPage() { const [showHotspots, setShowHotspots] = useState(false); const [hotspots, setHotspots] = useState([]); const [isLoadingHotspots, setIsLoadingHotspots] = useState(false); + const [reports, setReports] = useState([]); + const [isLoadingReports, setIsLoadingReports] = useState(true); + const [modalImageUrl, setModalImageUrl] = useState(null); - // Sample data for demonstration (will be replaced with API data in later tasks) + // Fetch reports from API + useEffect(() => { + const fetchReports = async () => { + try { + setIsLoadingReports(true); + const data = await reportService.getReports(filters); + setReports(data); + } catch (error) { + console.error('Failed to fetch reports:', error); + // Fall back to sample data on error + setReports(sampleReports); + } finally { + setIsLoadingReports(false); + } + }; + + fetchReports(); + }, [filters]); + + // Set up global function for opening image modal + useEffect(() => { + (window as any).openImageModal = (url: string) => { + setModalImageUrl(url); + }; + return () => { + delete (window as any).openImageModal; + }; + }, []); + + // Sample data for fallback const sampleReports: Report[] = [ { id: '1', @@ -196,16 +229,25 @@ export function MapPage() {
- + {isLoadingReports ? ( +
+
+
+

Loading reports...

+
+
+ ) : ( + + )}
)} + + {/* Image Modal */} + {modalImageUrl && ( +
setModalImageUrl(null)} + > +
+ + Full size litter photo e.stopPropagation()} + /> +
+
+ )}
); }