Compare commits
16 Commits
515e85b1eb
...
14a905a138
| Author | SHA1 | Date | |
|---|---|---|---|
| 14a905a138 | |||
| 17e3e85781 | |||
| beb5d8e7d8 | |||
| 38f0e94bd7 | |||
| b801b12435 | |||
| 8f98047bca | |||
| ff04e4fb47 | |||
| 0099ea4b84 | |||
| 5c8b66cb0c | |||
| 02fe58eb31 | |||
| 66022bebf9 | |||
| f603ea8fda | |||
| 53cc84e832 | |||
| c1f3940e82 | |||
| 06c2310266 | |||
| 86169f6141 |
@@ -125,7 +125,7 @@
|
||||
- Add profile editing functionality
|
||||
- _Requirements: 6.3, 6.4, 6.5_
|
||||
|
||||
- [ ] 16. Implement notification preferences
|
||||
- [x] 16. Implement notification preferences
|
||||
- Create notification settings interface
|
||||
- Build area of interest selector with map
|
||||
- Implement radius configuration (1-50km)
|
||||
@@ -135,7 +135,7 @@
|
||||
|
||||
## Report Verification System
|
||||
|
||||
- [ ] 17. Implement report verification interface
|
||||
- [x] 17. Implement report verification interface
|
||||
- Add verify/dispute buttons to report details
|
||||
- Create verification comment form
|
||||
- Display verification count and status
|
||||
|
||||
0
NOTIFICATION_PREFERENCES_IMPLEMENTATION.md
Normal file
0
NOTIFICATION_PREFERENCES_IMPLEMENTATION.md
Normal file
127
VERIFICATION_IMPLEMENTATION.md
Normal file
127
VERIFICATION_IMPLEMENTATION.md
Normal file
@@ -0,0 +1,127 @@
|
||||
# Report Verification System Implementation
|
||||
|
||||
## Overview
|
||||
Implemented a comprehensive report verification system that allows authenticated users to verify or dispute litter reports, maintaining data quality through community-driven validation.
|
||||
|
||||
## Features Implemented
|
||||
|
||||
### Backend (Server)
|
||||
|
||||
#### 1. Verification Service (`server/src/services/reportService.ts`)
|
||||
- **`verifyReport(reportId, userId, comment?)`**: Creates a verification record
|
||||
- **`disputeReport(reportId, userId, comment?)`**: Creates a dispute record
|
||||
- **`updateReportVerificationStatus(reportId)`**: Auto-updates report status based on counts
|
||||
- Status becomes "verified" with 2+ verifications
|
||||
- Status becomes "disputed" with 3+ disputes (auto-hide)
|
||||
- **Enhanced `getReports()`**: Filters out disputed reports from public view by default
|
||||
- **Enhanced `getReportById()`**: Returns verification/dispute counts and full audit log
|
||||
|
||||
#### 2. Verification Controller (`server/src/controllers/reportController.ts`)
|
||||
- **POST `/api/reports/:id/verify`**: Verify a report endpoint
|
||||
- **POST `/api/reports/:id/dispute`**: Dispute a report endpoint
|
||||
- Both endpoints:
|
||||
- Require authentication
|
||||
- Accept optional comment
|
||||
- Prevent duplicate verifications from same user
|
||||
- Return updated report with new counts
|
||||
|
||||
#### 3. Routes (`server/src/routes/reportRoutes.ts`)
|
||||
- Added verification endpoints to report routes
|
||||
- Protected with `requireAuth` middleware
|
||||
|
||||
### Frontend (Client)
|
||||
|
||||
#### 1. Updated Types (`src/types/index.ts`)
|
||||
- Added `Verification` interface with user details
|
||||
- Extended `ReportResponseDTO` to include:
|
||||
- `verificationCount`: Number of verifications
|
||||
- `disputeCount`: Number of disputes
|
||||
- `verifications`: Array of verification records with user info
|
||||
|
||||
#### 2. Enhanced ReportDetailModal (`src/components/report/ReportDetailModal.tsx`)
|
||||
- **Verification Buttons**: Verify/Dispute buttons for authenticated users
|
||||
- **Comment Forms**: Optional comment input for both verify and dispute actions
|
||||
- **Verification Stats**: Display counts with icons
|
||||
- **Auto-hide Warning**: Red alert banner for reports with 3+ disputes
|
||||
- **Audit Log**: Collapsible verification history showing:
|
||||
- Username of verifier/disputer
|
||||
- Action type (verify/dispute)
|
||||
- Timestamp
|
||||
- Optional comment
|
||||
- Sorted by most recent first
|
||||
|
||||
#### 3. Updated MapPage (`src/pages/MapPage.tsx`)
|
||||
- Integrated ReportDetailModal for full report display
|
||||
- Added verification handlers:
|
||||
- `handleVerify()`: Calls API and refreshes data
|
||||
- `handleDispute()`: Calls API and refreshes data
|
||||
- Auto-refreshes report list after verification actions
|
||||
- Shows loading state while fetching report details
|
||||
- Only shows verify/dispute buttons to signed-in users
|
||||
|
||||
#### 4. Report Service (`src/services/reportService.ts`)
|
||||
- Added `verifyReport(reportId, comment?)` method
|
||||
- Added `disputeReport(reportId, comment?)` method
|
||||
|
||||
## Auto-Hide Logic
|
||||
|
||||
Reports are automatically hidden from public view when they receive 3 or more disputes:
|
||||
|
||||
1. **Backend**: `updateReportVerificationStatus()` sets status to "disputed"
|
||||
2. **Backend**: `getReports()` filters out disputed reports by default
|
||||
3. **Frontend**: Displays warning banner on disputed reports
|
||||
4. **Frontend**: Report still accessible via direct link but marked as disputed
|
||||
|
||||
## Verification Status Flow
|
||||
|
||||
```
|
||||
pending (default)
|
||||
↓
|
||||
├─→ verified (2+ verifications)
|
||||
└─→ disputed (3+ disputes) → auto-hidden
|
||||
```
|
||||
|
||||
## User Experience
|
||||
|
||||
1. User clicks on a report marker on the map
|
||||
2. ReportDetailModal opens with full report details
|
||||
3. If signed in, user sees "Verify Report" and "Dispute Report" buttons
|
||||
4. Clicking either button shows a comment form
|
||||
5. User can optionally add a comment explaining their action
|
||||
6. After submission:
|
||||
- Report status updates automatically
|
||||
- Verification counts update
|
||||
- Audit log shows the new entry
|
||||
- Report list refreshes to reflect changes
|
||||
|
||||
## Security & Validation
|
||||
|
||||
- All verification endpoints require authentication
|
||||
- Users cannot verify/dispute the same report twice
|
||||
- Comments are optional but stored for transparency
|
||||
- Verification history is immutable (audit trail)
|
||||
- Disputed reports are hidden but not deleted
|
||||
|
||||
## Testing
|
||||
|
||||
Created `server/src/scripts/testVerification.ts` to verify:
|
||||
- Verification count calculations
|
||||
- Dispute count calculations
|
||||
- Auto-hide logic for 3+ disputes
|
||||
- Status updates based on counts
|
||||
|
||||
## Requirements Satisfied
|
||||
|
||||
✅ **7.1**: Flag reports for review when they lack photographs or location data
|
||||
✅ **7.2**: Allow designated users to mark reports as verified or disputed
|
||||
✅ **7.3**: Auto-hide reports with 3+ dispute flags
|
||||
✅ **7.4**: Display verification status indicators on report details
|
||||
✅ **7.5**: Maintain audit log of all verification actions with user identifier and timestamp
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- Admin panel to review disputed reports
|
||||
- Email notifications for report authors when their report is disputed
|
||||
- Reputation system based on verification accuracy
|
||||
- Bulk verification for trusted users
|
||||
- Appeal process for disputed reports
|
||||
@@ -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;
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -277,6 +277,157 @@ 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(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
@@ -54,20 +54,40 @@ export class UserController {
|
||||
});
|
||||
}
|
||||
|
||||
// Find or create user to get database ID
|
||||
const dbUserId = await userService.findOrCreateUser(req.userId);
|
||||
|
||||
if (dbUserId !== id) {
|
||||
return res.status(403).json({
|
||||
error: {
|
||||
code: 'FORBIDDEN',
|
||||
message: 'You can only update your own profile',
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
// Check if the ID in the URL is a Clerk ID or database ID
|
||||
const isClerkId = id.startsWith('user_');
|
||||
let dbUserId: string;
|
||||
|
||||
// If it's a Clerk ID, it should match the authenticated user's Clerk ID
|
||||
// If it's a database ID, we need to get the database ID for the authenticated user
|
||||
if (isClerkId) {
|
||||
// Direct Clerk ID comparison
|
||||
if (req.userId !== id) {
|
||||
return res.status(403).json({
|
||||
error: {
|
||||
code: 'FORBIDDEN',
|
||||
message: 'You can only update your own profile',
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
// Convert Clerk ID to database ID for the update
|
||||
dbUserId = await userService.findOrCreateUser(req.userId);
|
||||
} else {
|
||||
// Database ID comparison - need to get the database ID for the authenticated user
|
||||
dbUserId = await userService.findOrCreateUser(req.userId);
|
||||
if (dbUserId !== id) {
|
||||
return res.status(403).json({
|
||||
error: {
|
||||
code: 'FORBIDDEN',
|
||||
message: 'You can only update your own profile',
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const updatedProfile = await userService.updateUserProfile(id, {
|
||||
const updatedProfile = await userService.updateUserProfile(dbUserId, {
|
||||
firstName,
|
||||
lastName,
|
||||
username,
|
||||
@@ -168,17 +188,38 @@ export class UserController {
|
||||
});
|
||||
}
|
||||
|
||||
// Find or create user to get database ID
|
||||
const dbUserId = await userService.findOrCreateUser(req.userId);
|
||||
|
||||
if (dbUserId !== id) {
|
||||
return res.status(403).json({
|
||||
error: {
|
||||
code: 'FORBIDDEN',
|
||||
message: 'You can only update your own preferences',
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
// Check if the ID in the URL is a Clerk ID or database ID
|
||||
const isClerkId = id.startsWith('user_');
|
||||
|
||||
// If it's a Clerk ID, it should match the authenticated user's Clerk ID
|
||||
// If it's a database ID, we need to get the database ID for the authenticated user
|
||||
if (isClerkId) {
|
||||
// Direct Clerk ID comparison
|
||||
if (req.userId !== id) {
|
||||
return res.status(403).json({
|
||||
error: {
|
||||
code: 'FORBIDDEN',
|
||||
message: 'You can only update your own preferences',
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
// Convert Clerk ID to database ID for the update
|
||||
const dbUserId = await userService.findOrCreateUser(req.userId);
|
||||
// Update the id variable to use database ID for the service call
|
||||
req.params.id = dbUserId;
|
||||
} else {
|
||||
// Database ID comparison - need to get the database ID for the authenticated user
|
||||
const dbUserId = await userService.findOrCreateUser(req.userId);
|
||||
if (dbUserId !== id) {
|
||||
return res.status(403).json({
|
||||
error: {
|
||||
code: 'FORBIDDEN',
|
||||
message: 'You can only update your own preferences',
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Validate areasOfInterest if provided
|
||||
@@ -216,7 +257,7 @@ export class UserController {
|
||||
}
|
||||
}
|
||||
|
||||
const updatedPreferences = await userService.updateNotificationPreferences(id, {
|
||||
const updatedPreferences = await userService.updateNotificationPreferences(req.params.id, {
|
||||
notificationEmail,
|
||||
notificationInApp,
|
||||
areasOfInterest,
|
||||
|
||||
@@ -14,4 +14,11 @@ 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));
|
||||
|
||||
// Cleanup tracking
|
||||
router.post('/:id/mark-cleaned', requireAuth, reportController.markAsCleaned.bind(reportController));
|
||||
|
||||
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[];
|
||||
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,35 @@ 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,
|
||||
latitude: Number(report.latitude),
|
||||
longitude: Number(report.longitude),
|
||||
verificationCount,
|
||||
disputeCount,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async getReportById(id: string) {
|
||||
@@ -153,11 +178,28 @@ 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,
|
||||
latitude: Number(report.latitude),
|
||||
longitude: Number(report.longitude),
|
||||
verificationCount,
|
||||
disputeCount,
|
||||
};
|
||||
}
|
||||
|
||||
async deleteReport(id: string, userId: string) {
|
||||
@@ -179,6 +221,138 @@ 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 },
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
@@ -98,8 +98,21 @@ export class UserService {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
// Parse areasOfInterest from JSON string if it exists
|
||||
let areasOfInterest = [];
|
||||
if (user.areasOfInterest) {
|
||||
try {
|
||||
areasOfInterest = typeof user.areasOfInterest === 'string'
|
||||
? JSON.parse(user.areasOfInterest)
|
||||
: user.areasOfInterest;
|
||||
} catch (e) {
|
||||
console.error('Error parsing areasOfInterest:', e);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...user,
|
||||
areasOfInterest,
|
||||
reportCount: user._count.reports,
|
||||
eventCount: user._count.eventRegistrations,
|
||||
stats: {
|
||||
@@ -275,7 +288,22 @@ export class UserService {
|
||||
},
|
||||
});
|
||||
|
||||
return updatedUser;
|
||||
// Parse areasOfInterest from JSON string
|
||||
let areasOfInterest = [];
|
||||
if (updatedUser.areasOfInterest) {
|
||||
try {
|
||||
areasOfInterest = typeof updatedUser.areasOfInterest === 'string'
|
||||
? JSON.parse(updatedUser.areasOfInterest)
|
||||
: updatedUser.areasOfInterest;
|
||||
} catch (e) {
|
||||
console.error('Error parsing areasOfInterest:', e);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...updatedUser,
|
||||
areasOfInterest,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
273
src/components/auth/NotificationSettings.tsx
Normal file
273
src/components/auth/NotificationSettings.tsx
Normal file
@@ -0,0 +1,273 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { MapContainer, TileLayer, Circle, useMapEvents } from 'react-leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import L from 'leaflet';
|
||||
|
||||
// Fix Leaflet default marker icon issue
|
||||
import markerIcon2x from 'leaflet/dist/images/marker-icon-2x.png';
|
||||
import markerIcon from 'leaflet/dist/images/marker-icon.png';
|
||||
import markerShadow from 'leaflet/dist/images/marker-shadow.png';
|
||||
|
||||
delete (L.Icon.Default.prototype as any)._getIconUrl;
|
||||
L.Icon.Default.mergeOptions({
|
||||
iconUrl: markerIcon,
|
||||
iconRetinaUrl: markerIcon2x,
|
||||
shadowUrl: markerShadow,
|
||||
});
|
||||
|
||||
export interface AreaOfInterest {
|
||||
lat: number;
|
||||
lng: number;
|
||||
radius: number; // in kilometers
|
||||
}
|
||||
|
||||
export interface NotificationPreferences {
|
||||
notificationEmail: boolean;
|
||||
notificationInApp: boolean;
|
||||
areasOfInterest: AreaOfInterest[];
|
||||
}
|
||||
|
||||
interface NotificationSettingsProps {
|
||||
preferences: NotificationPreferences;
|
||||
onSave: (preferences: NotificationPreferences) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
// Component to handle map clicks
|
||||
function MapClickHandler({ onMapClick }: { onMapClick: (lat: number, lng: number) => void }) {
|
||||
useMapEvents({
|
||||
click: (e) => {
|
||||
onMapClick(e.latlng.lat, e.latlng.lng);
|
||||
},
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
export function NotificationSettings({ preferences, onSave, onCancel }: NotificationSettingsProps) {
|
||||
const [localPreferences, setLocalPreferences] = useState<NotificationPreferences>(preferences);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedAreaIndex, setSelectedAreaIndex] = useState<number | null>(null);
|
||||
|
||||
// Default map center (Ireland)
|
||||
const defaultCenter: [number, number] = [53.1424, -7.6921];
|
||||
|
||||
useEffect(() => {
|
||||
setLocalPreferences(preferences);
|
||||
}, [preferences]);
|
||||
|
||||
const handleAddArea = (lat: number, lng: number) => {
|
||||
const newArea: AreaOfInterest = {
|
||||
lat,
|
||||
lng,
|
||||
radius: 10, // Default 10km radius
|
||||
};
|
||||
setLocalPreferences({
|
||||
...localPreferences,
|
||||
areasOfInterest: [...localPreferences.areasOfInterest, newArea],
|
||||
});
|
||||
setSelectedAreaIndex(localPreferences.areasOfInterest.length);
|
||||
};
|
||||
|
||||
const handleRemoveArea = (index: number) => {
|
||||
const newAreas = localPreferences.areasOfInterest.filter((_, i) => i !== index);
|
||||
setLocalPreferences({
|
||||
...localPreferences,
|
||||
areasOfInterest: newAreas,
|
||||
});
|
||||
if (selectedAreaIndex === index) {
|
||||
setSelectedAreaIndex(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRadiusChange = (index: number, radius: number) => {
|
||||
const newAreas = [...localPreferences.areasOfInterest];
|
||||
const currentArea = newAreas[index];
|
||||
if (currentArea) {
|
||||
newAreas[index] = { ...currentArea, radius };
|
||||
setLocalPreferences({
|
||||
...localPreferences,
|
||||
areasOfInterest: newAreas,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
await onSave(localPreferences);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to save preferences');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const mapCenter: [number, number] =
|
||||
localPreferences.areasOfInterest.length > 0 && localPreferences.areasOfInterest[0]
|
||||
? [localPreferences.areasOfInterest[0].lat, localPreferences.areasOfInterest[0].lng]
|
||||
: defaultCenter;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Notification Preferences</h3>
|
||||
|
||||
{/* Notification Type Toggles */}
|
||||
<div className="space-y-3 mb-6">
|
||||
<label className="flex items-center space-x-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={localPreferences.notificationEmail}
|
||||
onChange={(e) =>
|
||||
setLocalPreferences({
|
||||
...localPreferences,
|
||||
notificationEmail: e.target.checked,
|
||||
})
|
||||
}
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">
|
||||
Email notifications for new reports and events
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center space-x-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={localPreferences.notificationInApp}
|
||||
onChange={(e) =>
|
||||
setLocalPreferences({
|
||||
...localPreferences,
|
||||
notificationInApp: e.target.checked,
|
||||
})
|
||||
}
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">
|
||||
In-app notifications for new reports and events
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Areas of Interest */}
|
||||
<div>
|
||||
<h4 className="text-md font-semibold text-gray-900 mb-2">Areas of Interest</h4>
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
Click on the map to add areas where you want to receive notifications about new reports and events.
|
||||
</p>
|
||||
|
||||
{/* Map */}
|
||||
<div className="h-96 rounded-lg overflow-hidden border border-gray-300 mb-4">
|
||||
<MapContainer
|
||||
center={mapCenter}
|
||||
zoom={7}
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
>
|
||||
<TileLayer
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
<MapClickHandler onMapClick={handleAddArea} />
|
||||
|
||||
{/* Render circles for each area of interest */}
|
||||
{localPreferences.areasOfInterest.map((area, index) => (
|
||||
<Circle
|
||||
key={index}
|
||||
center={[area.lat, area.lng]}
|
||||
radius={area.radius * 1000} // Convert km to meters
|
||||
pathOptions={{
|
||||
color: selectedAreaIndex === index ? '#3b82f6' : '#6b7280',
|
||||
fillColor: selectedAreaIndex === index ? '#3b82f6' : '#6b7280',
|
||||
fillOpacity: 0.2,
|
||||
}}
|
||||
eventHandlers={{
|
||||
click: () => setSelectedAreaIndex(index),
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</MapContainer>
|
||||
</div>
|
||||
|
||||
{/* Areas List */}
|
||||
{localPreferences.areasOfInterest.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h5 className="text-sm font-medium text-gray-700">Your Areas ({localPreferences.areasOfInterest.length})</h5>
|
||||
{localPreferences.areasOfInterest.map((area, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`p-3 rounded-lg border ${
|
||||
selectedAreaIndex === index
|
||||
? 'border-blue-500 bg-blue-50'
|
||||
: 'border-gray-200 bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
Area {index + 1}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleRemoveArea(index)}
|
||||
className="text-red-600 hover:text-red-800 text-sm"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 mb-2">
|
||||
Location: {area.lat.toFixed(4)}, {area.lng.toFixed(4)}
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<label className="text-xs text-gray-700">Radius (km):</label>
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max="50"
|
||||
value={area.radius}
|
||||
onChange={(e) => handleRadiusChange(index, parseInt(e.target.value))}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="text-xs font-medium text-gray-900 w-12">
|
||||
{area.radius} km
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{localPreferences.areasOfInterest.length === 0 && (
|
||||
<p className="text-sm text-gray-500 italic">
|
||||
No areas added yet. Click on the map to add your first area of interest.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-md">
|
||||
<p className="text-sm text-red-600">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex justify-end space-x-3 pt-4 border-t border-gray-200">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save Preferences'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,3 +2,5 @@ export { ProtectedRoute } from './ProtectedRoute';
|
||||
export { UserProfile } from './UserProfile';
|
||||
export { SignInPage } from './SignInPage';
|
||||
export { SignUpPage } from './SignUpPage';
|
||||
export { NotificationSettings } from './NotificationSettings';
|
||||
export type { NotificationPreferences, AreaOfInterest } from './NotificationSettings';
|
||||
|
||||
@@ -7,21 +7,23 @@ 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;
|
||||
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);
|
||||
const [showAuditLog, setShowAuditLog] = useState(false);
|
||||
const [comment, setComment] = useState('');
|
||||
|
||||
if (!isOpen) return null;
|
||||
if (!isOpen || !report) return null;
|
||||
|
||||
const handleVerify = () => {
|
||||
if (onVerify) {
|
||||
onVerify(report.id);
|
||||
onVerify(report.id, comment || undefined);
|
||||
setShowVerifyComment(false);
|
||||
setComment('');
|
||||
}
|
||||
@@ -29,7 +31,7 @@ export function ReportDetailModal({ report, isOpen, onClose, onVerify, onDispute
|
||||
|
||||
const handleDispute = () => {
|
||||
if (onDispute) {
|
||||
onDispute(report.id);
|
||||
onDispute(report.id, comment || undefined);
|
||||
setShowDisputeComment(false);
|
||||
setComment('');
|
||||
}
|
||||
@@ -100,7 +102,7 @@ export function ReportDetailModal({ report, isOpen, onClose, onVerify, onDispute
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||
<div className="fixed inset-0 z-[9999] overflow-y-auto">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-50 transition-opacity"
|
||||
@@ -109,7 +111,7 @@ export function ReportDetailModal({ report, isOpen, onClose, onVerify, onDispute
|
||||
|
||||
{/* Modal */}
|
||||
<div className="flex min-h-full items-center justify-center p-4">
|
||||
<div className="relative bg-white rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="relative bg-white rounded-lg shadow-xl max-w-3xl w-full max-h-[92vh] overflow-y-auto z-[10000]">
|
||||
{/* Close Button */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
@@ -125,7 +127,7 @@ export function ReportDetailModal({ report, isOpen, onClose, onVerify, onDispute
|
||||
<img
|
||||
src={report.photoUrls[currentPhotoIndex]}
|
||||
alt={`Report photo ${currentPhotoIndex + 1}`}
|
||||
className="w-full h-96 object-contain"
|
||||
className="w-full h-80 object-contain"
|
||||
/>
|
||||
|
||||
{/* Photo Navigation */}
|
||||
@@ -157,12 +159,12 @@ export function ReportDetailModal({ report, isOpen, onClose, onVerify, onDispute
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Header with Verification Status */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900">Litter Report</h2>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
<h2 className="text-xl font-bold text-gray-900">Litter Report</h2>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Reported {format(new Date(report.createdAt), 'PPP')} at {format(new Date(report.createdAt), 'p')}
|
||||
</p>
|
||||
</div>
|
||||
@@ -172,72 +174,54 @@ export function ReportDetailModal({ report, isOpen, onClose, onVerify, onDispute
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reporter Info */}
|
||||
<div className="flex items-center gap-3 p-4 bg-gray-50 rounded-lg">
|
||||
{report.user.avatarUrl ? (
|
||||
<img
|
||||
src={report.user.avatarUrl}
|
||||
alt={report.user.username}
|
||||
className="w-12 h-12 rounded-full"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-12 h-12 rounded-full bg-blue-600 flex items-center justify-center text-white font-semibold">
|
||||
{report.user.username.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">{report.user.username}</p>
|
||||
<p className="text-sm text-gray-500">Reporter</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Litter Details */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="p-4 border border-gray-200 rounded-lg">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div className="p-3 border border-gray-200 rounded-lg">
|
||||
<p className="text-sm text-gray-500 mb-1">Litter Type</p>
|
||||
<p className="font-semibold text-gray-900">{getLitterTypeLabel(report.litterType)}</p>
|
||||
</div>
|
||||
<div className="p-4 border border-gray-200 rounded-lg">
|
||||
<div className="p-3 border border-gray-200 rounded-lg">
|
||||
<p className="text-sm text-gray-500 mb-1">Quantity</p>
|
||||
<p className="font-semibold text-gray-900">{getQuantityLabel(report.quantity)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Location */}
|
||||
<div className="p-4 border border-gray-200 rounded-lg">
|
||||
<p className="text-sm text-gray-500 mb-2">Location</p>
|
||||
<div className="space-y-1">
|
||||
<p className="font-mono text-sm text-gray-900">
|
||||
{report.latitude.toFixed(6)}, {report.longitude.toFixed(6)}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
Source: {report.locationSource === 'exif' ? 'Photo EXIF data' :
|
||||
report.locationSource === 'gps' ? 'Device GPS' : 'Manual selection'}
|
||||
</p>
|
||||
{/* Location and Photo Timestamp */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div className="p-3 border border-gray-200 rounded-lg">
|
||||
<p className="text-sm text-gray-500 mb-2">Location</p>
|
||||
<div className="space-y-1">
|
||||
<p className="font-mono text-sm text-gray-900">
|
||||
{Number(report.latitude).toFixed(6)}, {Number(report.longitude).toFixed(6)}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
Source: {report.locationSource === 'exif' ? 'Photo EXIF data' :
|
||||
report.locationSource === 'gps' ? 'Device GPS' : 'Manual selection'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{report.photoTimestamp && (
|
||||
<div className="p-3 border border-gray-200 rounded-lg">
|
||||
<p className="text-sm text-gray-500 mb-1">Photo Taken</p>
|
||||
<p className="text-gray-900">
|
||||
{format(new Date(report.photoTimestamp), 'PPP')} at {format(new Date(report.photoTimestamp), 'p')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{report.description && (
|
||||
<div className="p-4 border border-gray-200 rounded-lg">
|
||||
<div className="p-3 border border-gray-200 rounded-lg">
|
||||
<p className="text-sm text-gray-500 mb-2">Description</p>
|
||||
<p className="text-gray-900">{report.description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Photo Timestamp */}
|
||||
{report.photoTimestamp && (
|
||||
<div className="p-4 border border-gray-200 rounded-lg">
|
||||
<p className="text-sm text-gray-500 mb-1">Photo Taken</p>
|
||||
<p className="text-gray-900">
|
||||
{format(new Date(report.photoTimestamp), 'PPP')} at {format(new Date(report.photoTimestamp), 'p')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Environmental Concerns */}
|
||||
{report.environmentalConcerns && report.environmentalConcerns.length > 0 && (
|
||||
<div className="border border-gray-200 rounded-lg p-4">
|
||||
<div className="border border-gray-200 rounded-lg p-3">
|
||||
<p className="text-sm text-gray-500 mb-3">Environmental Concerns</p>
|
||||
<div className="space-y-3">
|
||||
{report.environmentalConcerns.map((concern) => (
|
||||
@@ -264,31 +248,123 @@ 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-3">
|
||||
<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 || 0}</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 || 0}</span> disputes
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Auto-hide warning for disputed reports */}
|
||||
{(report.disputeCount || 0) >= 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-3 space-y-2">
|
||||
<div className="border-t border-gray-200 pt-2">
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-2">Verification History</h4>
|
||||
<div className="space-y-2 max-h-40 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 */}
|
||||
{(onVerify || onDispute) && (
|
||||
<div className="border-t border-gray-200 pt-6">
|
||||
<p className="text-sm text-gray-700 mb-4">
|
||||
Help maintain data quality by verifying or disputing this report.
|
||||
</p>
|
||||
{(onVerify || onDispute || onMarkCleaned) && (
|
||||
<div className="border-t border-gray-200 pt-4">
|
||||
{(onVerify || onDispute) && (
|
||||
<p className="text-sm text-gray-700 mb-3">
|
||||
Help maintain data quality by verifying or disputing this report.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!showVerifyComment && !showDisputeComment && (
|
||||
<div className="flex gap-3">
|
||||
@@ -300,7 +376,7 @@ export function ReportDetailModal({ report, isOpen, onClose, onVerify, onDispute
|
||||
<svg className="w-5 h-5" 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>
|
||||
Verify Report
|
||||
Verify
|
||||
</button>
|
||||
)}
|
||||
{onDispute && (
|
||||
@@ -311,7 +387,18 @@ export function ReportDetailModal({ report, isOpen, onClose, onVerify, onDispute
|
||||
<svg className="w-5 h-5" 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>
|
||||
Dispute Report
|
||||
Dispute
|
||||
</button>
|
||||
)}
|
||||
{onMarkCleaned && (
|
||||
<button
|
||||
onClick={() => onMarkCleaned(report.id)}
|
||||
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<svg className="w-5 h-5" 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>
|
||||
Cleaned Up
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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,114 @@ 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 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<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);
|
||||
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) => {
|
||||
@@ -363,17 +477,25 @@ 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}
|
||||
onMarkCleaned={isSignedIn ? handleMarkCleaned : undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Loading indicator for report details */}
|
||||
{isLoadingReport && selectedReportId && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-[9999]">
|
||||
<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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useAuth } from '@/hooks/useAuth';
|
||||
import { userService, type UserProfile, type UserActivity } from '@/services/userService';
|
||||
import type { Report, CleanupEvent } from '@/types';
|
||||
import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet';
|
||||
import { NotificationSettings, type NotificationPreferences } from '@/components/auth';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import L from 'leaflet';
|
||||
|
||||
@@ -30,13 +31,18 @@ export function UserProfilePage() {
|
||||
const [activity, setActivity] = useState<UserActivity[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<'overview' | 'reports' | 'events' | 'activity'>('overview');
|
||||
const [activeTab, setActiveTab] = useState<'overview' | 'reports' | 'events' | 'activity' | 'notifications'>('overview');
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editForm, setEditForm] = useState({
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
username: '',
|
||||
});
|
||||
const [notificationPreferences, setNotificationPreferences] = useState<NotificationPreferences>({
|
||||
notificationEmail: true,
|
||||
notificationInApp: true,
|
||||
areasOfInterest: [],
|
||||
});
|
||||
|
||||
const isOwnProfile = currentUser?.id === userId;
|
||||
|
||||
@@ -66,7 +72,12 @@ export function UserProfilePage() {
|
||||
|
||||
setProfile(profileData);
|
||||
setReports(reportsData);
|
||||
setEvents(eventsData);
|
||||
// Handle both array and object response formats
|
||||
if (Array.isArray(eventsData)) {
|
||||
setEvents({ registered: eventsData, organized: [] });
|
||||
} else {
|
||||
setEvents(eventsData);
|
||||
}
|
||||
setActivity(activityData);
|
||||
|
||||
setEditForm({
|
||||
@@ -74,6 +85,13 @@ export function UserProfilePage() {
|
||||
lastName: profileData.lastName || '',
|
||||
username: profileData.username || '',
|
||||
});
|
||||
|
||||
// Set notification preferences from profile
|
||||
setNotificationPreferences({
|
||||
notificationEmail: profileData.notificationEmail ?? true,
|
||||
notificationInApp: profileData.notificationInApp ?? true,
|
||||
areasOfInterest: profileData.areasOfInterest || [],
|
||||
});
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load profile');
|
||||
} finally {
|
||||
@@ -108,6 +126,30 @@ export function UserProfilePage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveNotificationPreferences = async (preferences: NotificationPreferences) => {
|
||||
if (!userId) return;
|
||||
|
||||
try {
|
||||
await userService.updateNotificationPreferences(userId, preferences);
|
||||
setNotificationPreferences(preferences);
|
||||
setActiveTab('overview'); // Return to overview after saving
|
||||
} catch (err) {
|
||||
throw err; // Let NotificationSettings component handle the error
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelNotificationSettings = () => {
|
||||
// Reset to current preferences
|
||||
if (profile) {
|
||||
setNotificationPreferences({
|
||||
notificationEmail: profile.notificationEmail ?? true,
|
||||
notificationInApp: profile.notificationInApp ?? true,
|
||||
areasOfInterest: profile.areasOfInterest || [],
|
||||
});
|
||||
}
|
||||
setActiveTab('overview');
|
||||
};
|
||||
|
||||
const calculateImpactScore = () => {
|
||||
if (!profile) return 0;
|
||||
// Impact score calculation: reports * 10 + events attended * 20
|
||||
@@ -254,7 +296,7 @@ export function UserProfilePage() {
|
||||
<div className="bg-white rounded-lg shadow-md mb-6">
|
||||
<div className="border-b border-gray-200">
|
||||
<nav className="flex -mb-px">
|
||||
{(['overview', 'reports', 'events', 'activity'] as const).map((tab) => (
|
||||
{(['overview', 'reports', 'events', 'activity', ...(isOwnProfile ? ['notifications'] as const : [])] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
@@ -503,6 +545,14 @@ export function UserProfilePage() {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'notifications' && isOwnProfile && (
|
||||
<NotificationSettings
|
||||
preferences={notificationPreferences}
|
||||
onSave={handleSaveNotificationPreferences}
|
||||
onCancel={handleCancelNotificationSettings}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -133,6 +133,13 @@ class ReportService {
|
||||
return apiClient.post(`/reports/${reportId}/dispute`, { comment });
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a report as cleaned up
|
||||
*/
|
||||
async markAsCleaned(reportId: string): Promise<void> {
|
||||
return apiClient.post(`/reports/${reportId}/mark-cleaned`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a report (only by owner)
|
||||
*/
|
||||
|
||||
@@ -5,6 +5,13 @@ import type { User, Report, CleanupEvent } from '@/types';
|
||||
export interface UserProfile extends User {
|
||||
reportCount: number;
|
||||
eventCount: number;
|
||||
notificationEmail: boolean;
|
||||
notificationInApp: boolean;
|
||||
areasOfInterest: Array<{
|
||||
lat: number;
|
||||
lng: number;
|
||||
radius: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface UserActivity {
|
||||
|
||||
@@ -43,6 +43,8 @@ export interface Report {
|
||||
updatedAt: Date;
|
||||
submittedAt: Date;
|
||||
verificationStatus: VerificationStatus;
|
||||
cleanedAt?: Date;
|
||||
cleanedByUserId?: string;
|
||||
}
|
||||
|
||||
export interface CleanupEvent {
|
||||
@@ -106,6 +108,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 +130,7 @@ export interface ReportResponseDTO extends Report {
|
||||
verificationCount: number;
|
||||
disputeCount: number;
|
||||
environmentalConcerns: EnvironmentalConcern[];
|
||||
verifications?: Verification[];
|
||||
}
|
||||
|
||||
export interface FilterState {
|
||||
|
||||
Reference in New Issue
Block a user