From 17e3e85781360df8b77e0c924302302fbe09f9af Mon Sep 17 00:00:00 2001 From: Paul Bailey Date: Sun, 16 Nov 2025 22:26:59 +0000 Subject: [PATCH] Add 'Mark as Cleaned Up' functionality - Add cleaned_at and cleaned_by_user_id fields to reports table - Create database migration for cleanup tracking - Add markAsCleaned backend endpoint and service method - Add Mark as Cleaned Up button to ReportDetailModal - Update types to include cleaned fields - Add frontend handler to mark reports as cleaned --- .../migration.sql | 10 +++ server/prisma/schema.prisma | 6 ++ server/src/controllers/reportController.ts | 43 +++++++++++++ server/src/routes/reportRoutes.ts | 3 + server/src/services/reportService.ts | 23 +++++++ src/components/report/ReportDetailModal.tsx | 63 ++++++++++++------- src/pages/MapPage.tsx | 32 ++++++++++ src/services/reportService.ts | 7 +++ src/types/index.ts | 2 + 9 files changed, 168 insertions(+), 21 deletions(-) create mode 100644 server/prisma/migrations/20251116222516_add_cleaned_fields/migration.sql diff --git a/server/prisma/migrations/20251116222516_add_cleaned_fields/migration.sql b/server/prisma/migrations/20251116222516_add_cleaned_fields/migration.sql new file mode 100644 index 0000000..4a11589 --- /dev/null +++ b/server/prisma/migrations/20251116222516_add_cleaned_fields/migration.sql @@ -0,0 +1,10 @@ +-- Add cleaned_at and cleaned_by_user_id fields to reports table +ALTER TABLE `reports` +ADD COLUMN `cleaned_at` TIMESTAMP NULL, +ADD COLUMN `cleaned_by_user_id` VARCHAR(36) NULL, +ADD INDEX `idx_cleaned_at` (`cleaned_at`), +ADD CONSTRAINT `reports_cleaned_by_fk` + FOREIGN KEY (`cleaned_by_user_id`) + REFERENCES `users`(`id`) + ON DELETE SET NULL + ON UPDATE NO ACTION; diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index c1d364a..003733d 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -25,6 +25,7 @@ model User { eventRegistrations EventRegistration[] organizedEvents Event[] @relation("EventOrganizer") reports Report[] + cleanedReports Report[] @relation("CleanedByUser") verifications Verification[] @@index([clerkId], map: "idx_clerk_id") @@ -49,15 +50,20 @@ model Report { updatedAt DateTime? @default(now()) @updatedAt @map("updated_at") @db.Timestamp(0) submittedAt DateTime? @default(now()) @map("submitted_at") @db.Timestamp(0) verificationStatus reports_verification_status? @default(pending) @map("verification_status") + cleanedAt DateTime? @map("cleaned_at") @db.Timestamp(0) + cleanedByUserId String? @map("cleaned_by_user_id") @db.VarChar(36) environmentalConcerns EnvironmentalConcern[] user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "reports_ibfk_1") + cleanedByUser User? @relation("CleanedByUser", fields: [cleanedByUserId], references: [id], onDelete: SetNull, onUpdate: NoAction, map: "reports_cleaned_by_fk") verifications Verification[] @@index([createdAt], map: "idx_created_at") + @@index([cleanedAt], map: "idx_cleaned_at") @@index([latitude, longitude], map: "idx_lat_lng") @@index([litterType], map: "idx_litter_type") @@index([location(length: 32)], map: "idx_location") @@index([userId], map: "user_id") + @@index([cleanedByUserId], map: "cleaned_by_user_id") @@map("reports") } diff --git a/server/src/controllers/reportController.ts b/server/src/controllers/reportController.ts index cbb70f2..b922fff 100644 --- a/server/src/controllers/reportController.ts +++ b/server/src/controllers/reportController.ts @@ -385,6 +385,49 @@ export class ReportController { }); } } + + async markAsCleaned(req: AuthRequest, res: Response) { + try { + if (!req.userId) { + return res.status(401).json({ + error: { + code: 'UNAUTHORIZED', + message: 'User not authenticated', + timestamp: new Date().toISOString(), + }, + }); + } + + const { id } = req.params; + + // Find or create user in database + const dbUserId = await this.findOrCreateUser(req.userId); + + const report = await reportService.markAsCleaned(id, dbUserId); + + res.json(report); + } catch (error: any) { + console.error('Error marking report as cleaned:', error); + + if (error.message === 'Report not found') { + return res.status(404).json({ + error: { + code: 'RESOURCE_NOT_FOUND', + message: 'Report not found', + timestamp: new Date().toISOString(), + }, + }); + } + + res.status(500).json({ + error: { + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to mark report as cleaned', + timestamp: new Date().toISOString(), + }, + }); + } + } } export const reportController = new ReportController(); diff --git a/server/src/routes/reportRoutes.ts b/server/src/routes/reportRoutes.ts index 03ad752..7a20411 100644 --- a/server/src/routes/reportRoutes.ts +++ b/server/src/routes/reportRoutes.ts @@ -18,4 +18,7 @@ router.delete('/:id', requireAuth, reportController.deleteReport.bind(reportCont router.post('/:id/verify', requireAuth, reportController.verifyReport.bind(reportController)); router.post('/:id/dispute', requireAuth, reportController.disputeReport.bind(reportController)); +// Cleanup tracking +router.post('/:id/mark-cleaned', requireAuth, reportController.markAsCleaned.bind(reportController)); + export default router; diff --git a/server/src/services/reportService.ts b/server/src/services/reportService.ts index 7f2f35f..e8583d0 100644 --- a/server/src/services/reportService.ts +++ b/server/src/services/reportService.ts @@ -330,6 +330,29 @@ export class ReportService { data: { verificationStatus: status }, }); } + + async markAsCleaned(reportId: string, userId: string) { + // Check if report exists + const report = await prisma.report.findUnique({ + where: { id: reportId }, + }); + + if (!report) { + throw new Error('Report not found'); + } + + // Mark as cleaned + await prisma.report.update({ + where: { id: reportId }, + data: { + cleanedAt: new Date(), + cleanedByUserId: userId, + }, + }); + + // Return updated report + return this.getReportById(reportId); + } } export const reportService = new ReportService(); diff --git a/src/components/report/ReportDetailModal.tsx b/src/components/report/ReportDetailModal.tsx index 583ef43..448b2c2 100644 --- a/src/components/report/ReportDetailModal.tsx +++ b/src/components/report/ReportDetailModal.tsx @@ -9,9 +9,10 @@ interface ReportDetailModalProps { onClose: () => void; onVerify?: (reportId: string, comment?: string) => void; onDispute?: (reportId: string, comment?: string) => void; + onMarkCleaned?: (reportId: string) => void; } -export function ReportDetailModal({ report, isOpen, onClose, onVerify, onDispute }: ReportDetailModalProps) { +export function ReportDetailModal({ report, isOpen, onClose, onVerify, onDispute, onMarkCleaned }: ReportDetailModalProps) { const [currentPhotoIndex, setCurrentPhotoIndex] = useState(0); const [showVerifyComment, setShowVerifyComment] = useState(false); const [showDisputeComment, setShowDisputeComment] = useState(false); @@ -357,35 +358,55 @@ export function ReportDetailModal({ report, isOpen, onClose, onVerify, onDispute {/* Action Buttons */} - {(onVerify || onDispute) && ( + {(onVerify || onDispute || onMarkCleaned) && (
-

- Help maintain data quality by verifying or disputing this report. -

+ {(onVerify || onDispute) && ( +

+ Help maintain data quality by verifying or disputing this report. +

+ )} {!showVerifyComment && !showDisputeComment && ( -
- {onVerify && ( +
+ {/* Mark as Cleaned Button */} + {onMarkCleaned && ( )} - {onDispute && ( - + + {/* Verify/Dispute Buttons */} + {(onVerify || onDispute) && ( +
+ {onVerify && ( + + )} + {onDispute && ( + + )} +
)}
)} diff --git a/src/pages/MapPage.tsx b/src/pages/MapPage.tsx index 5e94ff2..7c03d59 100644 --- a/src/pages/MapPage.tsx +++ b/src/pages/MapPage.tsx @@ -322,6 +322,37 @@ export function MapPage() { } }; + const handleMarkCleaned = async (reportId: string) => { + try { + await reportService.markAsCleaned(reportId); + // Refresh the report details + const updatedReport = await reportService.getReport(reportId); + setSelectedReport(updatedReport); + // Refresh the reports list + const apiFilters: Parameters[0] = filters.dateRange + ? { + dateRange: { + start: filters.dateRange.start.toISOString(), + end: filters.dateRange.end.toISOString(), + }, + litterTypes: filters.litterTypes, + quantities: filters.quantities, + verificationStatus: filters.verificationStatus, + } + : { + litterTypes: filters.litterTypes, + quantities: filters.quantities, + verificationStatus: filters.verificationStatus, + }; + const data = await reportService.getReports(apiFilters); + setReports(data); + alert('Report marked as cleaned up!'); + } catch (error: any) { + console.error('Failed to mark report as cleaned:', error); + alert(error.message || 'Failed to mark report as cleaned. Please try again.'); + } + }; + const handleMapClick = (lat: number, lng: number) => { console.log('Map clicked at:', lat, lng); // In later tasks, this can be used for manual location selection @@ -454,6 +485,7 @@ export function MapPage() { onClose={handleCloseModal} onVerify={isSignedIn ? handleVerify : undefined} onDispute={isSignedIn ? handleDispute : undefined} + onMarkCleaned={isSignedIn ? handleMarkCleaned : undefined} /> )} diff --git a/src/services/reportService.ts b/src/services/reportService.ts index 6b81b8a..25657b3 100644 --- a/src/services/reportService.ts +++ b/src/services/reportService.ts @@ -133,6 +133,13 @@ class ReportService { return apiClient.post(`/reports/${reportId}/dispute`, { comment }); } + /** + * Mark a report as cleaned up + */ + async markAsCleaned(reportId: string): Promise { + return apiClient.post(`/reports/${reportId}/mark-cleaned`); + } + /** * Delete a report (only by owner) */ diff --git a/src/types/index.ts b/src/types/index.ts index fd34fae..fd49fc8 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -43,6 +43,8 @@ export interface Report { updatedAt: Date; submittedAt: Date; verificationStatus: VerificationStatus; + cleanedAt?: Date; + cleanedByUserId?: string; } export interface CleanupEvent {