Add 'Mark as Cleaned Up' functionality
- Add cleaned_at and cleaned_by_user_id fields to reports table - Create database migration for cleanup tracking - Add markAsCleaned backend endpoint and service method - Add Mark as Cleaned Up button to ReportDetailModal - Update types to include cleaned fields - Add frontend handler to mark reports as cleaned
This commit is contained in:
@@ -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[]
|
eventRegistrations EventRegistration[]
|
||||||
organizedEvents Event[] @relation("EventOrganizer")
|
organizedEvents Event[] @relation("EventOrganizer")
|
||||||
reports Report[]
|
reports Report[]
|
||||||
|
cleanedReports Report[] @relation("CleanedByUser")
|
||||||
verifications Verification[]
|
verifications Verification[]
|
||||||
|
|
||||||
@@index([clerkId], map: "idx_clerk_id")
|
@@index([clerkId], map: "idx_clerk_id")
|
||||||
@@ -49,15 +50,20 @@ model Report {
|
|||||||
updatedAt DateTime? @default(now()) @updatedAt @map("updated_at") @db.Timestamp(0)
|
updatedAt DateTime? @default(now()) @updatedAt @map("updated_at") @db.Timestamp(0)
|
||||||
submittedAt DateTime? @default(now()) @map("submitted_at") @db.Timestamp(0)
|
submittedAt DateTime? @default(now()) @map("submitted_at") @db.Timestamp(0)
|
||||||
verificationStatus reports_verification_status? @default(pending) @map("verification_status")
|
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[]
|
environmentalConcerns EnvironmentalConcern[]
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "reports_ibfk_1")
|
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[]
|
verifications Verification[]
|
||||||
|
|
||||||
@@index([createdAt], map: "idx_created_at")
|
@@index([createdAt], map: "idx_created_at")
|
||||||
|
@@index([cleanedAt], map: "idx_cleaned_at")
|
||||||
@@index([latitude, longitude], map: "idx_lat_lng")
|
@@index([latitude, longitude], map: "idx_lat_lng")
|
||||||
@@index([litterType], map: "idx_litter_type")
|
@@index([litterType], map: "idx_litter_type")
|
||||||
@@index([location(length: 32)], map: "idx_location")
|
@@index([location(length: 32)], map: "idx_location")
|
||||||
@@index([userId], map: "user_id")
|
@@index([userId], map: "user_id")
|
||||||
|
@@index([cleanedByUserId], map: "cleaned_by_user_id")
|
||||||
@@map("reports")
|
@@map("reports")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -385,6 +385,49 @@ export class ReportController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async markAsCleaned(req: AuthRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
if (!req.userId) {
|
||||||
|
return res.status(401).json({
|
||||||
|
error: {
|
||||||
|
code: 'UNAUTHORIZED',
|
||||||
|
message: 'User not authenticated',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
// Find or create user in database
|
||||||
|
const dbUserId = await this.findOrCreateUser(req.userId);
|
||||||
|
|
||||||
|
const report = await reportService.markAsCleaned(id, dbUserId);
|
||||||
|
|
||||||
|
res.json(report);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error marking report as cleaned:', error);
|
||||||
|
|
||||||
|
if (error.message === 'Report not found') {
|
||||||
|
return res.status(404).json({
|
||||||
|
error: {
|
||||||
|
code: 'RESOURCE_NOT_FOUND',
|
||||||
|
message: 'Report not found',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(500).json({
|
||||||
|
error: {
|
||||||
|
code: 'INTERNAL_SERVER_ERROR',
|
||||||
|
message: 'Failed to mark report as cleaned',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const reportController = new ReportController();
|
export const reportController = new ReportController();
|
||||||
|
|||||||
@@ -18,4 +18,7 @@ router.delete('/:id', requireAuth, reportController.deleteReport.bind(reportCont
|
|||||||
router.post('/:id/verify', requireAuth, reportController.verifyReport.bind(reportController));
|
router.post('/:id/verify', requireAuth, reportController.verifyReport.bind(reportController));
|
||||||
router.post('/:id/dispute', requireAuth, reportController.disputeReport.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;
|
export default router;
|
||||||
|
|||||||
@@ -330,6 +330,29 @@ export class ReportService {
|
|||||||
data: { verificationStatus: status },
|
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();
|
export const reportService = new ReportService();
|
||||||
|
|||||||
@@ -9,9 +9,10 @@ interface ReportDetailModalProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onVerify?: (reportId: string, comment?: string) => void;
|
onVerify?: (reportId: string, comment?: string) => void;
|
||||||
onDispute?: (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 [currentPhotoIndex, setCurrentPhotoIndex] = useState(0);
|
||||||
const [showVerifyComment, setShowVerifyComment] = useState(false);
|
const [showVerifyComment, setShowVerifyComment] = useState(false);
|
||||||
const [showDisputeComment, setShowDisputeComment] = useState(false);
|
const [showDisputeComment, setShowDisputeComment] = useState(false);
|
||||||
@@ -357,13 +358,31 @@ export function ReportDetailModal({ report, isOpen, onClose, onVerify, onDispute
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action Buttons */}
|
{/* Action Buttons */}
|
||||||
{(onVerify || onDispute) && (
|
{(onVerify || onDispute || onMarkCleaned) && (
|
||||||
<div className="border-t border-gray-200 pt-4">
|
<div className="border-t border-gray-200 pt-4">
|
||||||
|
{(onVerify || onDispute) && (
|
||||||
<p className="text-sm text-gray-700 mb-3">
|
<p className="text-sm text-gray-700 mb-3">
|
||||||
Help maintain data quality by verifying or disputing this report.
|
Help maintain data quality by verifying or disputing this report.
|
||||||
</p>
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{!showVerifyComment && !showDisputeComment && (
|
{!showVerifyComment && !showDisputeComment && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* Mark as Cleaned Button */}
|
||||||
|
{onMarkCleaned && (
|
||||||
|
<button
|
||||||
|
onClick={() => onMarkCleaned(report.id)}
|
||||||
|
className="w-full 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>
|
||||||
|
Mark as Cleaned Up
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Verify/Dispute Buttons */}
|
||||||
|
{(onVerify || onDispute) && (
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
{onVerify && (
|
{onVerify && (
|
||||||
<button
|
<button
|
||||||
@@ -389,6 +408,8 @@ export function ReportDetailModal({ report, isOpen, onClose, onVerify, onDispute
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Verify Comment Form */}
|
{/* Verify Comment Form */}
|
||||||
{showVerifyComment && (
|
{showVerifyComment && (
|
||||||
|
|||||||
@@ -322,6 +322,37 @@ export function MapPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleMarkCleaned = async (reportId: string) => {
|
||||||
|
try {
|
||||||
|
await reportService.markAsCleaned(reportId);
|
||||||
|
// Refresh the report details
|
||||||
|
const updatedReport = await reportService.getReport(reportId);
|
||||||
|
setSelectedReport(updatedReport);
|
||||||
|
// Refresh the reports list
|
||||||
|
const apiFilters: Parameters<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) => {
|
const handleMapClick = (lat: number, lng: number) => {
|
||||||
console.log('Map clicked at:', lat, lng);
|
console.log('Map clicked at:', lat, lng);
|
||||||
// In later tasks, this can be used for manual location selection
|
// In later tasks, this can be used for manual location selection
|
||||||
@@ -454,6 +485,7 @@ export function MapPage() {
|
|||||||
onClose={handleCloseModal}
|
onClose={handleCloseModal}
|
||||||
onVerify={isSignedIn ? handleVerify : undefined}
|
onVerify={isSignedIn ? handleVerify : undefined}
|
||||||
onDispute={isSignedIn ? handleDispute : undefined}
|
onDispute={isSignedIn ? handleDispute : undefined}
|
||||||
|
onMarkCleaned={isSignedIn ? handleMarkCleaned : undefined}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -133,6 +133,13 @@ class ReportService {
|
|||||||
return apiClient.post(`/reports/${reportId}/dispute`, { comment });
|
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)
|
* Delete a report (only by owner)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ export interface Report {
|
|||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
submittedAt: Date;
|
submittedAt: Date;
|
||||||
verificationStatus: VerificationStatus;
|
verificationStatus: VerificationStatus;
|
||||||
|
cleanedAt?: Date;
|
||||||
|
cleanedByUserId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CleanupEvent {
|
export interface CleanupEvent {
|
||||||
|
|||||||
Reference in New Issue
Block a user