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:
@@ -135,7 +135,7 @@
|
|||||||
|
|
||||||
## Report Verification System
|
## Report Verification System
|
||||||
|
|
||||||
- [ ] 17. Implement report verification interface
|
- [-] 17. Implement report verification interface
|
||||||
- Add verify/dispute buttons to report details
|
- Add verify/dispute buttons to report details
|
||||||
- Create verification comment form
|
- Create verification comment form
|
||||||
- Display verification count and status
|
- Display verification count and status
|
||||||
|
|||||||
@@ -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();
|
export const reportController = new ReportController();
|
||||||
|
|||||||
@@ -14,4 +14,8 @@ router.get('/', reportController.getReports.bind(reportController));
|
|||||||
router.get('/:id', reportController.getReportById.bind(reportController));
|
router.get('/:id', reportController.getReportById.bind(reportController));
|
||||||
router.delete('/:id', requireAuth, reportController.deleteReport.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;
|
export default router;
|
||||||
|
|||||||
75
server/src/scripts/testVerification.ts
Normal file
75
server/src/scripts/testVerification.ts
Normal file
@@ -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();
|
||||||
@@ -79,6 +79,7 @@ export class ReportService {
|
|||||||
quantities?: string[];
|
quantities?: string[];
|
||||||
verificationStatus?: string[];
|
verificationStatus?: string[];
|
||||||
bounds?: { north: number; south: number; east: number; west: number };
|
bounds?: { north: number; south: number; east: number; west: number };
|
||||||
|
includeDisputed?: boolean; // Admin flag to include disputed reports
|
||||||
}) {
|
}) {
|
||||||
const where: any = {};
|
const where: any = {};
|
||||||
|
|
||||||
@@ -98,6 +99,9 @@ export class ReportService {
|
|||||||
|
|
||||||
if (filters?.verificationStatus?.length) {
|
if (filters?.verificationStatus?.length) {
|
||||||
where.verificationStatus = { in: filters.verificationStatus };
|
where.verificationStatus = { in: filters.verificationStatus };
|
||||||
|
} else if (!filters?.includeDisputed) {
|
||||||
|
// By default, exclude disputed reports (auto-hidden)
|
||||||
|
where.verificationStatus = { not: 'disputed' };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filters?.bounds) {
|
if (filters?.bounds) {
|
||||||
@@ -122,14 +126,33 @@ export class ReportService {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
environmentalConcerns: true,
|
environmentalConcerns: true,
|
||||||
verifications: true,
|
verifications: {
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
orderBy: {
|
orderBy: {
|
||||||
createdAt: 'desc',
|
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) {
|
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) {
|
async deleteReport(id: string, userId: string) {
|
||||||
@@ -179,6 +217,115 @@ export class ReportService {
|
|||||||
where: { id },
|
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();
|
export const reportService = new ReportService();
|
||||||
|
|||||||
@@ -7,21 +7,22 @@ interface ReportDetailModalProps {
|
|||||||
report: ReportResponseDTO;
|
report: ReportResponseDTO;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onVerify?: (reportId: string) => void;
|
onVerify?: (reportId: string, comment?: string) => void;
|
||||||
onDispute?: (reportId: string) => void;
|
onDispute?: (reportId: string, comment?: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ReportDetailModal({ report, isOpen, onClose, onVerify, onDispute }: ReportDetailModalProps) {
|
export function ReportDetailModal({ report, isOpen, onClose, onVerify, onDispute }: ReportDetailModalProps) {
|
||||||
const [currentPhotoIndex, setCurrentPhotoIndex] = useState(0);
|
const [currentPhotoIndex, setCurrentPhotoIndex] = useState(0);
|
||||||
const [showVerifyComment, setShowVerifyComment] = useState(false);
|
const [showVerifyComment, setShowVerifyComment] = useState(false);
|
||||||
const [showDisputeComment, setShowDisputeComment] = useState(false);
|
const [showDisputeComment, setShowDisputeComment] = useState(false);
|
||||||
|
const [showAuditLog, setShowAuditLog] = useState(false);
|
||||||
const [comment, setComment] = useState('');
|
const [comment, setComment] = useState('');
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
const handleVerify = () => {
|
const handleVerify = () => {
|
||||||
if (onVerify) {
|
if (onVerify) {
|
||||||
onVerify(report.id);
|
onVerify(report.id, comment || undefined);
|
||||||
setShowVerifyComment(false);
|
setShowVerifyComment(false);
|
||||||
setComment('');
|
setComment('');
|
||||||
}
|
}
|
||||||
@@ -29,7 +30,7 @@ export function ReportDetailModal({ report, isOpen, onClose, onVerify, onDispute
|
|||||||
|
|
||||||
const handleDispute = () => {
|
const handleDispute = () => {
|
||||||
if (onDispute) {
|
if (onDispute) {
|
||||||
onDispute(report.id);
|
onDispute(report.id, comment || undefined);
|
||||||
setShowDisputeComment(false);
|
setShowDisputeComment(false);
|
||||||
setComment('');
|
setComment('');
|
||||||
}
|
}
|
||||||
@@ -264,23 +265,113 @@ export function ReportDetailModal({ report, isOpen, onClose, onVerify, onDispute
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Verification Stats */}
|
{/* Verification Stats */}
|
||||||
<div className="flex items-center gap-6 p-4 bg-gray-50 rounded-lg">
|
<div className="border border-gray-200 rounded-lg p-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<svg className="w-5 h-5 text-green-600" fill="currentColor" viewBox="0 0 20 20">
|
<h3 className="font-semibold text-gray-900">Verification Status</h3>
|
||||||
<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" />
|
{report.verifications && report.verifications.length > 0 && (
|
||||||
</svg>
|
<button
|
||||||
<span className="text-sm text-gray-700">
|
onClick={() => setShowAuditLog(!showAuditLog)}
|
||||||
<span className="font-semibold">{report.verificationCount}</span> verifications
|
className="text-sm text-blue-600 hover:text-blue-700 font-medium flex items-center gap-1"
|
||||||
</span>
|
>
|
||||||
|
{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>
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<svg className="w-5 h-5 text-red-600" fill="currentColor" viewBox="0 0 20 20">
|
<div className="flex items-center gap-6">
|
||||||
<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" />
|
<div className="flex items-center gap-2">
|
||||||
</svg>
|
<svg className="w-5 h-5 text-green-600" fill="currentColor" viewBox="0 0 20 20">
|
||||||
<span className="text-sm text-gray-700">
|
<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" />
|
||||||
<span className="font-semibold">{report.disputeCount}</span> disputes
|
</svg>
|
||||||
</span>
|
<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>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Action Buttons */}
|
{/* Action Buttons */}
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useAuth } from '@clerk/clerk-react';
|
||||||
import { MapView, MapFilters } from '@/components/map';
|
import { MapView, MapFilters } from '@/components/map';
|
||||||
|
import { ReportDetailModal } from '@/components/report';
|
||||||
import { analyticsService } from '@/services/analyticsService';
|
import { analyticsService } from '@/services/analyticsService';
|
||||||
import { reportService } from '@/services/reportService';
|
import { reportService } from '@/services/reportService';
|
||||||
import { eventService } from '@/services/eventService';
|
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
|
* Map page - Interactive map displaying litter reports and cleanup events
|
||||||
*/
|
*/
|
||||||
export function MapPage() {
|
export function MapPage() {
|
||||||
|
const { getToken, isSignedIn } = useAuth();
|
||||||
const [selectedReportId, setSelectedReportId] = useState<string | null>(null);
|
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 [filters, setFilters] = useState<FilterState>({});
|
||||||
const [showClusters, setShowClusters] = useState(true);
|
const [showClusters, setShowClusters] = useState(true);
|
||||||
const [showHeatmap, setShowHeatmap] = useState(false);
|
const [showHeatmap, setShowHeatmap] = useState(false);
|
||||||
@@ -23,6 +28,11 @@ export function MapPage() {
|
|||||||
const [isLoadingEvents, setIsLoadingEvents] = useState(true);
|
const [isLoadingEvents, setIsLoadingEvents] = useState(true);
|
||||||
const [modalImageUrl, setModalImageUrl] = useState<string | null>(null);
|
const [modalImageUrl, setModalImageUrl] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Set up token getter for report service
|
||||||
|
useEffect(() => {
|
||||||
|
reportService.setTokenGetter(getToken);
|
||||||
|
}, [getToken]);
|
||||||
|
|
||||||
// Fetch reports from API
|
// Fetch reports from API
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchReports = async () => {
|
const fetchReports = async () => {
|
||||||
@@ -233,10 +243,83 @@ export function MapPage() {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const handleMarkerClick = (reportId: string) => {
|
const handleMarkerClick = async (reportId: string) => {
|
||||||
setSelectedReportId(reportId);
|
setSelectedReportId(reportId);
|
||||||
console.log('Report clicked:', reportId);
|
setIsLoadingReport(true);
|
||||||
// In later tasks, this will open a modal with full report details
|
|
||||||
|
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) => {
|
const handleMapClick = (lat: number, lng: number) => {
|
||||||
@@ -363,17 +446,24 @@ export function MapPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedReportId && (
|
{/* Report Detail Modal */}
|
||||||
<div className="fixed bottom-4 left-4 bg-white p-4 rounded-lg shadow-lg z-[1000]">
|
{selectedReport && (
|
||||||
<p className="text-sm text-gray-600">
|
<ReportDetailModal
|
||||||
Selected Report ID: {selectedReportId}
|
report={selectedReport}
|
||||||
</p>
|
isOpen={!!selectedReport}
|
||||||
<button
|
onClose={handleCloseModal}
|
||||||
onClick={() => setSelectedReportId(null)}
|
onVerify={isSignedIn ? handleVerify : undefined}
|
||||||
className="mt-2 text-xs text-blue-600 hover:text-blue-800"
|
onDispute={isSignedIn ? handleDispute : undefined}
|
||||||
>
|
/>
|
||||||
Close
|
)}
|
||||||
</button>
|
|
||||||
|
{/* 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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
export interface ReportResponseDTO extends Report {
|
||||||
user: {
|
user: {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -115,6 +128,7 @@ export interface ReportResponseDTO extends Report {
|
|||||||
verificationCount: number;
|
verificationCount: number;
|
||||||
disputeCount: number;
|
disputeCount: number;
|
||||||
environmentalConcerns: EnvironmentalConcern[];
|
environmentalConcerns: EnvironmentalConcern[];
|
||||||
|
verifications?: Verification[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FilterState {
|
export interface FilterState {
|
||||||
|
|||||||
Reference in New Issue
Block a user