diff --git a/.kiro/specs/beach-litter-mapping/tasks.md b/.kiro/specs/beach-litter-mapping/tasks.md index 9c1367e..ac70c31 100644 --- a/.kiro/specs/beach-litter-mapping/tasks.md +++ b/.kiro/specs/beach-litter-mapping/tasks.md @@ -135,7 +135,7 @@ ## Report Verification System -- [ ] 17. Implement report verification interface +- [-] 17. Implement report verification interface - Add verify/dispute buttons to report details - Create verification comment form - Display verification count and status diff --git a/server/src/controllers/reportController.ts b/server/src/controllers/reportController.ts index 2cf5eba..cbb70f2 100644 --- a/server/src/controllers/reportController.ts +++ b/server/src/controllers/reportController.ts @@ -277,6 +277,114 @@ export class ReportController { }); } } + + async verifyReport(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; + const { comment } = req.body; + + // Find or create user in database + const dbUserId = await this.findOrCreateUser(req.userId); + + const report = await reportService.verifyReport(id, dbUserId, comment); + + res.json(report); + } catch (error: any) { + console.error('Error verifying report:', 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(), + }, + }); + } + + if (error.message === 'You have already verified or disputed this report') { + return res.status(400).json({ + error: { + code: 'VALIDATION_ERROR', + message: 'You have already verified or disputed this report', + timestamp: new Date().toISOString(), + }, + }); + } + + res.status(500).json({ + error: { + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to verify report', + timestamp: new Date().toISOString(), + }, + }); + } + } + + async disputeReport(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; + const { comment } = req.body; + + // Find or create user in database + const dbUserId = await this.findOrCreateUser(req.userId); + + const report = await reportService.disputeReport(id, dbUserId, comment); + + res.json(report); + } catch (error: any) { + console.error('Error disputing report:', 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(), + }, + }); + } + + if (error.message === 'You have already verified or disputed this report') { + return res.status(400).json({ + error: { + code: 'VALIDATION_ERROR', + message: 'You have already verified or disputed this report', + timestamp: new Date().toISOString(), + }, + }); + } + + res.status(500).json({ + error: { + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to dispute report', + timestamp: new Date().toISOString(), + }, + }); + } + } } export const reportController = new ReportController(); diff --git a/server/src/routes/reportRoutes.ts b/server/src/routes/reportRoutes.ts index e11ec23..03ad752 100644 --- a/server/src/routes/reportRoutes.ts +++ b/server/src/routes/reportRoutes.ts @@ -14,4 +14,8 @@ router.get('/', reportController.getReports.bind(reportController)); router.get('/:id', reportController.getReportById.bind(reportController)); router.delete('/:id', requireAuth, reportController.deleteReport.bind(reportController)); +// Verification endpoints +router.post('/:id/verify', requireAuth, reportController.verifyReport.bind(reportController)); +router.post('/:id/dispute', requireAuth, reportController.disputeReport.bind(reportController)); + export default router; diff --git a/server/src/scripts/testVerification.ts b/server/src/scripts/testVerification.ts new file mode 100644 index 0000000..046569b --- /dev/null +++ b/server/src/scripts/testVerification.ts @@ -0,0 +1,75 @@ +import prisma from '../config/database'; + +async function testVerification() { + try { + console.log('Testing verification system...\n'); + + // Get a test report + const report = await prisma.report.findFirst({ + include: { + verifications: { + include: { + user: { + select: { + username: true, + }, + }, + }, + }, + }, + }); + + if (!report) { + console.log('No reports found in database. Please create a report first.'); + return; + } + + console.log('Test Report:'); + console.log(` ID: ${report.id}`); + console.log(` Litter Type: ${report.litterType}`); + console.log(` Status: ${report.verificationStatus}`); + console.log(` Location: ${report.latitude}, ${report.longitude}`); + + // Count verifications + const verifyCount = report.verifications.filter(v => v.verificationType === 'verify').length; + const disputeCount = report.verifications.filter(v => v.verificationType === 'dispute').length; + + console.log(`\nVerification Stats:`); + console.log(` Verifications: ${verifyCount}`); + console.log(` Disputes: ${disputeCount}`); + + if (report.verifications.length > 0) { + console.log(`\nVerification History:`); + report.verifications.forEach((v, i) => { + console.log(` ${i + 1}. ${v.user.username} - ${v.verificationType} ${v.comment ? `("${v.comment}")` : ''}`); + }); + } + + // Check auto-hide logic + if (disputeCount >= 3) { + console.log(`\n⚠️ This report should be auto-hidden (3+ disputes)`); + console.log(` Current status: ${report.verificationStatus}`); + if (report.verificationStatus !== 'disputed') { + console.log(` ❌ ERROR: Status should be "disputed" but is "${report.verificationStatus}"`); + } else { + console.log(` ✅ Status correctly set to "disputed"`); + } + } else if (verifyCount >= 2) { + console.log(`\n✅ This report should be verified (2+ verifications)`); + console.log(` Current status: ${report.verificationStatus}`); + if (report.verificationStatus !== 'verified') { + console.log(` ⚠️ Status should be "verified" but is "${report.verificationStatus}"`); + } else { + console.log(` ✅ Status correctly set to "verified"`); + } + } + + console.log('\n✅ Verification system test complete!'); + } catch (error) { + console.error('Error testing verification:', error); + } finally { + await prisma.$disconnect(); + } +} + +testVerification(); diff --git a/server/src/services/reportService.ts b/server/src/services/reportService.ts index d879164..13339f1 100644 --- a/server/src/services/reportService.ts +++ b/server/src/services/reportService.ts @@ -79,6 +79,7 @@ export class ReportService { quantities?: string[]; verificationStatus?: string[]; bounds?: { north: number; south: number; east: number; west: number }; + includeDisputed?: boolean; // Admin flag to include disputed reports }) { const where: any = {}; @@ -98,6 +99,9 @@ export class ReportService { if (filters?.verificationStatus?.length) { where.verificationStatus = { in: filters.verificationStatus }; + } else if (!filters?.includeDisputed) { + // By default, exclude disputed reports (auto-hidden) + where.verificationStatus = { not: 'disputed' }; } if (filters?.bounds) { @@ -122,14 +126,33 @@ export class ReportService { }, }, environmentalConcerns: true, - verifications: true, + verifications: { + include: { + user: { + select: { + id: true, + username: true, + }, + }, + }, + }, }, orderBy: { createdAt: 'desc', }, }); - return reports; + // Add verification and dispute counts to each report + return reports.map(report => { + const verificationCount = report.verifications.filter(v => v.verificationType === 'verify').length; + const disputeCount = report.verifications.filter(v => v.verificationType === 'dispute').length; + + return { + ...report, + verificationCount, + disputeCount, + }; + }); } async getReportById(id: string) { @@ -153,11 +176,26 @@ export class ReportService { }, }, }, + orderBy: { + createdAt: 'desc', + }, }, }, }); - return report; + if (!report) { + return null; + } + + // Calculate verification and dispute counts + const verificationCount = report.verifications.filter(v => v.verificationType === 'verify').length; + const disputeCount = report.verifications.filter(v => v.verificationType === 'dispute').length; + + return { + ...report, + verificationCount, + disputeCount, + }; } async deleteReport(id: string, userId: string) { @@ -179,6 +217,115 @@ export class ReportService { where: { id }, }); } + + async verifyReport(reportId: string, userId: string, comment?: string) { + // Check if report exists + const report = await prisma.report.findUnique({ + where: { id: reportId }, + }); + + if (!report) { + throw new Error('Report not found'); + } + + // Check if user already verified/disputed this report + const existingVerification = await prisma.verification.findUnique({ + where: { + reportId_userId: { + reportId, + userId, + }, + }, + }); + + if (existingVerification) { + throw new Error('You have already verified or disputed this report'); + } + + // Create verification + await prisma.verification.create({ + data: { + reportId, + userId, + verificationType: 'verify', + comment, + }, + }); + + // Update verification counts and status + await this.updateReportVerificationStatus(reportId); + + // Return updated report + return this.getReportById(reportId); + } + + async disputeReport(reportId: string, userId: string, comment?: string) { + // Check if report exists + const report = await prisma.report.findUnique({ + where: { id: reportId }, + }); + + if (!report) { + throw new Error('Report not found'); + } + + // Check if user already verified/disputed this report + const existingVerification = await prisma.verification.findUnique({ + where: { + reportId_userId: { + reportId, + userId, + }, + }, + }); + + if (existingVerification) { + throw new Error('You have already verified or disputed this report'); + } + + // Create dispute + await prisma.verification.create({ + data: { + reportId, + userId, + verificationType: 'dispute', + comment, + }, + }); + + // Update verification counts and status + await this.updateReportVerificationStatus(reportId); + + // Return updated report + return this.getReportById(reportId); + } + + private async updateReportVerificationStatus(reportId: string) { + // Count verifications and disputes + const verifications = await prisma.verification.findMany({ + where: { reportId }, + }); + + const verifyCount = verifications.filter(v => v.verificationType === 'verify').length; + const disputeCount = verifications.filter(v => v.verificationType === 'dispute').length; + + // Determine status based on counts + let status: 'pending' | 'verified' | 'disputed' = 'pending'; + + if (disputeCount >= 3) { + // Auto-hide: 3+ disputes + status = 'disputed'; + } else if (verifyCount >= 2) { + // Verified: 2+ verifications + status = 'verified'; + } + + // Update report status + await prisma.report.update({ + where: { id: reportId }, + data: { verificationStatus: status }, + }); + } } export const reportService = new ReportService(); diff --git a/src/components/report/ReportDetailModal.tsx b/src/components/report/ReportDetailModal.tsx index f75e334..fd090ac 100644 --- a/src/components/report/ReportDetailModal.tsx +++ b/src/components/report/ReportDetailModal.tsx @@ -7,21 +7,22 @@ interface ReportDetailModalProps { report: ReportResponseDTO; isOpen: boolean; onClose: () => void; - onVerify?: (reportId: string) => void; - onDispute?: (reportId: string) => void; + onVerify?: (reportId: string, comment?: string) => void; + onDispute?: (reportId: string, comment?: string) => void; } export function ReportDetailModal({ report, isOpen, onClose, onVerify, onDispute }: ReportDetailModalProps) { const [currentPhotoIndex, setCurrentPhotoIndex] = useState(0); const [showVerifyComment, setShowVerifyComment] = useState(false); const [showDisputeComment, setShowDisputeComment] = useState(false); + const [showAuditLog, setShowAuditLog] = useState(false); const [comment, setComment] = useState(''); if (!isOpen) return null; const handleVerify = () => { if (onVerify) { - onVerify(report.id); + onVerify(report.id, comment || undefined); setShowVerifyComment(false); setComment(''); } @@ -29,7 +30,7 @@ export function ReportDetailModal({ report, isOpen, onClose, onVerify, onDispute const handleDispute = () => { if (onDispute) { - onDispute(report.id); + onDispute(report.id, comment || undefined); setShowDisputeComment(false); setComment(''); } @@ -264,23 +265,113 @@ export function ReportDetailModal({ report, isOpen, onClose, onVerify, onDispute )} {/* Verification Stats */} -
-
- - - - - {report.verificationCount} verifications - +
+
+

Verification Status

+ {report.verifications && report.verifications.length > 0 && ( + + )}
-
- - - - - {report.disputeCount} disputes - + +
+
+ + + + + {report.verificationCount} verifications + +
+
+ + + + + {report.disputeCount} disputes + +
+ + {/* Auto-hide warning for disputed reports */} + {report.disputeCount >= 3 && ( +
+ + + +
+

Report Disputed

+

+ This report has received 3 or more disputes and is hidden from public view pending review. +

+
+
+ )} + + {/* Audit Log */} + {showAuditLog && report.verifications && report.verifications.length > 0 && ( +
+
+

Verification History

+
+ {report.verifications + .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) + .map((verification) => ( +
+
+
+ {verification.verificationType === 'verify' ? ( + + + + ) : ( + + + + )} + + {verification.user.username} + + + {verification.verificationType === 'verify' ? 'verified' : 'disputed'} + +
+ + {format(new Date(verification.createdAt), 'MMM d, yyyy')} + +
+ {verification.comment && ( +

+ "{verification.comment}" +

+ )} +
+ ))} +
+
+
+ )}
{/* Action Buttons */} diff --git a/src/pages/MapPage.tsx b/src/pages/MapPage.tsx index 158a763..d8ef92a 100644 --- a/src/pages/MapPage.tsx +++ b/src/pages/MapPage.tsx @@ -1,15 +1,20 @@ import { useState, useEffect } from 'react'; +import { useAuth } from '@clerk/clerk-react'; import { MapView, MapFilters } from '@/components/map'; +import { ReportDetailModal } from '@/components/report'; import { analyticsService } from '@/services/analyticsService'; import { reportService } from '@/services/reportService'; import { eventService } from '@/services/eventService'; -import type { Report, FilterState, HotspotWithSeverity, CleanupEvent } from '@/types'; +import type { Report, FilterState, HotspotWithSeverity, CleanupEvent, ReportResponseDTO } from '@/types'; /** * Map page - Interactive map displaying litter reports and cleanup events */ export function MapPage() { + const { getToken, isSignedIn } = useAuth(); const [selectedReportId, setSelectedReportId] = useState(null); + const [selectedReport, setSelectedReport] = useState(null); + const [isLoadingReport, setIsLoadingReport] = useState(false); const [filters, setFilters] = useState({}); const [showClusters, setShowClusters] = useState(true); const [showHeatmap, setShowHeatmap] = useState(false); @@ -23,6 +28,11 @@ export function MapPage() { const [isLoadingEvents, setIsLoadingEvents] = useState(true); const [modalImageUrl, setModalImageUrl] = useState(null); + // Set up token getter for report service + useEffect(() => { + reportService.setTokenGetter(getToken); + }, [getToken]); + // Fetch reports from API useEffect(() => { const fetchReports = async () => { @@ -233,10 +243,83 @@ export function MapPage() { }, ]; - const handleMarkerClick = (reportId: string) => { + const handleMarkerClick = async (reportId: string) => { setSelectedReportId(reportId); - console.log('Report clicked:', reportId); - // In later tasks, this will open a modal with full report details + setIsLoadingReport(true); + + try { + const report = await reportService.getReport(reportId); + setSelectedReport(report); + } catch (error) { + console.error('Failed to fetch report details:', error); + } finally { + setIsLoadingReport(false); + } + }; + + const handleCloseModal = () => { + setSelectedReportId(null); + setSelectedReport(null); + }; + + const handleVerify = async (reportId: string, comment?: string) => { + try { + await reportService.verifyReport(reportId, comment); + // 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); + } catch (error: any) { + console.error('Failed to verify report:', error); + alert(error.message || 'Failed to verify report. Please try again.'); + } + }; + + const handleDispute = async (reportId: string, comment?: string) => { + try { + await reportService.disputeReport(reportId, comment); + // 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); + } catch (error: any) { + console.error('Failed to dispute report:', error); + alert(error.message || 'Failed to dispute report. Please try again.'); + } }; const handleMapClick = (lat: number, lng: number) => { @@ -363,17 +446,24 @@ export function MapPage() {
- {selectedReportId && ( -
-

- Selected Report ID: {selectedReportId} -

- + {/* Report Detail Modal */} + {selectedReport && ( + + )} + + {/* Loading indicator for report details */} + {isLoadingReport && selectedReportId && ( +
+
+
+

Loading report details...

+
)} diff --git a/src/types/index.ts b/src/types/index.ts index f370ea1..fd34fae 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -106,6 +106,19 @@ export interface CreateReportDTO { }[]; } +export interface Verification { + id: string; + reportId: string; + userId: string; + verificationType: 'verify' | 'dispute'; + comment?: string; + createdAt: Date; + user: { + id: string; + username: string; + }; +} + export interface ReportResponseDTO extends Report { user: { id: string; @@ -115,6 +128,7 @@ export interface ReportResponseDTO extends Report { verificationCount: number; disputeCount: number; environmentalConcerns: EnvironmentalConcern[]; + verifications?: Verification[]; } export interface FilterState {