Implement report verification interface with audit log

- Add verify/dispute endpoints to backend API
- Create verification service methods with auto-hide logic for 3+ disputes
- Update ReportDetailModal with verification buttons and comment forms
- Add verification audit log display showing history of verifications/disputes
- Implement auto-hide warning for disputed reports
- Update MapPage to handle verification actions and refresh data
- Add verification counts to report responses
- Filter out disputed reports from public view by default
- Add verification test script

Requirements: 7.1, 7.2, 7.3, 7.4, 7.5
This commit is contained in:
2025-11-16 22:11:40 +00:00
parent 53cc84e832
commit f603ea8fda
8 changed files with 567 additions and 38 deletions

View File

@@ -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 */}
<div className="flex items-center gap-6 p-4 bg-gray-50 rounded-lg">
<div className="flex items-center gap-2">
<svg className="w-5 h-5 text-green-600" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
<span className="text-sm text-gray-700">
<span className="font-semibold">{report.verificationCount}</span> verifications
</span>
<div className="border border-gray-200 rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="font-semibold text-gray-900">Verification Status</h3>
{report.verifications && report.verifications.length > 0 && (
<button
onClick={() => setShowAuditLog(!showAuditLog)}
className="text-sm text-blue-600 hover:text-blue-700 font-medium flex items-center gap-1"
>
{showAuditLog ? 'Hide' : 'Show'} Audit Log
<svg
className={`w-4 h-4 transition-transform ${showAuditLog ? 'rotate-180' : ''}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
)}
</div>
<div className="flex items-center gap-2">
<svg className="w-5 h-5 text-red-600" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
<span className="text-sm text-gray-700">
<span className="font-semibold">{report.disputeCount}</span> disputes
</span>
<div className="flex items-center gap-6">
<div className="flex items-center gap-2">
<svg className="w-5 h-5 text-green-600" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
<span className="text-sm text-gray-700">
<span className="font-semibold">{report.verificationCount}</span> verifications
</span>
</div>
<div className="flex items-center gap-2">
<svg className="w-5 h-5 text-red-600" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
<span className="text-sm text-gray-700">
<span className="font-semibold">{report.disputeCount}</span> disputes
</span>
</div>
</div>
{/* Auto-hide warning for disputed reports */}
{report.disputeCount >= 3 && (
<div className="mt-3 p-3 bg-red-50 border border-red-200 rounded-lg flex items-start gap-2">
<svg className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
<div>
<p className="text-sm font-medium text-red-800">Report Disputed</p>
<p className="text-xs text-red-700 mt-1">
This report has received 3 or more disputes and is hidden from public view pending review.
</p>
</div>
</div>
)}
{/* Audit Log */}
{showAuditLog && report.verifications && report.verifications.length > 0 && (
<div className="mt-4 space-y-3">
<div className="border-t border-gray-200 pt-3">
<h4 className="text-sm font-medium text-gray-700 mb-3">Verification History</h4>
<div className="space-y-2 max-h-60 overflow-y-auto">
{report.verifications
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.map((verification) => (
<div
key={verification.id}
className={`p-3 rounded-lg border ${
verification.verificationType === 'verify'
? 'bg-green-50 border-green-200'
: 'bg-red-50 border-red-200'
}`}
>
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2">
{verification.verificationType === 'verify' ? (
<svg className="w-4 h-4 text-green-600 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
) : (
<svg className="w-4 h-4 text-red-600 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
)}
<span className="text-sm font-medium text-gray-900">
{verification.user.username}
</span>
<span className={`text-xs font-medium ${
verification.verificationType === 'verify' ? 'text-green-700' : 'text-red-700'
}`}>
{verification.verificationType === 'verify' ? 'verified' : 'disputed'}
</span>
</div>
<span className="text-xs text-gray-500 whitespace-nowrap">
{format(new Date(verification.createdAt), 'MMM d, yyyy')}
</span>
</div>
{verification.comment && (
<p className="text-sm text-gray-700 mt-2 pl-6">
"{verification.comment}"
</p>
)}
</div>
))}
</div>
</div>
</div>
)}
</div>
{/* Action Buttons */}

View File

@@ -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<string | null>(null);
const [selectedReport, setSelectedReport] = useState<ReportResponseDTO | null>(null);
const [isLoadingReport, setIsLoadingReport] = useState(false);
const [filters, setFilters] = useState<FilterState>({});
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<string | null>(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<typeof reportService.getReports>[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<typeof reportService.getReports>[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() {
</div>
</div>
{selectedReportId && (
<div className="fixed bottom-4 left-4 bg-white p-4 rounded-lg shadow-lg z-[1000]">
<p className="text-sm text-gray-600">
Selected Report ID: {selectedReportId}
</p>
<button
onClick={() => setSelectedReportId(null)}
className="mt-2 text-xs text-blue-600 hover:text-blue-800"
>
Close
</button>
{/* Report Detail Modal */}
{selectedReport && (
<ReportDetailModal
report={selectedReport}
isOpen={!!selectedReport}
onClose={handleCloseModal}
onVerify={isSignedIn ? handleVerify : undefined}
onDispute={isSignedIn ? handleDispute : undefined}
/>
)}
{/* Loading indicator for report details */}
{isLoadingReport && selectedReportId && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white p-6 rounded-lg shadow-xl">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Loading report details...</p>
</div>
</div>
)}

View File

@@ -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 {