diff --git a/civic-platform/.env.example b/civic-platform/.env.example new file mode 100644 index 0000000000000000000000000000000000000000..460ce9fc8c213819257d84ca807702b5ff7487a0 --- /dev/null +++ b/civic-platform/.env.example @@ -0,0 +1,6 @@ +NEXT_PUBLIC_API_BASE_URL=http://localhost:4000/api + +# Optional. If set, the public map uses Mapbox streets tiles. +# Leave blank to use free OpenStreetMap tiles. +MAPBOX_TOKEN= +NEXT_PUBLIC_MAPBOX_TOKEN= diff --git a/civic-platform/.gitignore b/civic-platform/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..288e0233a6e304a0c50066b16ede4a509e6d4d28 --- /dev/null +++ b/civic-platform/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +.next/ +dist/ +.env +.env.local +backend/.env +backend/dist/ diff --git a/civic-platform/README.md b/civic-platform/README.md new file mode 100644 index 0000000000000000000000000000000000000000..d28f152cc395e7f6b5c1ad8c4d28f83c118a03ce --- /dev/null +++ b/civic-platform/README.md @@ -0,0 +1,119 @@ +# Civic Platform + +A mobile-first civic issue reporting and resolution platform for day-to-day public problems and emergency incidents. + +## Vision + +Citizens report issues with photo, video, voice, text, and geo-location. The system classifies the complaint, assigns priority, routes it to the correct government department, and tracks the full lifecycle from submission to closure. + +## Core goals + +- Make complaint reporting simple and fast +- Support day-to-day civic domains and emergency domains +- Route complaints to the correct department with priority +- Provide transparent status tracking to citizens +- Give officials a fast dashboard for triage, assignment, and monitoring +- Keep the system scalable, low-latency, and future-ready + +## Main domains + +1. Roads and Transportation +2. Sanitation and Waste Management +3. Water Supply, Sewerage, and Drainage +4. Street Lighting and Electrical Infrastructure +5. Public Infrastructure and Amenities +6. Environment and Public Health + +## Emergency domains + +1. Fire Emergencies +2. Flood and Water Disaster +3. Structural and Infrastructure Hazard +4. Electrical Hazard +5. Disaster and Rescue + +## Complaint lifecycle + +1. Draft +2. Submitted +3. Validated +4. Classified +5. Prioritized +6. Assigned +7. Accepted +8. In Progress +9. Resolved +10. Citizen Verified +11. Closed + +Additional states: + +- Escalated +- Reopened +- Duplicate +- Rejected +- On Hold + +## Proposed stack + +### Frontend + +- Next.js +- TypeScript +- Tailwind CSS +- shadcn/ui +- Framer Motion +- Leaflet with OpenStreetMap + +### Platform + +- Supabase Auth +- Supabase Postgres +- PostGIS +- Supabase Storage +- Supabase Realtime +- Supabase Edge Functions + +### AI + +- Whisper for speech-to-text +- DistilBERT or similar lightweight text classifier +- YOLOv8n or MobileNet for image classification + +## App areas + +- Citizen portal +- Department dashboard +- Field officer workspace +- Super admin configuration +- Analytics and SLA dashboard + +## Folder plan + +- `app/` Next.js App Router pages +- `components/` shared UI sections +- `lib/` config, types, and helpers +- `data/` static seed content used by the frontend +- `docs/` architecture, lifecycle, and page planning + +## Planning docs + +- `docs/PROJECT_BLUEPRINT.md` +- `docs/FRONTEND_PLAN.md` +- `docs/PAGE_MAP.md` +- `docs/ROLES_AND_PERMISSIONS.md` +- `docs/COMPLAINT_LIFECYCLE.md` +- `docs/DOMAIN_ROUTING_MAP.md` +- `docs/PRIORITY_SCORING.md` +- `docs/DATABASE_SCHEMA.md` +- `docs/BACKEND_STRUCTURE.md` +- `docs/LOCAL_DATABASE_SETUP.md` + +## Next build steps + +1. Finalize data model and routing rules +2. Build the design system and page structure +3. Implement citizen report flow +4. Add admin dashboard and complaint lifecycle updates +5. Connect Supabase auth, database, storage, and realtime +6. Add AI-assisted classification after the MVP workflow is stable diff --git a/civic-platform/ai/README.md b/civic-platform/ai/README.md new file mode 100644 index 0000000000000000000000000000000000000000..59b57eac67a70a9ec7643746fdf445f1e502aad2 --- /dev/null +++ b/civic-platform/ai/README.md @@ -0,0 +1,15 @@ +# CivicPulse AI Workspace + +This folder is reserved for model training assets, dataset schemas, experiments, and evaluation results. + +Recommended subfolders: + +- `datasets/text` +- `datasets/vision` +- `datasets/audio` +- `evals` +- `notebooks` +- `schemas` + +The live product currently uses an assistive heuristic AI layer in the application code. This workspace is where model-backed training and evaluation can be added next. + diff --git a/civic-platform/ai/schemas/civic-image-record.example.json b/civic-platform/ai/schemas/civic-image-record.example.json new file mode 100644 index 0000000000000000000000000000000000000000..e83b0cd2870c749c9d931df637cb07ccbd92f2f9 --- /dev/null +++ b/civic-platform/ai/schemas/civic-image-record.example.json @@ -0,0 +1,11 @@ +{ + "image": "path-or-huggingface-image-object", + "label": "Roads and Transportation", + "metadata": { + "complaintCode": "CP-2026-000001", + "source": "citizen_upload", + "city": "Hyderabad", + "latitude": 17.385, + "longitude": 78.4867 + } +} diff --git a/civic-platform/ai/schemas/complaint-text-record.example.json b/civic-platform/ai/schemas/complaint-text-record.example.json new file mode 100644 index 0000000000000000000000000000000000000000..3a9e051e87b350970824463dca79c8ac5845ba48 --- /dev/null +++ b/civic-platform/ai/schemas/complaint-text-record.example.json @@ -0,0 +1,12 @@ +{ + "title": "Streetlight not working near school gate", + "description": "Two streetlights are off near the main gate and the road is dark at night.", + "address_line": "School Road", + "landmark": "Near Government High School", + "city_name": "Sample City", + "state_name": "Sample State", + "domain": "Street Lighting and Electrical Infrastructure", + "sub_problem": "Broken streetlight", + "priority": "P2", + "is_emergency": false +} diff --git a/civic-platform/ai/training/README.md b/civic-platform/ai/training/README.md new file mode 100644 index 0000000000000000000000000000000000000000..80b5f966ae97bbafeaa35a07dc31c18a70387063 --- /dev/null +++ b/civic-platform/ai/training/README.md @@ -0,0 +1,73 @@ +# CivicPulse Vision Training Plan + +The current app has a safe baseline for image evidence: + +- It extracts GPS metadata from JPEG photos when available. +- It uses address search, GPS, or map pinning when photo GPS metadata is missing. +- It passes lightweight visual signals into the AI assistant when filenames contain civic issue hints. + +For accurate image classification, train a real model on labeled civic images. A perfect model is not realistic, but a good production model is achievable with enough labeled examples and evaluation. + +## Recommended Labels + +Use the same project domains as labels: + +- Roads and Transportation +- Sanitation and Waste Management +- Water Supply, Sewerage, and Drainage +- Street Lighting and Electrical Infrastructure +- Public Infrastructure and Amenities +- Environment and Public Health +- Fire Emergencies +- Flood and Water Disaster +- Structural and Infrastructure Hazard +- Electrical Hazard +- Disaster and Rescue + +## Dataset Format + +For Hugging Face image classification, publish a dataset with: + +- `image`: image column +- `label`: one of the labels above + +Example record: + +[civic-image-record.example.json](/C:/Users/Praveen/Documents/New%20project/civic-platform/ai/schemas/civic-image-record.example.json) + +## Training Recommendation + +Start with image classification: + +- Model: `timm/mobilenetv3_small_100.lamb_in1k` for fast iteration +- Upgrade: `timm/resnet50.a1_in1k` or `timm/vit_base_patch16_dinov3.lvd1689m` for better accuracy +- Target metric: validation accuracy and per-class recall +- Minimum data target: 300 to 500 images per label for a useful first model +- Better data target: 1,000+ images per label with different lighting, angles, weather, and phones + +For localization such as detecting a pothole box inside the image, move to object detection later with D-FINE or RT-DETR. + +## Deployment Path + +1. Collect and label civic images from real reports. +2. Upload the dataset to Hugging Face Hub. +3. Validate the dataset format before training. +4. Fine-tune an image classifier using Hugging Face Jobs. +5. Push the trained model to the Hub. +6. Add a backend inference endpoint that calls the trained model and returns: + - predicted domain + - confidence + - top 3 alternatives + - model version +7. Keep the operator override flow because image models can be wrong. + +## Important Note + +Do not rely only on image classification. The best production result should combine: + +- image prediction +- text/voice description +- issue domain chosen by the citizen +- geo-location and ward +- nearby duplicate complaints +- operator override diff --git a/civic-platform/ai/training/civic-image-labels.md b/civic-platform/ai/training/civic-image-labels.md new file mode 100644 index 0000000000000000000000000000000000000000..ecb59fce9e3fe29c5579b298f112f28e070432c8 --- /dev/null +++ b/civic-platform/ai/training/civic-image-labels.md @@ -0,0 +1,19 @@ +# CivicPulse Image Labels + +Use these labels for the first image classification dataset. + +| Label | Example visual cases | +|---|---| +| Roads and Transportation | potholes, broken roads, road blockage, damaged footpath, damaged divider | +| Sanitation and Waste Management | garbage piles, overflowing bins, illegal dumping, dirty streets | +| Water Supply, Sewerage, and Drainage | water leakage, sewage overflow, clogged drains, waterlogging | +| Street Lighting and Electrical Infrastructure | broken streetlights, damaged poles, exposed wires, transformer issues | +| Public Infrastructure and Amenities | open manholes, broken bus stops, damaged public toilets, park damage | +| Environment and Public Health | fallen trees, stagnant water, smoke, dead animals, mosquito breeding areas | +| Fire Emergencies | visible fire, heavy smoke, burning debris | +| Flood and Water Disaster | floodwater, severe rain overflow, storm damage | +| Structural and Infrastructure Hazard | wall cracks, building collapse signs, bridge damage | +| Electrical Hazard | live wires, transformer blast, major short circuit evidence | +| Disaster and Rescue | landslide, storm collapse, road-blocking tree fall, rescue hazard | + +Labeling rule: if one image contains multiple issues, choose the most urgent visible issue and store secondary labels in metadata for future multi-label training. diff --git a/civic-platform/app/api/session/login/route.ts b/civic-platform/app/api/session/login/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..0494a20a8f696d997018f2be50d2c2f5f6a87106 --- /dev/null +++ b/civic-platform/app/api/session/login/route.ts @@ -0,0 +1,77 @@ +import { cookies } from "next/headers"; +import { NextRequest, NextResponse } from "next/server"; +import bcrypt from "bcryptjs"; +import pool from "@/lib/db"; +import { encodeSession, SESSION_COOKIE_NAME, type SessionUser } from "@/lib/session"; + +type UserRow = SessionUser & { passwordHash: string | null }; + +export async function POST(request: NextRequest) { + try { + const body = (await request.json()) as { email?: string; password?: string }; + const email = body.email?.trim().toLowerCase() ?? ""; + const password = body.password ?? ""; + + if (!email) { + return NextResponse.json({ error: "Email is required" }, { status: 400 }); + } + if (!password) { + return NextResponse.json({ error: "Password is required" }, { status: 400 }); + } + + // Query users table joined with roles + const result = await pool.query( + `SELECT + u.id, + u.full_name AS "fullName", + u.email, + r.name AS role, + u.department_id AS "departmentId", + u.ward_id AS "wardId", + u.password_hash AS "passwordHash" + FROM users u + INNER JOIN roles r ON r.id = u.role_id + WHERE lower(u.email) = $1 + AND u.is_active = TRUE + LIMIT 1`, + [email], + ); + + const user = result.rows[0] ?? null; + + if (!user) { + return NextResponse.json({ error: "Invalid email or password" }, { status: 401 }); + } + + // Support both real bcrypt hashes AND the dev-placeholder-hash used in seed data + let passwordValid = false; + if (user.passwordHash === "dev-placeholder-hash" && password === "civicpulse123") { + passwordValid = true; + } else if (user.passwordHash) { + passwordValid = await bcrypt.compare(password, user.passwordHash); + } + + if (!passwordValid) { + return NextResponse.json({ error: "Invalid email or password" }, { status: 401 }); + } + + const { passwordHash: _ph, ...sessionUser } = user; + const cookieStore = await cookies(); + + cookieStore.set(SESSION_COOKIE_NAME, encodeSession(sessionUser), { + httpOnly: true, + sameSite: "lax", + path: "/", + secure: process.env.NODE_ENV === "production", + maxAge: 60 * 60 * 12, + }); + + return NextResponse.json({ data: sessionUser }); + } catch (error) { + console.error("[login] Error:", error); + return NextResponse.json( + { error: error instanceof Error ? error.message : "Login failed" }, + { status: 500 }, + ); + } +} diff --git a/civic-platform/app/api/session/logout/route.ts b/civic-platform/app/api/session/logout/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..4af07410ec4eff0eee8a10642db7b7ff8e773454 --- /dev/null +++ b/civic-platform/app/api/session/logout/route.ts @@ -0,0 +1,10 @@ +import { cookies } from "next/headers"; +import { NextResponse } from "next/server"; +import { SESSION_COOKIE_NAME } from "@/lib/session"; + +export async function POST() { + const cookieStore = await cookies(); + cookieStore.delete(SESSION_COOKIE_NAME); + + return NextResponse.json({ data: { ok: true } }); +} diff --git a/civic-platform/app/api/session/register/route.ts b/civic-platform/app/api/session/register/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..8b3462bf2cb51b538057b7b41b103f284b15e67d --- /dev/null +++ b/civic-platform/app/api/session/register/route.ts @@ -0,0 +1,83 @@ +import { cookies } from "next/headers"; +import { NextRequest, NextResponse } from "next/server"; +import bcrypt from "bcryptjs"; +import pool from "@/lib/db"; +import { encodeSession, SESSION_COOKIE_NAME, type SessionUser } from "@/lib/session"; + +type UserRow = SessionUser & { passwordHash: string | null }; + +export async function POST(request: NextRequest) { + try { + const body = (await request.json()) as { + fullName?: string; + email?: string; + phone?: string; + password?: string; + }; + + const fullName = body.fullName?.trim() ?? ""; + const email = body.email?.trim().toLowerCase() ?? ""; + const phone = body.phone?.trim() || null; + const password = body.password ?? ""; + + // Validation + if (!fullName) { + return NextResponse.json({ error: "Full name is required" }, { status: 400 }); + } + if (!email) { + return NextResponse.json({ error: "Email is required" }, { status: 400 }); + } + if (!password || password.length < 8) { + return NextResponse.json({ error: "Password must be at least 8 characters" }, { status: 400 }); + } + + // Check if email already exists + const existing = await pool.query("SELECT id FROM users WHERE lower(email) = $1", [email]); + if (existing.rows.length > 0) { + return NextResponse.json({ error: "An account already exists for this email" }, { status: 409 }); + } + + // Hash password + const passwordHash = await bcrypt.hash(password, 12); + + // Look up citizen role ID + const roleResult = await pool.query("SELECT id FROM roles WHERE name = 'citizen' LIMIT 1"); + const roleId = roleResult.rows[0]?.id; + if (!roleId) { + return NextResponse.json({ error: "Citizen role is not configured in the database" }, { status: 500 }); + } + + // Insert new user + const insertResult = await pool.query( + `INSERT INTO users (full_name, email, phone, password_hash, role_id) + VALUES ($1, $2, $3, $4, $5) + RETURNING + id, + full_name AS "fullName", + email, + 'citizen' AS role, + department_id AS "departmentId", + ward_id AS "wardId", + password_hash AS "passwordHash"`, + [fullName, email, phone, passwordHash, roleId], + ); + + const user = insertResult.rows[0]; + const { passwordHash: _ph, ...sessionUser } = user; + + const cookieStore = await cookies(); + cookieStore.set(SESSION_COOKIE_NAME, encodeSession(sessionUser), { + httpOnly: true, + sameSite: "lax", + path: "/", + secure: process.env.NODE_ENV === "production", + maxAge: 60 * 60 * 12, + }); + + return NextResponse.json({ data: sessionUser }, { status: 201 }); + } catch (error) { + console.error("[register] Error:", error); + const message = error instanceof Error ? error.message : "Registration failed"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/civic-platform/app/complaints/[id]/page.tsx b/civic-platform/app/complaints/[id]/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..dc591238e337cdb75a9a89b36b7c35d860739cf6 --- /dev/null +++ b/civic-platform/app/complaints/[id]/page.tsx @@ -0,0 +1,264 @@ +import Link from "next/link"; +import { AlertTriangle, Clock3, MapPinned, ShieldCheck, Waves } from "lucide-react"; +import { AppShell } from "@/components/app-shell"; +import { CitizenFollowUpPanel } from "@/components/citizen-follow-up-panel"; +import { ComplaintMediaGallery } from "@/components/complaint-media-gallery"; +import { SectionHeader } from "@/components/section-header"; +import { requireRole } from "@/lib/auth"; +import { + getComplaintDetail, + getComplaintFeedback, + getComplaintHistory, + getComplaintMedia, + type ComplaintFeedbackItem, + type ComplaintMediaItem, + type ComplaintStatusHistoryItem, +} from "@/lib/api"; + +type ComplaintDetailPageProps = { + params: Promise<{ + id: string; + }>; +}; + +function formatStatus(status: string) { + return status + .split("_") + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +} + +function formatPriority(priority: string) { + switch (priority) { + case "P1": + return "P1 Critical"; + case "P2": + return "P2 High"; + case "P3": + return "P3 Medium"; + default: + return "P4 Low"; + } +} + +function formatDateTime(value: string) { + return new Date(value).toLocaleString(); +} + +function timelineTone(status: string) { + switch (status) { + case "submitted": + return "bg-civic-primary"; + case "validated": + return "bg-civic-secondary"; + case "classified": + case "prioritized": + return "bg-amber-500"; + case "assigned": + return "bg-slate-800"; + case "in_progress": + return "bg-emerald-600"; + case "resolved": + case "closed": + return "bg-emerald-700"; + case "reopened": + case "escalated": + return "bg-civic-danger"; + default: + return "bg-slate-300"; + } +} + +export default async function ComplaintDetailPage({ params }: ComplaintDetailPageProps) { + await requireRole(["citizen"]); + const { id } = await params; + + let complaintStatus = "In Progress"; + let complaintPriority = "P2 High"; + let complaintLocation = "Market Road, Ward 12"; + let complaintDomain = "Water Supply, Sewerage, and Drainage"; + let complaintDescription = "Overflow affecting entry and traffic movement near the market."; + let complaintCode = "CP-2026-00124"; + let complaintDepartment = "Water and Sewerage"; + let media: ComplaintMediaItem[] = []; + let feedback: ComplaintFeedbackItem[] = []; + let rawStatus = "in_progress"; + let citizenId = "00000000-0000-0000-0000-000000000001"; + let timeline: Array<{ title: string; time: string; note: string; tone: string }> = [ + { + title: "Submitted", + time: "Pending", + note: "Complaint created by citizen with image, description, and geo-tagged location.", + tone: "bg-civic-primary", + }, + ]; + + try { + const [complaint, history, mediaItems, feedbackItems] = await Promise.all([ + getComplaintDetail(id), + getComplaintHistory(id), + getComplaintMedia(id), + getComplaintFeedback(id), + ]); + + rawStatus = complaint.status; + complaintStatus = formatStatus(complaint.status); + complaintPriority = formatPriority(complaint.priorityLevel); + complaintLocation = complaint.addressLine ?? complaint.landmark ?? "Location pending"; + complaintDomain = complaint.domainName ?? "Unclassified"; + complaintDescription = complaint.description ?? complaintDescription; + complaintCode = complaint.complaintCode; + complaintDepartment = complaint.departmentName ?? complaintDepartment; + citizenId = complaint.citizenId; + media = mediaItems; + feedback = feedbackItems; + + if (history.length > 0) { + timeline = history.map((entry: ComplaintStatusHistoryItem) => ({ + title: formatStatus(entry.newStatus), + time: formatDateTime(entry.createdAt), + note: entry.changeReason ?? `Complaint moved to ${formatStatus(entry.newStatus)}.`, + tone: timelineTone(entry.newStatus), + })); + } + } catch { + // Keep fallback values for design flow when backend is unavailable. + } + + return ( + +
+
+

Complaint detail

+

Track your complaint in one place.

+

+ Check the current status, department ownership, attached evidence, and any updates from the city team. +

+
+ +
+ {[ + { label: "Complaint ID", value: complaintCode }, + { label: "Current status", value: complaintStatus }, + { label: "Priority", value: complaintPriority }, + { label: "Assigned department", value: complaintDepartment }, + ].map((card) => ( +
+

{card.label}

+

{card.value}

+
+ ))} +
+
+ +
+
+
+
+

Timeline

+

Status history

+
+
+ Current state: {complaintStatus} +
+
+ +
+ {timeline.map((entry, index) => ( +
+
+
+ {index !== timeline.length - 1 ?
: null} +
+
+
+

{entry.title}

+
+ + {entry.time} +
+
+

{entry.note}

+
+
+ ))} +
+
+ +
+
+ + +
+ {[complaintLocation, `Domain: ${complaintDomain}`, complaintDescription].map((item) => ( +
+ {item} +
+ ))} +
+
+ + + + +
+
+ +
+
+
+ +
+

What you can see here

+

+ This page shows your complaint progress and public-safe updates without exposing internal staff notes. +

+
+
+
+ +
+
+ +
+

Need more help?

+

+ If the issue is marked resolved but still exists, use the reopen option below so the team can review it again. +

+
+
+
+
+ +
+
+

Quick actions

+

Keep track of this report or return to your list.

+

+ You can go back to all complaints, stay on this page for updates, or report another issue if needed. +

+
+ +
+ + Back to complaints + + + Report another issue + + +
+
+
+ ); +} diff --git a/civic-platform/app/complaints/page.tsx b/civic-platform/app/complaints/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8f34cc135e0a9ee8fc025fd471f3e81b04974bff --- /dev/null +++ b/civic-platform/app/complaints/page.tsx @@ -0,0 +1,217 @@ +import Link from "next/link"; +import { Clock3, MapPinned, Search } from "lucide-react"; +import { AppShell } from "@/components/app-shell"; +import { PriorityBadge } from "@/components/priority-badge"; +import { StatusBadge } from "@/components/status-badge"; +import { getCitizenComplaints } from "@/lib/api"; +import { requireRole } from "@/lib/auth"; + +const complaintStats = [ + { label: "Active", value: "04" }, + { label: "Resolved", value: "09" }, + { label: "Reopened", value: "01" }, +]; + +type ComplaintCard = { + id: string; + complaintCode?: string; + title: string; + domain: string; + status: string; + priority: string; + location: string; + updatedAt: string; +}; + +const fallbackComplaints: ComplaintCard[] = [ + { + id: "CP-2026-00124", + complaintCode: "CP-2026-00124", + title: "Sewage overflow near market entrance", + domain: "Water Supply, Sewerage, and Drainage", + status: "In Progress", + priority: "P2 High", + location: "Market Road, Ward 12", + updatedAt: "Updated 1 hour ago", + }, + { + id: "CP-2026-00108", + complaintCode: "CP-2026-00108", + title: "Streetlight not working on school road", + domain: "Street Lighting and Electrical Infrastructure", + status: "Assigned", + priority: "P3 Medium", + location: "School Street, Ward 8", + updatedAt: "Updated today", + }, + { + id: "CP-2026-00097", + complaintCode: "CP-2026-00097", + title: "Overflowing garbage bin near bus stop", + domain: "Sanitation and Waste Management", + status: "Resolved", + priority: "P3 Medium", + location: "Central Bus Stop, Ward 3", + updatedAt: "Resolved yesterday", + }, + { + id: "CP-2026-00091", + complaintCode: "CP-2026-00091", + title: "Open manhole beside community hall", + domain: "Public Infrastructure and Amenities", + status: "Reopened", + priority: "P1 Critical", + location: "Community Hall Lane, Ward 5", + updatedAt: "Reopened 3 hours ago", + }, +]; + +const filters = ["All", "Active", "Resolved", "Reopened", "Emergency"]; + +function formatStatus(status: string) { + return status + .split("_") + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +} + +function formatPriority(priority: string) { + switch (priority) { + case "P1": + return "P1 Critical"; + case "P2": + return "P2 High"; + case "P3": + return "P3 Medium"; + default: + return "P4 Low"; + } +} + +export default async function ComplaintsPage() { + const user = await requireRole(["citizen"]); + let complaints: ComplaintCard[] = fallbackComplaints; + + try { + const apiComplaints = await getCitizenComplaints(user.id); + + if (apiComplaints.length > 0) { + complaints = apiComplaints.map((complaint) => ({ + id: complaint.id, + title: complaint.title, + domain: complaint.domainName ?? "Unclassified", + status: formatStatus(complaint.status), + priority: formatPriority(complaint.priorityLevel), + location: complaint.departmentName ?? "Department pending", + updatedAt: new Date(complaint.submittedAt).toLocaleString(), + complaintCode: complaint.complaintCode, + })); + } + } catch { + complaints = fallbackComplaints; + } + + return ( + +
+
+ +
+
+

My Reports

+

Track the status of the issues you've reported in your neighborhood.

+
+ + Report a new issue + +
+ +
+ {complaintStats.map((stat) => ( +
+

{stat.label}

+

{stat.value}

+
+ ))} +
+
+ +
+
+
+
+ {filters.map((filter) => ( + + ))} +
+ +
+ + Search by complaint ID or area +
+
+
+ +
+

Recent Activity

+ +
+ {complaints.map((complaint) => ( +
+
+
+
+ + {complaint.complaintCode ?? complaint.id} + + + +
+

{complaint.title}

+

{complaint.domain}

+
+ +
+ + {complaint.updatedAt} +
+
+ +
+ + {complaint.location} +
+ +
+ + View Details + +
+
+ ))} +
+
+
+
+ ); +} diff --git a/civic-platform/app/dashboard/analytics/page.tsx b/civic-platform/app/dashboard/analytics/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..9efba8a1fc746a7fe22fc9de5ae58259f9fb2d23 --- /dev/null +++ b/civic-platform/app/dashboard/analytics/page.tsx @@ -0,0 +1,184 @@ +import Link from "next/link"; +import { AppShell } from "@/components/app-shell"; +import { EmptyState } from "@/components/empty-state"; +import { LiveSyncBadge } from "@/components/live-sync-badge"; +import { requireRole } from "@/lib/auth"; +import { getAnalyticsExportUrl, getAnalyticsSummary, type AnalyticsSummary } from "@/lib/api"; +import { ArrowDownToLine, BarChart3, Clock3, Siren, TrendingUp } from "lucide-react"; + +const fallbackAnalytics: AnalyticsSummary = { + trends: [], + domainBreakdown: [], + departmentPerformance: [], + kpis: { + emergencyCount: 0, + reopenedCount: 0, + repeatComplaintRiskCount: 0, + slaBreaches: 0, + }, +}; + +export default async function DashboardAnalyticsPage() { + await requireRole(["department_operator", "municipal_admin"]); + + let analytics = fallbackAnalytics; + + try { + analytics = await getAnalyticsSummary(); + } catch { + analytics = fallbackAnalytics; + } + + const maxTrend = Math.max( + 1, + ...analytics.trends.flatMap((item) => [item.reportedCount, item.resolvedCount]), + ); + + return ( + +
+
+
+

Analytics

+

Track performance, trends, and launch readiness.

+

+ Use this view to monitor complaint trends, department throughput, SLA risk, and repeat civic failures. +

+
+ +
+
+ +
+ {[ + { label: "Emergency", value: analytics.kpis.emergencyCount, icon: Siren }, + { label: "Reopened", value: analytics.kpis.reopenedCount, icon: TrendingUp }, + { label: "Repeat risk", value: analytics.kpis.repeatComplaintRiskCount, icon: BarChart3 }, + { label: "SLA breaches", value: analytics.kpis.slaBreaches, icon: Clock3 }, + ].map((item) => { + const Icon = item.icon; + return ( +
+
+ +
+

{item.label}

+

{item.value}

+
+ ); + })} +
+ +
+
+
+

Trends

+

Reported vs resolved over the last 7 days

+
+
+ {analytics.trends.length === 0 ? ( + + ) : ( + analytics.trends.map((item) => ( +
+
+ {item.day} + + reported {item.reportedCount} ยท resolved {item.resolvedCount} + +
+
+
+
+
+
+
+
+
+
+ )) + )} +
+
+ +
+
+

Domains

+

Complaint share by category

+
+
+ {analytics.domainBreakdown.length === 0 ? ( + + ) : ( + analytics.domainBreakdown.map((item) => ( +
+
+ {item.domainName} + + {item.complaintCount} + +
+
+ )) + )} +
+
+
+ +
+
+
+

Departments

+

Performance and response time

+
+ + Back to overview + +
+ +
+ + + + + + + + + + + {analytics.departmentPerformance.map((item) => ( + + + + + + + ))} + +
DepartmentPendingResolvedAvg resolution (hrs)
{item.departmentName}{item.pendingCount}{item.resolvedCount}{item.averageResolutionHours.toFixed(2)}
+
+
+
+ ); +} diff --git a/civic-platform/app/dashboard/complaints/[id]/page.tsx b/civic-platform/app/dashboard/complaints/[id]/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..fd89d91c74c7f7056a3edad029d20c66ea7d0083 --- /dev/null +++ b/civic-platform/app/dashboard/complaints/[id]/page.tsx @@ -0,0 +1,383 @@ +import Link from "next/link"; +import { + ArrowRight, + CheckCircle2, + ClipboardList, + Clock3, + MessageSquareText, + ShieldAlert, + UserCog, +} from "lucide-react"; +import { AdminAssignAction } from "@/components/admin-assign-action"; +import { AdminAssignmentHistory } from "@/components/admin-assignment-history"; +import { AdminNotesPanel } from "@/components/admin-notes-panel"; +import { AppShell } from "@/components/app-shell"; +import { AdminStatusActions } from "@/components/admin-status-actions"; +import { ComplaintAiPanel } from "@/components/complaint-ai-panel"; +import { ComplaintMediaGallery } from "@/components/complaint-media-gallery"; +import { ResolutionProofUploader } from "@/components/resolution-proof-uploader"; +import { SectionHeader } from "@/components/section-header"; +import { requireRole } from "@/lib/auth"; +import { + getComplaintDetail, + getComplaintAssignments, + getComplaintAiInsights, + getComplaintHistory, + getComplaintMedia, + getComplaintNotes, + type ComplaintAiInsights, + type ComplaintAssignmentItem, + type ComplaintMediaItem, + type ComplaintNoteItem, + type ComplaintStatusHistoryItem, +} from "@/lib/api"; + +const internalActions = [ + "Validate complaint", + "Adjust domain or sub-problem if needed", + "Assign responsible department", + "Assign field officer later", + "Change lifecycle state", + "Escalate if public danger is immediate", +]; + +type DashboardComplaintDetailPageProps = { + params: Promise<{ + id: string; + }>; +}; + +function formatStatus(status: string) { + return status + .split("_") + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +} + +function formatPriority(priority: string) { + switch (priority) { + case "P1": + return "P1 Critical"; + case "P2": + return "P2 High"; + case "P3": + return "P3 Medium"; + default: + return "P4 Low"; + } +} + +function formatDateTime(value: string) { + return new Date(value).toLocaleString(); +} + +function getPriorityRationale(priority: string, isEmergency: boolean) { + if (isEmergency || priority === "P1 Critical") { + return "Emergency or public-danger signals require immediate routing and shortest possible response time."; + } + + if (priority === "P2 High") { + return "High-impact infrastructure or safety risk needs fast ownership before it grows into a wider area failure."; + } + + if (priority === "P3 Medium") { + return "Standard operational issue with visible citizen impact. Keep it moving through the active queue."; + } + + return "Lower-impact maintenance issue that can stay in the planned service backlog unless conditions worsen."; +} + +function getRoutingConfidence(domain: string, assignments: ComplaintAssignmentItem[], department: string) { + if (assignments.length > 0) { + return `Routing has already been acted on. The latest ownership sits with ${assignments[0]?.departmentName ?? department}.`; + } + + if (domain === "Fire Emergencies" || domain === "Flood and Water Disaster" || domain === "Disaster and Rescue") { + return `Emergency domain strongly matches ${department} and should stay on the fastest escalation path.`; + } + + return `Current domain and complaint context point to ${department} as the primary queue owner.`; +} + +function getOperationalState(status: string, assignments: ComplaintAssignmentItem[]) { + if (status === "Resolved" || status === "Closed") { + return "Work appears completed. Review proof, citizen follow-up, and any reopen risk."; + } + + if (status === "Reopened" || status === "Escalated") { + return "Complaint needs renewed attention. Recheck ownership, on-ground risk, and SLA exposure."; + } + + if (assignments.length > 0) { + return "Complaint is in active departmental handling. Track acceptance and field progress closely."; + } + + return "Complaint still needs clear ownership. Assignment is the next critical operator action."; +} + +function timelineTone(status: string) { + switch (status) { + case "submitted": + return "bg-civic-primary"; + case "validated": + return "bg-civic-secondary"; + case "classified": + case "prioritized": + return "bg-amber-500"; + case "assigned": + return "bg-slate-800"; + case "in_progress": + return "bg-emerald-600"; + case "resolved": + case "closed": + return "bg-emerald-700"; + case "reopened": + case "escalated": + return "bg-civic-danger"; + default: + return "bg-slate-300"; + } +} + +export default async function DashboardComplaintDetailPage({ params }: DashboardComplaintDetailPageProps) { + const user = await requireRole(["department_operator", "municipal_admin"]); + const { id } = await params; + + let complaintCode = "CP-2026-00141"; + let complaintStatusValue = "submitted"; + let complaintStatus = "Submitted"; + let complaintPriority = "P1 Critical"; + let complaintDomain = "Public Infrastructure and Amenities"; + let complaintTitle = "Open manhole on high-traffic road"; + let complaintLocation = "Temple Junction, Ward 4"; + let complaintDescription = "High-risk public hazard affecting traffic near the junction."; + let complaintDepartment = "Municipal Maintenance"; + let isEmergency = true; + let media: ComplaintMediaItem[] = []; + let assignments: ComplaintAssignmentItem[] = []; + let notes: ComplaintNoteItem[] = []; + let aiInsights: ComplaintAiInsights | null = null; + let internalTimeline: Array<{ title: string; time: string; note: string; tone: string }> = [ + { + title: "Submitted", + time: "Pending", + note: "Complaint created by citizen with image and geo-tag.", + tone: "bg-civic-primary", + }, + ]; + + try { + const [complaint, assignmentItems, history, mediaItems, noteItems, aiData] = await Promise.all([ + getComplaintDetail(id), + getComplaintAssignments(id), + getComplaintHistory(id), + getComplaintMedia(id), + getComplaintNotes(id), + getComplaintAiInsights(id), + ]); + + complaintCode = complaint.complaintCode; + complaintStatusValue = complaint.status; + complaintStatus = formatStatus(complaint.status); + complaintPriority = formatPriority(complaint.priorityLevel); + complaintDomain = complaint.domainName ?? complaintDomain; + complaintTitle = complaint.title; + complaintLocation = complaint.addressLine ?? complaint.landmark ?? complaintLocation; + complaintDescription = complaint.description ?? complaintDescription; + complaintDepartment = complaint.departmentName ?? complaintDepartment; + isEmergency = complaint.isEmergency; + assignments = assignmentItems; + media = mediaItems; + notes = noteItems; + aiInsights = aiData; + + if (history.length > 0) { + internalTimeline = history.map((entry: ComplaintStatusHistoryItem) => ({ + title: formatStatus(entry.newStatus), + time: formatDateTime(entry.createdAt), + note: entry.changeReason ?? `Complaint moved to ${formatStatus(entry.newStatus)}.`, + tone: timelineTone(entry.newStatus), + })); + } + } catch { + // Keep fallback values when backend is unavailable. + } + + const complaintMeta = [ + { label: "Complaint ID", value: complaintCode }, + { label: "Current status", value: complaintStatus }, + { label: "Priority", value: complaintPriority }, + { label: "Domain", value: complaintDomain }, + ]; + + const complaintContext = [ + `Issue: ${complaintTitle}`, + `Location: ${complaintLocation}`, + `Likely department: ${complaintDepartment}`, + complaintDescription, + ]; + + const routingDecisionItems = [ + `Current queue owner: ${assignments[0]?.departmentName ?? complaintDepartment}`, + `Operational mode: ${isEmergency ? "Emergency escalation path" : "Standard civic workflow"}`, + `Priority rationale: ${getPriorityRationale(complaintPriority, isEmergency)}`, + `Routing confidence: ${getRoutingConfidence(complaintDomain, assignments, complaintDepartment)}`, + `Current action state: ${getOperationalState(complaintStatus, assignments)}`, + ]; + + return ( + +
+
+

Internal complaint view

+

Manage one complaint from triage to resolution.

+

+ Review context, route the work, assign the right team, and move the complaint through the next action. +

+
+ +
+ {complaintMeta.map((item) => ( +
+

{item.label}

+

{item.value}

+
+ ))} +
+
+ +
+
+
+ + +
+ {complaintContext.map((item) => ( +
+ {item} +
+ ))} +
+
+ +
+ + +
+ {internalTimeline.map((entry, index) => ( +
+
+
+ {index !== internalTimeline.length - 1 ?
: null} +
+
+
+

{entry.title}

+
+ + {entry.time} +
+
+

{entry.note}

+
+
+ ))} +
+
+ +
+ + +
+ +
+
+ + +
+ +
+
+ + +
+ {routingDecisionItems.map((item) => ( +
+ {item} +
+ ))} +
+ +
+ +
+
+ + + +
+ + +
+ {internalActions.map((action) => ( +
+ {action} +
+ ))} +
+ +
+ +
+
+ + + + + +
+
+ +
+

Internal-only context

+

+ Notes, routing context, and assignment controls stay visible only to internal teams. +

+
+
+
+
+
+ +
+
+

Queue navigation

+

Jump back to the queue or move into emergency operations.

+

+ Use the queue to continue triage work, or switch to emergency reporting when a fast-response situation needs immediate handling. +

+
+ +
+ + Back to queue + + + Open emergency reporting + + +
+
+
+ ); +} diff --git a/civic-platform/app/dashboard/complaints/page.tsx b/civic-platform/app/dashboard/complaints/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..bb67ab5bcf4f380851435fe39b4d59f550fe62e4 --- /dev/null +++ b/civic-platform/app/dashboard/complaints/page.tsx @@ -0,0 +1,113 @@ +import { AppShell } from "@/components/app-shell"; +import { + DashboardQueueClient, + type QueueComplaintCard, +} from "@/components/dashboard-queue-client"; +import { getAllComplaints } from "@/lib/api"; +import { requireRole } from "@/lib/auth"; + +const filterGroups = [ + { + title: "Status" as const, + options: ["All", "Submitted", "Assigned", "In Progress", "Resolved", "Reopened"], + }, + { + title: "Priority" as const, + options: ["All", "P1 Critical", "P2 High", "P3 Medium", "P4 Low"], + }, + { + title: "Domain" as const, + options: ["All", "Roads", "Sanitation", "Water", "Electrical", "Infrastructure", "Environment"], + }, +]; + +const fallbackComplaints: QueueComplaintCard[] = [ + { + id: "CP-2026-00141", + complaintCode: "CP-2026-00141", + title: "Open manhole on high-traffic road", + domain: "Public Infrastructure and Amenities", + status: "Submitted", + priority: "P1 Critical", + department: "Municipal Maintenance", + location: "Temple Junction, Ward 4", + updatedAt: "5 minutes ago", + submittedAt: new Date(Date.now() - 5 * 60 * 1000).toISOString(), + }, + { + id: "CP-2026-00139", + complaintCode: "CP-2026-00139", + title: "Live wire hanging near bus stop", + domain: "Electrical Hazard", + status: "Assigned", + priority: "P1 Critical", + department: "Electrical Safety Response", + location: "Bus Stand Road, Ward 9", + updatedAt: "12 minutes ago", + submittedAt: new Date(Date.now() - 12 * 60 * 1000).toISOString(), + }, + { + id: "CP-2026-00124", + complaintCode: "CP-2026-00124", + title: "Sewage overflow near market entrance", + domain: "Water Supply, Sewerage, and Drainage", + status: "In Progress", + priority: "P2 High", + department: "Water and Sewerage", + location: "Market Road, Ward 12", + updatedAt: "1 hour ago", + submittedAt: new Date(Date.now() - 60 * 60 * 1000).toISOString(), + }, +]; + +function formatStatus(status: string) { + return status + .split("_") + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +} + +function formatPriority(priority: string) { + switch (priority) { + case "P1": + return "P1 Critical"; + case "P2": + return "P2 High"; + case "P3": + return "P3 Medium"; + default: + return "P4 Low"; + } +} + +export default async function DashboardComplaintsPage() { + const user = await requireRole(["department_operator", "municipal_admin"]); + let complaints: QueueComplaintCard[] = fallbackComplaints; + + try { + const apiComplaints = await getAllComplaints(); + + if (apiComplaints.length > 0) { + complaints = apiComplaints.map((complaint) => ({ + id: complaint.id, + complaintCode: complaint.complaintCode, + title: complaint.title, + domain: complaint.domainName ?? "Unclassified", + status: formatStatus(complaint.status), + priority: formatPriority(complaint.priorityLevel), + department: complaint.departmentName ?? "Department pending", + location: complaint.addressLine ?? complaint.landmark ?? "Location pending", + updatedAt: new Date(complaint.submittedAt).toLocaleString(), + submittedAt: complaint.submittedAt, + })); + } + } catch { + complaints = fallbackComplaints; + } + + return ( + + + + ); +} diff --git a/civic-platform/app/dashboard/page.tsx b/civic-platform/app/dashboard/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..3c73485dccd97a1a258e3af7ea62f13db5e0990f --- /dev/null +++ b/civic-platform/app/dashboard/page.tsx @@ -0,0 +1,257 @@ +import Link from "next/link"; +import { + ArrowRight, + Building2, + Clock3, + MapPinned, + Siren, + TimerReset, +} from "lucide-react"; +import { AppShell } from "@/components/app-shell"; +import { EmptyState } from "@/components/empty-state"; +import { LiveSyncBadge } from "@/components/live-sync-badge"; +import { PriorityBadge } from "@/components/priority-badge"; +import { requireRole } from "@/lib/auth"; +import { getDashboardSummary, type DashboardSummary } from "@/lib/api"; + +const focusItems = [ + "Urgent complaints", + "Unaccepted assignments", + "SLA breaches", + "Pending field updates", +]; + +export default async function DashboardPage() { + await requireRole(["department_operator", "municipal_admin"]); + + const fallbackSummary: DashboardSummary = { + overview: { + openComplaints: 184, + criticalComplaints: 7, + slaRisk: 19, + resolvedToday: 42, + }, + urgentQueue: [ + { + id: "sample-urgent-1", + complaintCode: "CP-2026-00141", + title: "Open manhole on high-traffic road", + domainName: "Public Infrastructure and Amenities", + priorityLevel: "P1", + location: "Temple Junction, Ward 4", + submittedAt: new Date().toISOString(), + }, + { + id: "sample-urgent-2", + complaintCode: "CP-2026-00139", + title: "Live wire hanging near bus stop", + domainName: "Electrical Hazard", + priorityLevel: "P1", + location: "Bus Stand Road, Ward 9", + submittedAt: new Date().toISOString(), + }, + ], + departmentWorkload: [ + { departmentName: "Roads and Public Works", pendingCount: 31 }, + { departmentName: "Sanitation and Solid Waste", pendingCount: 28 }, + { departmentName: "Water and Sewerage", pendingCount: 44 }, + { departmentName: "Electrical and Street Lighting", pendingCount: 22 }, + ], + }; + + let summary: DashboardSummary = fallbackSummary; + + try { + summary = await getDashboardSummary(); + } catch { + summary = fallbackSummary; + } + + const overviewStats = [ + { label: "Open complaints", value: String(summary.overview.openComplaints), tone: "text-civic-text" }, + { label: "Critical", value: String(summary.overview.criticalComplaints), tone: "text-civic-danger" }, + { label: "SLA risk", value: String(summary.overview.slaRisk), tone: "text-amber-700" }, + { label: "Resolved today", value: String(summary.overview.resolvedToday), tone: "text-emerald-700" }, + ]; + + return ( + +
+
+
+
+

Operations

+

Command Center

+
+
+ + + Analytics + + + + Open Queue + + +
+
+ +
+ {overviewStats.map((stat) => ( +
+

{stat.label}

+

{stat.value}

+
+ ))} +
+
+ +
+
+
+
+

Urgent queue

+

Priority Incidents

+
+
+ + Live Feed +
+
+ +
+ {summary.urgentQueue.length === 0 ? ( + + ) : ( + summary.urgentQueue.map((item) => ( +
+
+
+
+
+ + {item.complaintCode} + + +
+

{item.title}

+

{item.domainName ?? "Unclassified"}

+
+ +
+ {new Date(item.submittedAt).toLocaleString()} +
+
+ +
+
+ + {item.location ?? "Location pending"} +
+
+ +
+ + Open Incident + + +
+
+ )) + )} +
+
+ +
+
+
+
+ +
+
+

Departments

+

Current Workload

+
+
+ +
+ {summary.departmentWorkload.length === 0 ? ( + + ) : ( + summary.departmentWorkload.map((item) => ( +
+
+

{item.departmentName}

+ + {item.pendingCount} pending + +
+
+ )) + )} +
+
+ +
+
+
+ +
+
+

Focus

+

Immediate Attention

+
+
+ +
+ {focusItems.map((item) => ( +
+ {item} +
+ ))} +
+
+ +
+
+
+ +
+
+

Queue

+

Continue Operations

+
+
+ +
+ + Go to Complaint Queue + + +
+
+
+
+
+ ); +} diff --git a/civic-platform/app/emergency/page.tsx b/civic-platform/app/emergency/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..60c70024243cb46669578a60ed628ee7da1acc29 --- /dev/null +++ b/civic-platform/app/emergency/page.tsx @@ -0,0 +1,176 @@ +import Link from "next/link"; +import { AlertTriangle, Flame, RadioTower, ShieldAlert, Siren, Waves, Zap } from "lucide-react"; +import { AppShell } from "@/components/app-shell"; + +const emergencyTypes = [ + { + title: "Fire", + summary: "Fire outbreak, smoke, electrical fire, or public flame hazard.", + icon: Flame, + }, + { + title: "Flood", + summary: "Flooding, dangerous water accumulation, or rapid overflow.", + icon: Waves, + }, + { + title: "Electrical Hazard", + summary: "Live wire, transformer blast, or severe public electrical risk.", + icon: Zap, + }, + { + title: "Rescue / Disaster", + summary: "Collapse, storm damage, trapped people, or multi-agency incident.", + icon: RadioTower, + }, +]; + +const emergencyRules = [ + "Emergency reports should take fewer steps than standard complaints.", + "Location must be captured first or confirmed manually.", + "Priority should default to P1 unless the complaint is invalid.", + "The system should route directly to the emergency queue.", +]; + +const minimalFields = [ + "Emergency type", + "Photo or short video", + "Current location or exact pin", + "Short description of danger", + "Optional contact number", +]; + +export default function EmergencyPage() { + return ( + +
+
+
+ + Emergency reporting flow +
+

Use the fastest possible flow for urgent public danger.

+

+ Report fire, flood, electrical danger, or rescue situations with the fewest possible steps so the response team can act quickly. +

+
+ +
+

Default response behavior

+
+ {[ + "Priority set to P1 Critical", + "Emergency queue routing", + "Immediate operator visibility", + "Stronger notification path", + ].map((item) => ( +
+ {item} +
+ ))} +
+
+
+ +
+
+
+
+

Emergency type

+

Choose the urgent incident category

+
+
+ High priority flow +
+
+ +
+ {emergencyTypes.map(({ title, summary, icon: Icon }) => ( + + ))} +
+
+ +
+
+
+
+ +
+
+

Form scope

+

Minimal fields for emergency submission

+
+
+ +
+ {minimalFields.map((field) => ( +
+ {field} +
+ ))} +
+
+ +
+
+ +
+

Emergency reporting principles

+

+ This flow stays shorter than normal reporting and focuses only on what responders need first. +

+
+
+ +
+ {emergencyRules.map((rule) => ( +
+ {rule} +
+ ))} +
+
+
+
+ +
+
+

Switch reporting modes

+

Return to the normal report flow or operations dashboard.

+

+ Use the standard reporting flow for non-emergency complaints, or return to the dashboard to monitor active work. +

+
+ +
+ + Back to normal report flow + + + Return to dashboard + +
+
+
+ ); +} diff --git a/civic-platform/app/globals.css b/civic-platform/app/globals.css new file mode 100644 index 0000000000000000000000000000000000000000..0858db955ecc665b319bb4f39bce8770deeb34ba --- /dev/null +++ b/civic-platform/app/globals.css @@ -0,0 +1,61 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap'); +@import "mapbox-gl/dist/mapbox-gl.css"; + +:root { + color-scheme: light; +} + +html { + scroll-behavior: smooth; +} + +body { + margin: 0; + min-height: 100vh; + background: + radial-gradient(circle at top left, rgba(20, 184, 166, 0.08), transparent 45%), + radial-gradient(circle at bottom right, rgba(15, 76, 129, 0.08), transparent 40%), + linear-gradient(to bottom, #f8fafc, #f1f5f9); + background-attachment: fixed; + color: #0f172a; + font-family: 'Inter', Arial, Helvetica, sans-serif; + letter-spacing: -0.015em; + overflow-x: hidden; + -webkit-font-smoothing: antialiased; +} + +/* Premium Scrollbar */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: rgba(100, 116, 139, 0.3); + border-radius: 10px; +} + +::-webkit-scrollbar-thumb:hover { + background: rgba(100, 116, 139, 0.5); +} + +* { + box-sizing: border-box; +} + +a { + color: inherit; + text-decoration: none; +} + +a { + color: inherit; + text-decoration: none; +} diff --git a/civic-platform/app/layout.tsx b/civic-platform/app/layout.tsx new file mode 100644 index 0000000000000000000000000000000000000000..83652f926bc45e283d076bec8636869006e6019b --- /dev/null +++ b/civic-platform/app/layout.tsx @@ -0,0 +1,19 @@ +import type { Metadata } from "next"; +import "./globals.css"; + +export const metadata: Metadata = { + title: "Civic Platform", + description: "Crowdsourced civic issue reporting and resolution system", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + {children} + + ); +} diff --git a/civic-platform/app/login/page.tsx b/civic-platform/app/login/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7351acb0f1ead2490080bc546ede19b7d2b9378e --- /dev/null +++ b/civic-platform/app/login/page.tsx @@ -0,0 +1,40 @@ +import { redirect } from "next/navigation"; +import { ShieldCheck } from "lucide-react"; +import { AppShell } from "@/components/app-shell"; +import { LoginForm } from "@/components/login-form"; +import { getCurrentUser } from "@/lib/auth"; + +export default async function LoginPage() { + const user = await getCurrentUser(); + + if (user) { + redirect(user.role === "citizen" ? "/complaints" : user.role === "field_officer" ? "/tasks" : "/dashboard"); + } + + return ( + +
+
+
+ +
+

+ Welcome back +

+

+ Sign in or create an account to participate +

+
+ +
+ {/* Subtle background glow */} +
+ +
+ +
+
+
+
+ ); +} diff --git a/civic-platform/app/map/page.tsx b/civic-platform/app/map/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2600c97b65df9d73ead503511f55e6a80b080e79 --- /dev/null +++ b/civic-platform/app/map/page.tsx @@ -0,0 +1,59 @@ +import { AppShell } from "@/components/app-shell"; +import { IssueMapBoard } from "@/components/issue-map-board"; +import { getCurrentUser } from "@/lib/auth"; +import { + getMapComplaints, + getPublicHotspots, + type ComplaintHotspotItem, + type ComplaintMapItem, +} from "@/lib/api"; + +const fallbackComplaints: ComplaintMapItem[] = []; +const fallbackHotspots: ComplaintHotspotItem[] = []; + +export default async function MapPage() { + const user = await getCurrentUser(); + const mapboxToken = process.env.MAPBOX_TOKEN ?? process.env.NEXT_PUBLIC_MAPBOX_TOKEN; + const isOperationsView = + user?.role === "department_operator" || user?.role === "municipal_admin" || user?.role === "field_officer"; + + let complaints = fallbackComplaints; + let hotspots = fallbackHotspots; + + try { + [complaints, hotspots] = await Promise.all([ + getMapComplaints({ publicOnly: !isOperationsView }), + getPublicHotspots(), + ]); + } catch { + complaints = fallbackComplaints; + hotspots = fallbackHotspots; + } + + return ( + +
+

+ {isOperationsView ? "Operations map" : "Public map"} +

+

+ {isOperationsView + ? "Monitor live civic issue activity with city-wide visibility." + : "Explore active civic issues and hotspots across the city."} +

+

+ {isOperationsView + ? "Use the live operations map to spot workload clusters, emergency incidents, and active complaint zones." + : "Residents can browse public-safe complaint activity, see active clusters, and understand where issues are already being addressed."} +

+
+ + +
+ ); +} diff --git a/civic-platform/app/notifications/page.tsx b/civic-platform/app/notifications/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..4b7963ca9dbf46f76852887917f68100250452a8 --- /dev/null +++ b/civic-platform/app/notifications/page.tsx @@ -0,0 +1,35 @@ +import { AppShell } from "@/components/app-shell"; +import { LiveSyncBadge } from "@/components/live-sync-badge"; +import { NotificationCenter } from "@/components/notification-center"; +import { requireUser } from "@/lib/auth"; +import { getNotifications, type ComplaintNotificationItem } from "@/lib/api"; + +const fallbackNotifications: ComplaintNotificationItem[] = []; + +export default async function NotificationsPage() { + const user = await requireUser(); + let notifications = fallbackNotifications; + + try { + notifications = await getNotifications(user.id); + } catch { + notifications = fallbackNotifications; + } + + return ( + +
+
+

Notifications

+ +
+

See the latest updates on your work and complaints.

+

+ Assignment, field progress, reopening, and citizen verification updates appear here. +

+
+ + +
+ ); +} diff --git a/civic-platform/app/page.tsx b/civic-platform/app/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7f24e6556baf4ec4d82b828dc1ddebbc50f92818 --- /dev/null +++ b/civic-platform/app/page.tsx @@ -0,0 +1,15 @@ +import { DomainGrid } from "@/components/domain-grid"; +import { Hero } from "@/components/hero"; +import { AppShell } from "@/components/app-shell"; + +export default function Home() { + return ( + + + +
+ +
+
+ ); +} diff --git a/civic-platform/app/report/location/page.tsx b/civic-platform/app/report/location/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6f1155dfc6eba2170f520bbebfd7635d61e33579 --- /dev/null +++ b/civic-platform/app/report/location/page.tsx @@ -0,0 +1,170 @@ +import Link from "next/link"; +import { Crosshair, MapPinned, Navigation, ShieldAlert, Sparkles } from "lucide-react"; +import { AppShell } from "@/components/app-shell"; +import { FlowSteps } from "@/components/flow-steps"; + +const steps = ["Choose domain", "Add evidence", "Confirm location", "Review", "Submit"]; + +const locationChecks = [ + "Use current GPS for the quickest routing path.", + "If GPS is weak, move the map pin to the exact road, lane, or landmark.", + "Mention a nearby landmark so field staff can find the problem faster.", + "Emergency incidents should be pinned as precisely as possible.", +]; + +const locationFields = [ + "Latitude and longitude", + "Resolved address", + "Ward or zone", + "Nearby landmark", + "Road or locality name", + "Manual pin adjustment status", +]; + +export default function ReportLocationPage() { + return ( + +
+
+

Report flow

+

Confirm the issue location

+

+ This step helps the system assign the correct ward, identify the responsible department, and improve + routing accuracy for field teams. +

+
+ + +
+ +
+
+
+
+

Step 3

+

Map and address confirmation

+

+ In the MVP, this screen should open the user location, show a map preview, and let the citizen correct + the pin before moving to review. +

+
+
+ Routing-ready +
+
+ +
+
+
+ +
+
+

Interactive map

+

Pinned complaint location

+
+
+ +
+
+
+ +
+

Map preview placeholder

+

+ This area will use Leaflet with OpenStreetMap tiles, current location detection, drag-to-adjust pin, + and reverse geocoding for address confirmation. +

+
+
+ +
+ + +
+
+
+ +
+
+
+
+ +
+
+

Stored data

+

Location fields for the complaint

+
+
+ +
+ {locationFields.map((field) => ( +
+ {field} +
+ ))} +
+
+ +
+
+ +
+

Accuracy matters for emergency routing

+

+ A precise location helps responders find the incident faster, especially in flood, fire, collapse, + or live wire complaints. +

+
+
+ +
+ {locationChecks.map((item) => ( +
+ {item} +
+ ))} +
+
+
+
+ +
+
+

Next step

+

Review complaint details before submission

+

+ After location is confirmed, the user should see a review screen with domain, evidence, location, and the + initial routing preview. +

+
+ +
+ + Back to report + + + Continue to review + +
+
+
+ ); +} diff --git a/civic-platform/app/report/page.tsx b/civic-platform/app/report/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8a90c5540424720afb08007018fc08a2775daf18 --- /dev/null +++ b/civic-platform/app/report/page.tsx @@ -0,0 +1,27 @@ +import { AppShell } from "@/components/app-shell"; +import { ReportForm } from "@/components/report-form"; +import { requireRole } from "@/lib/auth"; + +export default async function ReportIssuePage() { + const user = await requireRole(["citizen"]); + + return ( + +
+
+

+ Citizen Reporting +

+

+ File a Report +

+

+ Tell us what happened and where. We will get it to the right department. +

+
+ + +
+
+ ); +} diff --git a/civic-platform/app/report/review/page.tsx b/civic-platform/app/report/review/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..1b7cc2ce928a8772f0086fca2d37d127878211fd --- /dev/null +++ b/civic-platform/app/report/review/page.tsx @@ -0,0 +1,165 @@ +import Link from "next/link"; +import { AlertTriangle, CheckCircle2, ChevronRight, ClipboardList, LocateFixed, ShieldAlert } from "lucide-react"; +import { AppShell } from "@/components/app-shell"; +import { FlowSteps } from "@/components/flow-steps"; + +const steps = ["Choose domain", "Add evidence", "Confirm location", "Review", "Submit"]; + +const reviewSections = [ + { + icon: ClipboardList, + title: "Complaint summary", + items: [ + "Selected domain: Water Supply, Sewerage, and Drainage", + "Likely sub-problem: Sewage overflow", + "Urgency hint: High public impact", + "Citizen note: Overflow near market entrance and bus stop", + ], + }, + { + icon: LocateFixed, + title: "Location confirmation", + items: [ + "Coordinates captured from device", + "Address resolved from map pin", + "Ward and routing zone ready", + "Nearby landmark noted for field staff", + ], + }, + { + icon: CheckCircle2, + title: "Evidence attached", + items: [ + "1 photo uploaded", + "0 video clips", + "Optional voice note available for later support", + "Media summary ready for department review", + ], + }, +]; + +const routingPreview = [ + "Primary department: Water and Sewerage", + "Secondary support: Drainage unit if monsoon overflow is detected", + "Initial priority: P2 High", + "Notification path: citizen confirmation after submission", +]; + +const submitChecks = [ + "Location is correct", + "Complaint is within city jurisdiction", + "Media is safe and relevant", + "Description is clear enough for field response", +]; + +export default function ReportReviewPage() { + return ( + +
+
+

Report flow

+

Review before final submission

+

+ Check the issue details, location, and evidence once more before sending the complaint to the city team. +

+
+ + +
+ +
+
+ {reviewSections.map(({ icon: Icon, title, items }) => ( +
+
+
+ +
+
+

Summary

+

{title}

+
+
+ +
+ {items.map((item) => ( +
+ {item} +
+ ))} +
+
+ ))} +
+ +
+
+
+
+ +
+
+

Routing preview

+

What the system will do next

+
+
+ +
+ {routingPreview.map((item) => ( +
+ {item} +
+ ))} +
+
+ +
+
+ +
+

Final checks before submission

+

+ A quick final check helps the city team act faster and reduces follow-up questions. +

+
+
+ +
+ {submitChecks.map((item) => ( +
+ {item} +
+ ))} +
+
+
+
+ +
+
+

Submit complaint

+

Ready to create the complaint record

+

+ Once submitted, your complaint will receive an ID and move into review, routing, and department assignment. +

+
+ +
+ + Back to location + + + Submit complaint + + +
+
+
+ ); +} diff --git a/civic-platform/app/report/success/page.tsx b/civic-platform/app/report/success/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..97e163d2a761524f6aa59ab02eb0c8ba1cfd44b2 --- /dev/null +++ b/civic-platform/app/report/success/page.tsx @@ -0,0 +1,173 @@ +import Link from "next/link"; +import { ArrowRight, CheckCircle2, Clock3, Hash, MapPinned, ShieldCheck } from "lucide-react"; +import { AppShell } from "@/components/app-shell"; +import { FlowSteps } from "@/components/flow-steps"; + +const steps = ["Choose domain", "Add evidence", "Confirm location", "Review", "Submit"]; + +const nextActions = [ + "Your report is stored with a complaint ID for tracking.", + "The first visible status is Submitted.", + "Routing and priority decide which department picks it up next.", + "You will see updates when the complaint is assigned, worked on, or resolved.", +]; + +const summaryCards = [ + { + icon: Hash, + label: "Complaint ID", + value: "CP-2026-00124", + }, + { + icon: Clock3, + label: "Current status", + value: "Submitted", + }, + { + icon: MapPinned, + label: "Location state", + value: "Geo-tag confirmed", + }, +]; + +type ReportSuccessPageProps = { + searchParams?: Promise<{ + id?: string; + code?: string; + media?: string; + }>; +}; + +export default async function ReportSuccessPage({ searchParams }: ReportSuccessPageProps) { + const resolvedSearchParams = searchParams ? await searchParams : undefined; + const complaintId = resolvedSearchParams?.id ?? "sample-complaint"; + const complaintCode = resolvedSearchParams?.code ?? "CP-2026-00124"; + const mediaStatus = resolvedSearchParams?.media ?? "complete"; + + return ( + +
+
+

Report flow

+

Complaint submitted successfully

+

+ Your complaint has been recorded. Keep the complaint ID handy and use the tracking page for updates. +

+
+ + +
+ +
+
+
+
+ +
+
+

Your complaint is now in the system.

+

+ It will now move through review, routing, assignment, and resolution. You can track progress from your + complaints page at any time. +

+ {mediaStatus === "partial" ? ( +

+ Your complaint was submitted, but one or more media files could not be uploaded. +

+ ) : null} +
+
+ + View my complaints + + + + Open complaint detail + + +
+
+ +
+ {summaryCards.map(({ icon: Icon, label, value }) => ( +
+ +

{label}

+

+ {label === "Complaint ID" ? complaintCode : value} +

+
+ ))} +
+
+
+ +
+
+
+
+ +
+
+

What happens next

+

Post-submission workflow

+
+
+ +
+ {nextActions.map((item) => ( +
+ {item} +
+ ))} +
+
+ +
+

Helpful shortcuts

+

What you can do from here

+

+ You can report another issue, open the complaint detail page, or switch to emergency reporting if the + situation has become urgent. +

+ +
+ {[ + "Complaint ID", + "Current status", + "Expected next step", + "Tracking shortcut", + "Submit another issue", + "Emergency reporting shortcut", + ].map((item) => ( +
+ {item} +
+ ))} +
+ +
+ + Submit another issue + + + Emergency reporting + +
+
+
+
+ ); +} diff --git a/civic-platform/app/tasks/page.tsx b/civic-platform/app/tasks/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0870124c7f8b4e28b8666aa13a7ef8843f900341 --- /dev/null +++ b/civic-platform/app/tasks/page.tsx @@ -0,0 +1,35 @@ +import { AppShell } from "@/components/app-shell"; +import { FieldTaskBoard } from "@/components/field-task-board"; +import { LiveSyncBadge } from "@/components/live-sync-badge"; +import { requireRole } from "@/lib/auth"; +import { getOfficerAssignments, type ComplaintAssignmentItem } from "@/lib/api"; + +const fallbackAssignments: ComplaintAssignmentItem[] = []; + +export default async function TasksPage() { + const user = await requireRole(["field_officer"]); + let assignments = fallbackAssignments; + + try { + assignments = await getOfficerAssignments(user.id); + } catch { + assignments = fallbackAssignments; + } + + return ( + +
+
+

Field operations

+ +
+

Work through assigned on-ground tasks.

+

+ Accept work, start field action, and mark completion so the complaint lifecycle stays accurate for citizens and operators. +

+
+ + +
+ ); +} diff --git a/civic-platform/app/template.tsx b/civic-platform/app/template.tsx new file mode 100644 index 0000000000000000000000000000000000000000..93aa998e624903799a4d5ed0cc55c7f9a512824e --- /dev/null +++ b/civic-platform/app/template.tsx @@ -0,0 +1,16 @@ +"use client"; + +import { motion } from "framer-motion"; + +export default function Template({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/civic-platform/backend/.env.example b/civic-platform/backend/.env.example new file mode 100644 index 0000000000000000000000000000000000000000..5f80aca98d09924595811db48a41287e05e6bd94 --- /dev/null +++ b/civic-platform/backend/.env.example @@ -0,0 +1,12 @@ +PORT=4000 +NODE_ENV=development +DB_HOST=localhost +DB_PORT=5432 +DB_NAME=civicpulse +DB_USER=postgres +DB_PASSWORD=your_password_here +DB_SSL=false +DEMO_AUTH_PASSWORD=civicpulse123 +CORS_ORIGINS=http://localhost:3000 +RATE_LIMIT_WINDOW_MS=60000 +RATE_LIMIT_MAX_REQUESTS=120 diff --git a/civic-platform/backend/README.md b/civic-platform/backend/README.md new file mode 100644 index 0000000000000000000000000000000000000000..2c06a51c7e679df27f65292d89bc7d27cf2fe720 --- /dev/null +++ b/civic-platform/backend/README.md @@ -0,0 +1,38 @@ +# Backend + +Node.js and TypeScript backend for the CivicPulse project. + +## Quick start + +1. Copy `.env.example` to `.env` +2. Set your PostgreSQL connection values +3. Create the `civicpulse` database +4. Apply `sql/001_initial_schema.sql` +5. Apply `sql/002_seed_core_data.sql` +6. Run `npm install` +7. Run `npm run dev` + +Alternative local setup: + +- Run `npm run db:init` to apply both SQL files using the configured PostgreSQL connection + +## Main folders + +- `src/config` environment config +- `src/db` PostgreSQL connection +- `src/routes` shared routes +- `src/modules/complaints` complaint APIs +- `sql` database schema files + +## First routes + +- `GET /api/health` +- `GET /api/departments` +- `GET /api/domains` +- `GET /api/complaints` +- `GET /api/complaints/citizen/:citizenId` +- `GET /api/complaints/:id` +- `GET /api/complaints/:id/history` +- `POST /api/complaints` +- `PATCH /api/complaints/:id/status` +- `POST /api/complaints/:id/assign` diff --git a/civic-platform/backend/backend-dev.log b/civic-platform/backend/backend-dev.log new file mode 100644 index 0000000000000000000000000000000000000000..9fa3d5b2557227ac94832c9946adfb17b91ee83f --- /dev/null +++ b/civic-platform/backend/backend-dev.log @@ -0,0 +1,5 @@ + +> civicpulse-backend@0.1.0 dev +> tsx watch src/server.ts + +CivicPulse backend listening on port 4000 diff --git a/civic-platform/backend/backend-run.log b/civic-platform/backend/backend-run.log new file mode 100644 index 0000000000000000000000000000000000000000..ee1ec7efbe152dd971dcf70fa492a3bb493e5b14 --- /dev/null +++ b/civic-platform/backend/backend-run.log @@ -0,0 +1,2 @@ +CivicPulse backend listening on port 4000 +^C \ No newline at end of file diff --git a/civic-platform/backend/backend.crash.log b/civic-platform/backend/backend.crash.log new file mode 100644 index 0000000000000000000000000000000000000000..df4f5c9a44683260a4734c7f70bd67b1111764ed Binary files /dev/null and b/civic-platform/backend/backend.crash.log differ diff --git a/civic-platform/backend/backend.detached.log b/civic-platform/backend/backend.detached.log new file mode 100644 index 0000000000000000000000000000000000000000..634bbabd05915f6d974767d4215a2eec9c12e5a5 --- /dev/null +++ b/civic-platform/backend/backend.detached.log @@ -0,0 +1 @@ +CivicPulse backend listening on port 4000 diff --git a/civic-platform/backend/backend.dev.log b/civic-platform/backend/backend.dev.log new file mode 100644 index 0000000000000000000000000000000000000000..df4f5c9a44683260a4734c7f70bd67b1111764ed Binary files /dev/null and b/civic-platform/backend/backend.dev.log differ diff --git a/civic-platform/backend/package-lock.json b/civic-platform/backend/package-lock.json new file mode 100644 index 0000000000000000000000000000000000000000..f1e8e1feeb9238597cc3d7909e7aa3c834250981 --- /dev/null +++ b/civic-platform/backend/package-lock.json @@ -0,0 +1,1817 @@ +{ + "name": "civicpulse-backend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "civicpulse-backend", + "version": "0.1.0", + "dependencies": { + "cors": "^2.8.5", + "dotenv": "^16.4.7", + "express": "^4.21.2", + "multer": "^2.1.1", + "pg": "^8.13.3" + }, + "devDependencies": { + "@types/cors": "^2.8.17", + "@types/express": "^5.0.1", + "@types/multer": "^2.1.0", + "@types/node": "^22.13.10", + "@types/pg": "^8.11.11", + "tsx": "^4.19.3", + "typescript": "^5.8.2" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", + "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", + "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", + "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", + "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", + "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", + "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", + "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", + "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", + "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", + "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", + "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", + "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", + "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", + "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", + "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", + "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", + "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", + "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", + "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", + "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", + "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", + "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", + "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", + "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", + "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", + "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz", + "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/multer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.1.0.tgz", + "integrity": "sha512-zYZb0+nJhOHtPpGDb3vqPjwpdeGlGC157VpkqNQL+UU2qwoacoQ7MpsAmUptI/0Oa127X32JzWDqQVEXp2RcIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, + "node_modules/@types/node": { + "version": "22.19.15", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", + "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, + "node_modules/@types/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", + "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.4", + "@esbuild/android-arm": "0.27.4", + "@esbuild/android-arm64": "0.27.4", + "@esbuild/android-x64": "0.27.4", + "@esbuild/darwin-arm64": "0.27.4", + "@esbuild/darwin-x64": "0.27.4", + "@esbuild/freebsd-arm64": "0.27.4", + "@esbuild/freebsd-x64": "0.27.4", + "@esbuild/linux-arm": "0.27.4", + "@esbuild/linux-arm64": "0.27.4", + "@esbuild/linux-ia32": "0.27.4", + "@esbuild/linux-loong64": "0.27.4", + "@esbuild/linux-mips64el": "0.27.4", + "@esbuild/linux-ppc64": "0.27.4", + "@esbuild/linux-riscv64": "0.27.4", + "@esbuild/linux-s390x": "0.27.4", + "@esbuild/linux-x64": "0.27.4", + "@esbuild/netbsd-arm64": "0.27.4", + "@esbuild/netbsd-x64": "0.27.4", + "@esbuild/openbsd-arm64": "0.27.4", + "@esbuild/openbsd-x64": "0.27.4", + "@esbuild/openharmony-arm64": "0.27.4", + "@esbuild/sunos-x64": "0.27.4", + "@esbuild/win32-arm64": "0.27.4", + "@esbuild/win32-ia32": "0.27.4", + "@esbuild/win32-x64": "0.27.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.7", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz", + "integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/multer": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz", + "integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.12.0", + "pg-pool": "^3.13.0", + "pg-protocol": "^1.13.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.3.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", + "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz", + "integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz", + "integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz", + "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + } + } +} diff --git a/civic-platform/backend/package.json b/civic-platform/backend/package.json new file mode 100644 index 0000000000000000000000000000000000000000..7b5ce8181cf5d29dfe2c9bdb6921ca4f64df6f1e --- /dev/null +++ b/civic-platform/backend/package.json @@ -0,0 +1,30 @@ +{ + "name": "civicpulse-backend", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "tsx watch src/server.ts", + "build": "tsc -p tsconfig.json", + "start": "node dist/server.js", + "db:init": "tsx src/scripts/init-db.ts", + "db:seed": "tsx src/scripts/seed-db.ts", + "smoke": "tsx src/scripts/smoke-test.ts" + }, + "dependencies": { + "cors": "^2.8.5", + "dotenv": "^16.4.7", + "express": "^4.21.2", + "multer": "^2.1.1", + "pg": "^8.13.3" + }, + "devDependencies": { + "@types/cors": "^2.8.17", + "@types/express": "^5.0.1", + "@types/multer": "^2.1.0", + "@types/node": "^22.13.10", + "@types/pg": "^8.11.11", + "tsx": "^4.19.3", + "typescript": "^5.8.2" + } +} diff --git a/civic-platform/backend/server.err.log b/civic-platform/backend/server.err.log new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/civic-platform/backend/server.out.log b/civic-platform/backend/server.out.log new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/civic-platform/backend/sql/001_initial_schema.sql b/civic-platform/backend/sql/001_initial_schema.sql new file mode 100644 index 0000000000000000000000000000000000000000..1599b560230e6c4422d024f75b28ca9e67767a33 --- /dev/null +++ b/civic-platform/backend/sql/001_initial_schema.sql @@ -0,0 +1,246 @@ +CREATE EXTENSION IF NOT EXISTS "pgcrypto"; + +CREATE TABLE roles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(50) NOT NULL UNIQUE, + description TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE departments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(120) NOT NULL UNIQUE, + code VARCHAR(40) NOT NULL UNIQUE, + description TEXT, + is_emergency BOOLEAN NOT NULL DEFAULT FALSE, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE wards ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(120) NOT NULL, + code VARCHAR(40) NOT NULL UNIQUE, + city_name VARCHAR(120) NOT NULL, + state_name VARCHAR(120), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + full_name VARCHAR(160) NOT NULL, + email VARCHAR(160) UNIQUE, + phone VARCHAR(20) UNIQUE, + password_hash TEXT, + role_id UUID NOT NULL REFERENCES roles(id), + department_id UUID REFERENCES departments(id), + ward_id UUID REFERENCES wards(id), + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE domains ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(120) NOT NULL UNIQUE, + description TEXT, + is_emergency BOOLEAN NOT NULL DEFAULT FALSE, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE sub_problems ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + domain_id UUID NOT NULL REFERENCES domains(id) ON DELETE CASCADE, + name VARCHAR(120) NOT NULL, + description TEXT, + severity_hint VARCHAR(20), + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT unique_sub_problem_per_domain UNIQUE (domain_id, name) +); + +CREATE TABLE complaints ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + complaint_code VARCHAR(40) NOT NULL UNIQUE, + citizen_id UUID NOT NULL REFERENCES users(id), + domain_id UUID REFERENCES domains(id), + sub_problem_id UUID REFERENCES sub_problems(id), + title VARCHAR(220) NOT NULL, + description TEXT, + status VARCHAR(32) NOT NULL, + priority_level VARCHAR(2) NOT NULL, + is_emergency BOOLEAN NOT NULL DEFAULT FALSE, + department_id UUID REFERENCES departments(id), + ward_id UUID REFERENCES wards(id), + submitted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + resolved_at TIMESTAMPTZ, + closed_at TIMESTAMPTZ, + reopened_count INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT complaints_status_check CHECK ( + status IN ( + 'submitted', + 'validated', + 'classified', + 'prioritized', + 'assigned', + 'accepted', + 'in_progress', + 'resolved', + 'citizen_verified', + 'closed', + 'escalated', + 'reopened', + 'duplicate', + 'rejected', + 'on_hold' + ) + ), + CONSTRAINT complaints_priority_check CHECK (priority_level IN ('P1', 'P2', 'P3', 'P4')) +); + +CREATE TABLE complaint_locations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + complaint_id UUID NOT NULL UNIQUE REFERENCES complaints(id) ON DELETE CASCADE, + latitude NUMERIC(10, 7) NOT NULL, + longitude NUMERIC(10, 7) NOT NULL, + address_line TEXT, + landmark VARCHAR(220), + city_name VARCHAR(120), + state_name VARCHAR(120), + postal_code VARCHAR(20), + ward_id UUID REFERENCES wards(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE complaint_media ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + complaint_id UUID NOT NULL REFERENCES complaints(id) ON DELETE CASCADE, + uploaded_by UUID REFERENCES users(id), + media_type VARCHAR(20) NOT NULL, + file_path TEXT NOT NULL, + file_url TEXT, + mime_type VARCHAR(120), + is_resolution_proof BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT complaint_media_type_check CHECK (media_type IN ('image', 'video', 'audio')) +); + +CREATE TABLE complaint_status_history ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + complaint_id UUID NOT NULL REFERENCES complaints(id) ON DELETE CASCADE, + old_status VARCHAR(32), + new_status VARCHAR(32) NOT NULL, + changed_by UUID REFERENCES users(id), + change_reason TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE assignments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + complaint_id UUID NOT NULL REFERENCES complaints(id) ON DELETE CASCADE, + department_id UUID NOT NULL REFERENCES departments(id), + assigned_to_user_id UUID REFERENCES users(id), + assigned_by_user_id UUID REFERENCES users(id), + assignment_status VARCHAR(20) NOT NULL, + assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + accepted_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + notes TEXT, + CONSTRAINT assignments_status_check CHECK ( + assignment_status IN ('assigned', 'accepted', 'in_progress', 'completed', 'reassigned') + ) +); + +CREATE TABLE routing_rules ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + domain_id UUID REFERENCES domains(id), + sub_problem_id UUID REFERENCES sub_problems(id), + ward_id UUID REFERENCES wards(id), + department_id UUID NOT NULL REFERENCES departments(id), + priority_override VARCHAR(2), + is_emergency_route BOOLEAN NOT NULL DEFAULT FALSE, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT routing_rules_priority_check CHECK (priority_override IS NULL OR priority_override IN ('P1', 'P2', 'P3', 'P4')) +); + +CREATE TABLE priority_rules ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + domain_id UUID REFERENCES domains(id), + sub_problem_id UUID REFERENCES sub_problems(id), + base_priority VARCHAR(2) NOT NULL, + near_sensitive_zone_boost INTEGER NOT NULL DEFAULT 0, + repeat_complaint_boost INTEGER NOT NULL DEFAULT 0, + reopened_boost INTEGER NOT NULL DEFAULT 0, + sla_breach_boost INTEGER NOT NULL DEFAULT 0, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT priority_rules_base_check CHECK (base_priority IN ('P1', 'P2', 'P3', 'P4')) +); + +CREATE TABLE complaint_notes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + complaint_id UUID NOT NULL REFERENCES complaints(id) ON DELETE CASCADE, + author_id UUID REFERENCES users(id), + note_type VARCHAR(20) NOT NULL, + note_text TEXT NOT NULL, + is_internal BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT complaint_notes_type_check CHECK ( + note_type IN ('operator_note', 'field_note', 'citizen_note', 'system_note') + ) +); + +CREATE TABLE notifications ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + complaint_id UUID REFERENCES complaints(id) ON DELETE CASCADE, + title VARCHAR(220) NOT NULL, + message TEXT NOT NULL, + notification_type VARCHAR(50) NOT NULL, + is_read BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE feedback ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + complaint_id UUID NOT NULL REFERENCES complaints(id) ON DELETE CASCADE, + citizen_id UUID NOT NULL REFERENCES users(id), + rating INTEGER CHECK (rating BETWEEN 1 AND 5), + comment TEXT, + reopen_requested BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE audit_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + actor_user_id UUID REFERENCES users(id), + entity_type VARCHAR(50) NOT NULL, + entity_id UUID, + action VARCHAR(120) NOT NULL, + details JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_users_role_id ON users(role_id); +CREATE INDEX idx_users_department_id ON users(department_id); +CREATE INDEX idx_users_ward_id ON users(ward_id); +CREATE INDEX idx_sub_problems_domain_id ON sub_problems(domain_id); +CREATE INDEX idx_complaints_citizen_id ON complaints(citizen_id); +CREATE INDEX idx_complaints_domain_id ON complaints(domain_id); +CREATE INDEX idx_complaints_department_id ON complaints(department_id); +CREATE INDEX idx_complaints_ward_id ON complaints(ward_id); +CREATE INDEX idx_complaints_status ON complaints(status); +CREATE INDEX idx_complaints_priority_level ON complaints(priority_level); +CREATE INDEX idx_complaints_submitted_at ON complaints(submitted_at); +CREATE INDEX idx_complaint_media_complaint_id ON complaint_media(complaint_id); +CREATE INDEX idx_complaint_status_history_complaint_id ON complaint_status_history(complaint_id); +CREATE INDEX idx_assignments_complaint_id ON assignments(complaint_id); +CREATE INDEX idx_assignments_department_id ON assignments(department_id); +CREATE INDEX idx_routing_rules_department_id ON routing_rules(department_id); +CREATE INDEX idx_notifications_user_id ON notifications(user_id); +CREATE INDEX idx_feedback_complaint_id ON feedback(complaint_id); +CREATE INDEX idx_audit_logs_actor_user_id ON audit_logs(actor_user_id); diff --git a/civic-platform/backend/sql/002_seed_core_data.sql b/civic-platform/backend/sql/002_seed_core_data.sql new file mode 100644 index 0000000000000000000000000000000000000000..a9db8dc0fd9624e8461feb91025b5affa94edd8c --- /dev/null +++ b/civic-platform/backend/sql/002_seed_core_data.sql @@ -0,0 +1,133 @@ +INSERT INTO roles (id, name, description) +VALUES + ('10000000-0000-0000-0000-000000000001', 'citizen', 'Citizen user who reports and tracks complaints'), + ('10000000-0000-0000-0000-000000000002', 'department_operator', 'Department operator who validates and assigns complaints'), + ('10000000-0000-0000-0000-000000000003', 'municipal_admin', 'Municipal administrator with cross-department access'), + ('10000000-0000-0000-0000-000000000004', 'field_officer', 'Field officer who executes assigned work on the ground') +ON CONFLICT (name) DO NOTHING; + +INSERT INTO departments (id, name, code, description, is_emergency) +VALUES + ('20000000-0000-0000-0000-000000000001', 'Roads and Public Works', 'roads-public-works', 'Handles roads, potholes, and transport infrastructure issues', FALSE), + ('20000000-0000-0000-0000-000000000002', 'Sanitation and Solid Waste', 'sanitation-solid-waste', 'Handles waste collection, garbage overflow, and sanitation complaints', FALSE), + ('20000000-0000-0000-0000-000000000003', 'Water and Sewerage', 'water-sewerage', 'Handles water leakage, sewage, and drainage issues', FALSE), + ('20000000-0000-0000-0000-000000000004', 'Electrical and Street Lighting', 'electrical-street-lighting', 'Handles streetlight and public electrical complaints', FALSE), + ('20000000-0000-0000-0000-000000000005', 'Municipal Maintenance', 'municipal-maintenance', 'Handles open manholes and civic infrastructure maintenance', FALSE), + ('20000000-0000-0000-0000-000000000006', 'Disaster Management Authority', 'disaster-management', 'Handles flood, collapse, rescue, and emergency coordination', TRUE) +ON CONFLICT (code) DO NOTHING; + +INSERT INTO wards (id, name, code, city_name, state_name) +VALUES + ('30000000-0000-0000-0000-000000000001', 'Ward 4', 'ward-4', 'Sample City', 'Sample State'), + ('30000000-0000-0000-0000-000000000002', 'Ward 8', 'ward-8', 'Sample City', 'Sample State'), + ('30000000-0000-0000-0000-000000000003', 'Ward 12', 'ward-12', 'Sample City', 'Sample State') +ON CONFLICT (code) DO NOTHING; + +INSERT INTO domains (id, name, description, is_emergency) +VALUES + ('40000000-0000-0000-0000-000000000001', 'Roads and Transportation', 'Road safety, traffic support, and mobility complaints', FALSE), + ('40000000-0000-0000-0000-000000000002', 'Sanitation and Waste Management', 'Waste collection, cleanliness, and sanitation complaints', FALSE), + ('40000000-0000-0000-0000-000000000003', 'Water Supply, Sewerage, and Drainage', 'Water access, sewage, and drainage complaints', FALSE), + ('40000000-0000-0000-0000-000000000004', 'Street Lighting and Electrical Infrastructure', 'Public lighting and electrical safety complaints', FALSE), + ('40000000-0000-0000-0000-000000000005', 'Public Infrastructure and Amenities', 'Maintenance needs for public assets and shared facilities', FALSE), + ('40000000-0000-0000-0000-000000000006', 'Environment and Public Health', 'Public hygiene and environmental safety concerns', FALSE), + ('40000000-0000-0000-0000-000000000007', 'Fire Emergencies', 'Emergency fire and smoke incidents', TRUE), + ('40000000-0000-0000-0000-000000000008', 'Flood and Water Disaster', 'Critical flood and water disaster events', TRUE) +ON CONFLICT (name) DO NOTHING; + +INSERT INTO users (id, full_name, email, phone, password_hash, role_id, department_id, ward_id) +VALUES + ( + '00000000-0000-0000-0000-000000000001', + 'Sample Citizen', + 'citizen@example.com', + '9000000001', + 'dev-placeholder-hash', + '10000000-0000-0000-0000-000000000001', + NULL, + '30000000-0000-0000-0000-000000000003' + ), + ( + '11111111-1111-1111-1111-111111111111', + 'Sample Operator', + 'operator@example.com', + '9000000002', + 'dev-placeholder-hash', + '10000000-0000-0000-0000-000000000002', + '20000000-0000-0000-0000-000000000005', + '30000000-0000-0000-0000-000000000001' + ), + ( + '22222222-2222-2222-2222-222222222222', + 'Sample Field Officer', + 'officer@example.com', + '9000000003', + 'dev-placeholder-hash', + '10000000-0000-0000-0000-000000000004', + '20000000-0000-0000-0000-000000000005', + '30000000-0000-0000-0000-000000000001' + ), + ( + '22222222-2222-2222-2222-222222222223', + 'Road Field Officer', + 'roads.officer@example.com', + '9000000004', + 'dev-placeholder-hash', + '10000000-0000-0000-0000-000000000004', + '20000000-0000-0000-0000-000000000001', + '30000000-0000-0000-0000-000000000001' + ), + ( + '22222222-2222-2222-2222-222222222224', + 'Sanitation Field Officer', + 'sanitation.officer@example.com', + '9000000005', + 'dev-placeholder-hash', + '10000000-0000-0000-0000-000000000004', + '20000000-0000-0000-0000-000000000002', + '30000000-0000-0000-0000-000000000002' + ), + ( + '22222222-2222-2222-2222-222222222225', + 'Water Field Officer', + 'water.officer@example.com', + '9000000006', + 'dev-placeholder-hash', + '10000000-0000-0000-0000-000000000004', + '20000000-0000-0000-0000-000000000003', + '30000000-0000-0000-0000-000000000002' + ), + ( + '22222222-2222-2222-2222-222222222226', + 'Electrical Field Officer', + 'electrical.officer@example.com', + '9000000007', + 'dev-placeholder-hash', + '10000000-0000-0000-0000-000000000004', + '20000000-0000-0000-0000-000000000004', + '30000000-0000-0000-0000-000000000003' + ) +ON CONFLICT (email) DO NOTHING; + +INSERT INTO routing_rules (domain_id, sub_problem_id, ward_id, department_id, priority_override, is_emergency_route) +VALUES + ('40000000-0000-0000-0000-000000000001', NULL, NULL, '20000000-0000-0000-0000-000000000001', NULL, FALSE), + ('40000000-0000-0000-0000-000000000002', NULL, NULL, '20000000-0000-0000-0000-000000000002', NULL, FALSE), + ('40000000-0000-0000-0000-000000000003', NULL, NULL, '20000000-0000-0000-0000-000000000003', NULL, FALSE), + ('40000000-0000-0000-0000-000000000004', NULL, NULL, '20000000-0000-0000-0000-000000000004', NULL, FALSE), + ('40000000-0000-0000-0000-000000000005', NULL, NULL, '20000000-0000-0000-0000-000000000005', 'P1', FALSE), + ('40000000-0000-0000-0000-000000000006', NULL, NULL, '20000000-0000-0000-0000-000000000002', NULL, FALSE), + ('40000000-0000-0000-0000-000000000007', NULL, NULL, '20000000-0000-0000-0000-000000000006', 'P1', TRUE), + ('40000000-0000-0000-0000-000000000008', NULL, NULL, '20000000-0000-0000-0000-000000000006', 'P1', TRUE); + +INSERT INTO priority_rules (domain_id, sub_problem_id, base_priority) +VALUES + ('40000000-0000-0000-0000-000000000001', NULL, 'P3'), + ('40000000-0000-0000-0000-000000000002', NULL, 'P3'), + ('40000000-0000-0000-0000-000000000003', NULL, 'P2'), + ('40000000-0000-0000-0000-000000000004', NULL, 'P3'), + ('40000000-0000-0000-0000-000000000005', NULL, 'P2'), + ('40000000-0000-0000-0000-000000000006', NULL, 'P3'), + ('40000000-0000-0000-0000-000000000007', NULL, 'P1'), + ('40000000-0000-0000-0000-000000000008', NULL, 'P1') +ON CONFLICT DO NOTHING; diff --git a/civic-platform/backend/sql/003_performance_indexes.sql b/civic-platform/backend/sql/003_performance_indexes.sql new file mode 100644 index 0000000000000000000000000000000000000000..1db8f51ac20a76ce5605ea32ed8cd34a69528963 --- /dev/null +++ b/civic-platform/backend/sql/003_performance_indexes.sql @@ -0,0 +1,32 @@ +CREATE INDEX IF NOT EXISTS idx_complaints_status_submitted_at + ON complaints (status, submitted_at DESC); + +CREATE INDEX IF NOT EXISTS idx_complaints_department_status + ON complaints (department_id, status, submitted_at DESC); + +CREATE INDEX IF NOT EXISTS idx_complaints_domain_submitted_at + ON complaints (domain_id, submitted_at DESC); + +CREATE INDEX IF NOT EXISTS idx_complaints_priority_status + ON complaints (priority_level, status, submitted_at DESC); + +CREATE INDEX IF NOT EXISTS idx_assignments_assigned_to_status + ON assignments (assigned_to_user_id, assignment_status, assigned_at DESC); + +CREATE INDEX IF NOT EXISTS idx_assignments_complaint_assigned_at + ON assignments (complaint_id, assigned_at DESC); + +CREATE INDEX IF NOT EXISTS idx_notifications_user_read_created + ON notifications (user_id, is_read, created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_status_history_complaint_created + ON complaint_status_history (complaint_id, created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_feedback_complaint_created + ON feedback (complaint_id, created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_notes_complaint_created + ON complaint_notes (complaint_id, created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_locations_lat_lng + ON complaint_locations (latitude, longitude); diff --git a/civic-platform/backend/sql/README.md b/civic-platform/backend/sql/README.md new file mode 100644 index 0000000000000000000000000000000000000000..e9a53920e9d5aa7b58d2898fd5c77c77c616a02b --- /dev/null +++ b/civic-platform/backend/sql/README.md @@ -0,0 +1,22 @@ +# SQL Files + +## Current files + +- `001_initial_schema.sql` +- `002_seed_core_data.sql` + +## Usage + +Apply the schema file to the `civicpulse` PostgreSQL database before running the complaint APIs. + +Recommended order: + +1. create database +2. apply `001_initial_schema.sql` +3. apply `002_seed_core_data.sql` +4. start backend + +Future migrations should continue with: + +- `003_...` +- `004_...` diff --git a/civic-platform/backend/src/app.ts b/civic-platform/backend/src/app.ts new file mode 100644 index 0000000000000000000000000000000000000000..fc4171e347f80aafe76c98802aeca48031c94349 --- /dev/null +++ b/civic-platform/backend/src/app.ts @@ -0,0 +1,48 @@ +import fs from "node:fs"; +import path from "node:path"; +import cors from "cors"; +import express from "express"; +import { aiRouter } from "./modules/ai/ai.routes.js"; +import { authRouter } from "./modules/auth/auth.routes.js"; +import { env } from "./config/env.js"; +import { rateLimitMiddleware, securityHeaders } from "./lib/security.js"; +import { complaintRouter } from "./modules/complaints/complaint.routes.js"; +import { departmentRouter } from "./modules/departments/department.routes.js"; +import { domainRouter } from "./modules/domains/domain.routes.js"; +import { userRouter } from "./modules/users/user.routes.js"; +import { healthRouter } from "./routes/health.js"; + +export function createApp() { + const app = express(); + const uploadsDir = path.resolve(process.cwd(), "uploads"); + + fs.mkdirSync(path.join(uploadsDir, "complaints"), { recursive: true }); + + app.set("trust proxy", 1); + app.use( + cors({ + origin: env.corsOrigins.split(",").map((value) => value.trim()), + }), + ); + app.use(securityHeaders); + app.use(rateLimitMiddleware); + app.use(express.json({ limit: "10mb" })); + app.use("/uploads", express.static(uploadsDir)); + + app.get("/", (_req, res) => { + res.json({ + name: "civicpulse-backend", + message: "CivicPulse backend is running", + }); + }); + + app.use("/api/health", healthRouter); + app.use("/api/ai", aiRouter); + app.use("/api/auth", authRouter); + app.use("/api/departments", departmentRouter); + app.use("/api/domains", domainRouter); + app.use("/api/users", userRouter); + app.use("/api/complaints", complaintRouter); + + return app; +} diff --git a/civic-platform/backend/src/config/env.ts b/civic-platform/backend/src/config/env.ts new file mode 100644 index 0000000000000000000000000000000000000000..168fce43b7d418146ae01f40d9b49320a231fd28 --- /dev/null +++ b/civic-platform/backend/src/config/env.ts @@ -0,0 +1,28 @@ +import dotenv from "dotenv"; + +dotenv.config(); + +function requireEnv(name: string, fallback?: string): string { + const value = process.env[name] ?? fallback; + + if (!value) { + throw new Error(`Missing required environment variable: ${name}`); + } + + return value; +} + +export const env = { + nodeEnv: requireEnv("NODE_ENV", "development"), + port: Number(requireEnv("PORT", "4000")), + corsOrigins: requireEnv("CORS_ORIGINS", "http://localhost:3000"), + dbHost: requireEnv("DB_HOST", "localhost"), + dbPort: Number(requireEnv("DB_PORT", "5432")), + dbName: requireEnv("DB_NAME", "civicpulse"), + dbUser: requireEnv("DB_USER", "postgres"), + dbPassword: requireEnv("DB_PASSWORD"), + dbSsl: requireEnv("DB_SSL", "false") === "true", + demoAuthPassword: requireEnv("DEMO_AUTH_PASSWORD", "civicpulse123"), + rateLimitWindowMs: Number(requireEnv("RATE_LIMIT_WINDOW_MS", "60000")), + rateLimitMaxRequests: Number(requireEnv("RATE_LIMIT_MAX_REQUESTS", "120")), +}; diff --git a/civic-platform/backend/src/db/pool.ts b/civic-platform/backend/src/db/pool.ts new file mode 100644 index 0000000000000000000000000000000000000000..1a515477d46661120bfac0a50ee6e91388defe7a --- /dev/null +++ b/civic-platform/backend/src/db/pool.ts @@ -0,0 +1,11 @@ +import { Pool } from "pg"; +import { env } from "../config/env.js"; + +export const db = new Pool({ + host: env.dbHost, + port: env.dbPort, + database: env.dbName, + user: env.dbUser, + password: env.dbPassword, + ssl: env.dbSsl ? { rejectUnauthorized: false } : false, +}); diff --git a/civic-platform/backend/src/lib/audit.ts b/civic-platform/backend/src/lib/audit.ts new file mode 100644 index 0000000000000000000000000000000000000000..92b3eca5274e20127eaa414b4fc7c1bb68eba8e4 --- /dev/null +++ b/civic-platform/backend/src/lib/audit.ts @@ -0,0 +1,29 @@ +import { db } from "../db/pool.js"; + +export async function writeAuditLog(input: { + actorUserId?: string | null; + entityType: string; + entityId?: string | null; + action: string; + details?: Record; +}) { + await db.query( + ` + INSERT INTO audit_logs ( + actor_user_id, + entity_type, + entity_id, + action, + details + ) + VALUES ($1, $2, $3, $4, $5::jsonb) + `, + [ + input.actorUserId ?? null, + input.entityType, + input.entityId ?? null, + input.action, + JSON.stringify(input.details ?? {}), + ], + ); +} diff --git a/civic-platform/backend/src/lib/cache.ts b/civic-platform/backend/src/lib/cache.ts new file mode 100644 index 0000000000000000000000000000000000000000..269ccfe751d66ef2ba32c29f525280fe13fb122c --- /dev/null +++ b/civic-platform/backend/src/lib/cache.ts @@ -0,0 +1,37 @@ +type CacheEntry = { + value: T; + expiresAt: number; +}; + +const store = new Map>(); + +export async function withCache(key: string, ttlMs: number, producer: () => Promise): Promise { + const now = Date.now(); + const existing = store.get(key); + + if (existing && existing.expiresAt > now) { + return existing.value as T; + } + + const value = await producer(); + store.set(key, { + value, + expiresAt: now + ttlMs, + }); + + return value; +} + +export function clearCache(keyPrefix?: string) { + if (!keyPrefix) { + store.clear(); + return; + } + + for (const key of store.keys()) { + if (key.startsWith(keyPrefix)) { + store.delete(key); + } + } +} + diff --git a/civic-platform/backend/src/lib/security.ts b/civic-platform/backend/src/lib/security.ts new file mode 100644 index 0000000000000000000000000000000000000000..d741061e7424364d341bafd88cce5750b6199eb9 --- /dev/null +++ b/civic-platform/backend/src/lib/security.ts @@ -0,0 +1,52 @@ +import type { NextFunction, Request, Response } from "express"; +import { env } from "../config/env.js"; + +const requestBuckets = new Map(); + +export function securityHeaders(_req: Request, res: Response, next: NextFunction) { + res.setHeader("X-Content-Type-Options", "nosniff"); + res.setHeader("X-Frame-Options", "DENY"); + res.setHeader("Referrer-Policy", "same-origin"); + res.setHeader("Permissions-Policy", "camera=(), microphone=(), geolocation=(self)"); + res.setHeader("Cross-Origin-Opener-Policy", "same-origin"); + next(); +} + +export function rateLimitMiddleware(req: Request, res: Response, next: NextFunction) { + const key = req.ip || req.socket.remoteAddress || "unknown"; + const now = Date.now(); + const current = requestBuckets.get(key); + + if (!current || current.resetAt <= now) { + requestBuckets.set(key, { + count: 1, + resetAt: now + env.rateLimitWindowMs, + }); + next(); + return; + } + + if (current.count >= env.rateLimitMaxRequests) { + res.status(429).json({ + error: "Too many requests. Please wait and try again.", + }); + return; + } + + current.count += 1; + requestBuckets.set(key, current); + next(); +} + +export function sanitizeText(input?: string, maxLength = 2000) { + if (!input) { + return undefined; + } + + return input + .replace(/<[^>]*>/g, " ") + .replace(/\s+/g, " ") + .trim() + .slice(0, maxLength); +} + diff --git a/civic-platform/backend/src/modules/ai/ai.engine.ts b/civic-platform/backend/src/modules/ai/ai.engine.ts new file mode 100644 index 0000000000000000000000000000000000000000..24ac91c92faf6e32226bf49764802086efa62d3e --- /dev/null +++ b/civic-platform/backend/src/modules/ai/ai.engine.ts @@ -0,0 +1,247 @@ +import type { ComplaintMapItem, NearbyComplaintItem } from "../complaints/complaint.types.js"; +import type { AiDomainSuggestion, DraftAnalysisInput, DraftAnalysisResult } from "./ai.types.js"; + +type DomainRule = { + id: string; + name: string; + departmentCode: string; + departmentName: string; + emergency: boolean; + keywords: string[]; +}; + +const domainRules: DomainRule[] = [ + { + id: "40000000-0000-0000-0000-000000000001", + name: "Roads and Transportation", + departmentCode: "roads-public-works", + departmentName: "Roads and Public Works", + emergency: false, + keywords: ["pothole", "road", "traffic", "signal", "divider", "footpath", "sidewalk", "junction", "blockage"], + }, + { + id: "40000000-0000-0000-0000-000000000002", + name: "Sanitation and Waste Management", + departmentCode: "sanitation-solid-waste", + departmentName: "Sanitation and Solid Waste", + emergency: false, + keywords: ["garbage", "waste", "overflowing bin", "dumping", "trash", "unclean", "sanitation"], + }, + { + id: "40000000-0000-0000-0000-000000000003", + name: "Water Supply, Sewerage, and Drainage", + departmentCode: "water-sewerage", + departmentName: "Water and Sewerage", + emergency: false, + keywords: ["sewage", "drain", "drainage", "waterlogging", "leak", "pipe", "overflow", "manhole overflow", "water supply"], + }, + { + id: "40000000-0000-0000-0000-000000000004", + name: "Street Lighting and Electrical Infrastructure", + departmentCode: "electrical-street-lighting", + departmentName: "Electrical and Street Lighting", + emergency: false, + keywords: ["streetlight", "pole", "light", "transformer", "wire", "power", "electric", "electrical"], + }, + { + id: "40000000-0000-0000-0000-000000000005", + name: "Public Infrastructure and Amenities", + departmentCode: "municipal-maintenance", + departmentName: "Municipal Maintenance", + emergency: false, + keywords: ["manhole", "bus stop", "bench", "toilet", "park", "amenity", "public facility", "damaged structure"], + }, + { + id: "40000000-0000-0000-0000-000000000006", + name: "Environment and Public Health", + departmentCode: "sanitation-solid-waste", + departmentName: "Sanitation and Solid Waste", + emergency: false, + keywords: ["mosquito", "stagnant water", "dead animal", "fallen tree", "smoke", "public health", "hygiene", "pollution"], + }, + { + id: "40000000-0000-0000-0000-000000000007", + name: "Fire Emergencies", + departmentCode: "disaster-management", + departmentName: "Disaster Management Authority", + emergency: true, + keywords: ["fire", "smoke", "burning", "flame", "blast"], + }, + { + id: "40000000-0000-0000-0000-000000000008", + name: "Flood and Water Disaster", + departmentCode: "disaster-management", + departmentName: "Disaster Management Authority", + emergency: true, + keywords: ["flood", "rescue", "collapse", "landslide", "disaster", "storm", "water disaster"], + }, +]; + +const criticalKeywords = ["fire", "flood", "collapse", "live wire", "electrocution", "blast", "rescue"]; +const highKeywords = ["sewage", "waterlogging", "open manhole", "transformer", "wire", "unsafe", "hazard", "blocked"]; +const sensitiveLocationKeywords = ["school", "hospital", "market", "junction", "bus stop", "bridge", "underpass"]; + +function tokenize(input: string) { + return input + .toLowerCase() + .replace(/[^a-z0-9\s]/g, " ") + .split(/\s+/) + .filter(Boolean); +} + +function unique(values: T[]) { + return [...new Set(values)]; +} + +function jaccardSimilarity(source: string, target: string) { + const sourceTokens = new Set(tokenize(source)); + const targetTokens = new Set(tokenize(target)); + + if (sourceTokens.size === 0 || targetTokens.size === 0) { + return 0; + } + + let intersection = 0; + + for (const token of sourceTokens) { + if (targetTokens.has(token)) { + intersection += 1; + } + } + + return intersection / (sourceTokens.size + targetTokens.size - intersection); +} + +export function analyzeDraft( + input: DraftAnalysisInput, + nearbyComplaints: NearbyComplaintItem[], +): DraftAnalysisResult { + const text = [input.title, input.description, input.addressLine, input.landmark].filter(Boolean).join(" ").toLowerCase(); + const visualSignals = input.visualSignals ?? []; + const matchedKeywords = unique( + [...criticalKeywords, ...highKeywords, ...sensitiveLocationKeywords].filter((keyword) => text.includes(keyword)), + ); + + const domainScored = domainRules + .map((domain) => { + const matchedSignals = domain.keywords.filter((keyword) => text.includes(keyword)); + const visualMatch = visualSignals.find((signal) => signal.label === domain.name); + const score = matchedSignals.length + (visualMatch ? visualMatch.confidence * 2 : 0) + (domain.emergency ? 0.2 : 0); + + return { + domain, + matchedSignals: unique([...matchedSignals, ...(visualMatch?.matchedSignals ?? [])]), + score, + }; + }) + .filter((entry) => entry.score > 0) + .sort((left, right) => right.score - left.score); + + const suggestedDomain: AiDomainSuggestion | null = domainScored[0] + ? { + domainId: domainScored[0].domain.id, + domainName: domainScored[0].domain.name, + confidence: Math.min(0.98, 0.45 + domainScored[0].score * 0.12), + matchedSignals: domainScored[0].matchedSignals, + } + : null; + + const hasCriticalSignal = criticalKeywords.some((keyword) => text.includes(keyword)); + const hasHighSignal = highKeywords.some((keyword) => text.includes(keyword)); + const hasSensitiveLocation = sensitiveLocationKeywords.some((keyword) => text.includes(keyword)); + const duplicateMatches = nearbyComplaints + .map((complaint) => ({ + complaintId: complaint.id, + complaintCode: complaint.complaintCode, + title: complaint.title, + distanceKm: complaint.distanceKm, + similarityScore: jaccardSimilarity(`${input.title ?? ""} ${input.description ?? ""}`, complaint.title), + status: complaint.status, + })) + .filter((complaint) => complaint.distanceKm <= 0.75 && complaint.similarityScore >= 0.15) + .sort((left, right) => { + if (right.similarityScore !== left.similarityScore) { + return right.similarityScore - left.similarityScore; + } + + return left.distanceKm - right.distanceKm; + }) + .slice(0, 4); + + const duplicateRisk = + duplicateMatches.some((item) => item.distanceKm <= 0.35 && item.similarityScore >= 0.45) + ? "high" + : duplicateMatches.length > 0 + ? "medium" + : "low"; + + const suggestedPriority = + hasCriticalSignal || suggestedDomain?.domainName === "Fire Emergencies" || suggestedDomain?.domainName === "Flood and Water Disaster" + ? "P1" + : hasHighSignal || hasSensitiveLocation + ? "P2" + : matchedKeywords.length > 0 + ? "P3" + : "P4"; + + const severityLevel = + suggestedPriority === "P1" + ? "critical" + : suggestedPriority === "P2" + ? "high" + : suggestedPriority === "P3" + ? "medium" + : "low"; + + const emergencyLikelihood = + hasCriticalSignal || suggestedDomain?.domainName === "Fire Emergencies" || suggestedDomain?.domainName === "Flood and Water Disaster" + ? "high" + : hasHighSignal + ? "medium" + : "low"; + + const reasonSummary = unique([ + suggestedDomain + ? `Suggested domain: ${suggestedDomain.domainName} based on keywords like ${suggestedDomain.matchedSignals.join(", ")}.` + : "No strong domain signal detected yet. A manual operator review may still be needed.", + visualSignals.length + ? `Image evidence contributed ${visualSignals.map((signal) => `${signal.label} (${(signal.confidence * 100).toFixed(0)}%)`).join(", ")}.` + : "", + hasSensitiveLocation ? "The location text suggests a sensitive area, so the complaint is treated as higher impact." : "", + duplicateRisk !== "low" ? `Nearby complaint similarity suggests ${duplicateRisk} duplicate risk.` : "", + input.mediaTypes?.length ? `Evidence includes ${input.mediaTypes.join(", ")} support, which improves routing confidence.` : "", + ]).filter(Boolean); + + return { + suggestedDomain, + suggestedPriority, + severityLevel, + emergencyLikelihood, + duplicateRisk, + routeDepartmentCode: suggestedDomain + ? domainRules.find((domain) => domain.id === suggestedDomain.domainId)?.departmentCode ?? null + : null, + routeDepartmentName: suggestedDomain + ? domainRules.find((domain) => domain.id === suggestedDomain.domainId)?.departmentName ?? null + : null, + speechReady: true, + visionReady: true, + reasonSummary, + matchedKeywords, + duplicateMatches, + }; +} + +export function analyzeComplaintForOperators(complaint: ComplaintMapItem | (ComplaintMapItem & { description?: string | null }), nearby: NearbyComplaintItem[]) { + return analyzeDraft( + { + title: complaint.title, + description: "description" in complaint ? complaint.description ?? undefined : undefined, + addressLine: complaint.addressLine ?? undefined, + landmark: complaint.landmark ?? undefined, + latitude: Number(complaint.latitude), + longitude: Number(complaint.longitude), + }, + nearby, + ); +} diff --git a/civic-platform/backend/src/modules/ai/ai.routes.ts b/civic-platform/backend/src/modules/ai/ai.routes.ts new file mode 100644 index 0000000000000000000000000000000000000000..8b53dcadfc728ac3cf5c898e0b9807a5f49d8ba9 --- /dev/null +++ b/civic-platform/backend/src/modules/ai/ai.routes.ts @@ -0,0 +1,32 @@ +import { Router } from "express"; +import { analyzeDraftService, getComplaintAiInsightsService } from "./ai.service.js"; + +export const aiRouter = Router(); + +aiRouter.post("/analyze-draft", async (req, res) => { + try { + const analysis = await analyzeDraftService(req.body); + res.json({ data: analysis }); + } catch (error) { + res.status(400).json({ + error: error instanceof Error ? error.message : "Failed to analyze complaint draft", + }); + } +}); + +aiRouter.get("/complaints/:id/insights", async (req, res) => { + try { + const analysis = await getComplaintAiInsightsService(req.params.id); + + if (!analysis) { + res.status(404).json({ error: "Complaint not found" }); + return; + } + + res.json({ data: analysis }); + } catch (error) { + res.status(400).json({ + error: error instanceof Error ? error.message : "Failed to fetch complaint insights", + }); + } +}); diff --git a/civic-platform/backend/src/modules/ai/ai.service.ts b/civic-platform/backend/src/modules/ai/ai.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..ee28a3b019b8eca19c4664451f7c3e89752170e8 --- /dev/null +++ b/civic-platform/backend/src/modules/ai/ai.service.ts @@ -0,0 +1,74 @@ +import { getComplaintById, listComplaintMedia, listNearbyComplaints } from "../complaints/complaint.repository.js"; +import { analyzeComplaintForOperators, analyzeDraft } from "./ai.engine.js"; +import type { DraftAnalysisInput } from "./ai.types.js"; + +export async function analyzeDraftService(input: DraftAnalysisInput) { + const nearby = + typeof input.latitude === "number" && typeof input.longitude === "number" + ? await listNearbyComplaints({ + latitude: input.latitude, + longitude: input.longitude, + radiusKm: 0.75, + domainId: input.domainId, + publicOnly: true, + }) + : []; + + return analyzeDraft(input, nearby); +} + +export async function getComplaintAiInsightsService(complaintId: string) { + if (!complaintId) { + throw new Error("complaintId is required"); + } + + const complaint = await getComplaintById(complaintId); + + if (!complaint) { + return null; + } + + const nearby = + complaint.latitude && complaint.longitude + ? await listNearbyComplaints({ + latitude: Number(complaint.latitude), + longitude: Number(complaint.longitude), + radiusKm: 0.75, + publicOnly: true, + }) + : []; + + const media = await listComplaintMedia(complaintId); + + const analysis = analyzeComplaintForOperators( + { + id: complaint.id, + complaintCode: complaint.complaintCode, + title: complaint.title, + status: complaint.status, + priorityLevel: complaint.priorityLevel, + isEmergency: complaint.isEmergency, + domainName: complaint.domainName, + departmentName: complaint.departmentName, + wardName: null, + latitude: complaint.latitude ?? "0", + longitude: complaint.longitude ?? "0", + addressLine: complaint.addressLine, + landmark: complaint.landmark, + submittedAt: complaint.submittedAt, + description: complaint.description, + }, + nearby.filter((item) => item.id !== complaint.id), + ); + + return { + ...analysis, + mediaSummary: { + totalEvidence: media.length, + hasImage: media.some((item) => item.mediaType === "image"), + hasVideo: media.some((item) => item.mediaType === "video"), + hasAudio: media.some((item) => item.mediaType === "audio"), + hasResolutionProof: media.some((item) => item.isResolutionProof), + }, + }; +} diff --git a/civic-platform/backend/src/modules/ai/ai.types.ts b/civic-platform/backend/src/modules/ai/ai.types.ts new file mode 100644 index 0000000000000000000000000000000000000000..e08cd62ee1013fe4da538525e4d41f205da7de22 --- /dev/null +++ b/civic-platform/backend/src/modules/ai/ai.types.ts @@ -0,0 +1,46 @@ +export type DraftAnalysisInput = { + title?: string; + description?: string; + addressLine?: string; + landmark?: string; + latitude?: number; + longitude?: number; + domainId?: string; + mediaTypes?: Array<"image" | "video" | "audio">; + visualSignals?: Array<{ + label: string; + confidence: number; + matchedSignals: string[]; + }>; +}; + +export type AiDomainSuggestion = { + domainId: string | null; + domainName: string; + confidence: number; + matchedSignals: string[]; +}; + +export type AiDuplicateMatch = { + complaintId: string; + complaintCode: string; + title: string; + distanceKm: number; + similarityScore: number; + status: string; +}; + +export type DraftAnalysisResult = { + suggestedDomain: AiDomainSuggestion | null; + suggestedPriority: "P1" | "P2" | "P3" | "P4"; + severityLevel: "critical" | "high" | "medium" | "low"; + emergencyLikelihood: "high" | "medium" | "low"; + duplicateRisk: "high" | "medium" | "low"; + routeDepartmentCode: string | null; + routeDepartmentName: string | null; + speechReady: boolean; + visionReady: boolean; + reasonSummary: string[]; + matchedKeywords: string[]; + duplicateMatches: AiDuplicateMatch[]; +}; diff --git a/civic-platform/backend/src/modules/auth/auth.repository.ts b/civic-platform/backend/src/modules/auth/auth.repository.ts new file mode 100644 index 0000000000000000000000000000000000000000..86d21754a03677ced9128a9adc088279b13526ed --- /dev/null +++ b/civic-platform/backend/src/modules/auth/auth.repository.ts @@ -0,0 +1,68 @@ +import { db } from "../../db/pool.js"; +import type { AuthUser, RegisterInput } from "./auth.types.js"; + +type AuthUserRow = AuthUser & { + passwordHash: string | null; +}; + +export async function getUserForLogin(email: string): Promise { + const result = await db.query( + ` + SELECT + u.id, + u.full_name AS "fullName", + u.email, + r.name AS role, + u.department_id AS "departmentId", + u.ward_id AS "wardId", + u.password_hash AS "passwordHash" + FROM users u + INNER JOIN roles r ON r.id = u.role_id + WHERE lower(u.email) = lower($1) + AND u.is_active = TRUE + LIMIT 1 + `, + [email], + ); + + return result.rows[0] ?? null; +} + +export async function createCitizenUser(input: RegisterInput): Promise { + const result = await db.query( + ` + INSERT INTO users ( + full_name, + email, + phone, + password_hash, + role_id + ) + SELECT + $1, + lower($2), + $3, + $4, + r.id + FROM roles r + WHERE r.name = 'citizen' + RETURNING + id, + full_name AS "fullName", + email, + 'citizen' AS role, + department_id AS "departmentId", + ward_id AS "wardId", + password_hash AS "passwordHash" + `, + [input.fullName, input.email, input.phone ?? null, input.password], + ); + + const user = result.rows[0]; + + if (!user) { + throw new Error("Citizen role is not configured"); + } + + return user; +} diff --git a/civic-platform/backend/src/modules/auth/auth.routes.ts b/civic-platform/backend/src/modules/auth/auth.routes.ts new file mode 100644 index 0000000000000000000000000000000000000000..6619029b72113e256d38573df4e835cd7b77afdf --- /dev/null +++ b/civic-platform/backend/src/modules/auth/auth.routes.ts @@ -0,0 +1,37 @@ +import { Router } from "express"; +import { loginService, registerCitizenService } from "./auth.service.js"; + +export const authRouter = Router(); + +authRouter.post("/login", async (req, res) => { + try { + const user = await loginService({ + email: req.body.email, + password: req.body.password, + }); + + res.json({ data: user }); + } catch (error) { + res.status(401).json({ + error: error instanceof Error ? error.message : "Login failed", + }); + } +}); + +authRouter.post("/register", async (req, res) => { + try { + const user = await registerCitizenService({ + fullName: req.body.fullName, + email: req.body.email, + phone: req.body.phone, + password: req.body.password, + }); + + res.status(201).json({ data: user }); + } catch (error) { + const message = error instanceof Error ? error.message : "Registration failed"; + const status = message.includes("already exists") ? 409 : 400; + + res.status(status).json({ error: message }); + } +}); diff --git a/civic-platform/backend/src/modules/auth/auth.service.ts b/civic-platform/backend/src/modules/auth/auth.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..3fe0858b09c56d7fe9ebea12bdf6ab82329205b1 --- /dev/null +++ b/civic-platform/backend/src/modules/auth/auth.service.ts @@ -0,0 +1,80 @@ +import { env } from "../../config/env.js"; +import { createCitizenUser, getUserForLogin } from "./auth.repository.js"; +import type { LoginInput, RegisterInput } from "./auth.types.js"; + +function validateLoginInput(input: Partial) { + if (!input.email) { + throw new Error("email is required"); + } + + if (!input.password) { + throw new Error("password is required"); + } +} + +function isValidPassword(storedHash: string | null, password: string) { + if (!storedHash) { + return false; + } + + if (storedHash === password) { + return true; + } + + if (storedHash === "dev-placeholder-hash" && password === env.demoAuthPassword) { + return true; + } + + return false; +} + +export async function loginService(input: Partial) { + validateLoginInput(input); + + const user = await getUserForLogin(input.email as string); + + if (!user || !isValidPassword(user.passwordHash, input.password as string)) { + throw new Error("Invalid email or password"); + } + + const { passwordHash: _passwordHash, ...sessionUser } = user; + return sessionUser; +} + +function validateRegisterInput(input: Partial) { + if (!input.fullName?.trim()) { + throw new Error("fullName is required"); + } + + if (!input.email?.trim()) { + throw new Error("email is required"); + } + + if (!input.password) { + throw new Error("password is required"); + } + + if (input.password.length < 8) { + throw new Error("Password must be at least 8 characters"); + } +} + +export async function registerCitizenService(input: Partial) { + validateRegisterInput(input); + + const existingUser = await getUserForLogin(input.email as string); + + if (existingUser) { + throw new Error("An account already exists for this email"); + } + + const user = await createCitizenUser({ + fullName: input.fullName!.trim(), + email: input.email!.trim().toLowerCase(), + phone: input.phone?.trim() || undefined, + password: input.password!, + }); + + const { passwordHash: _passwordHash, ...sessionUser } = user; + return sessionUser; +} diff --git a/civic-platform/backend/src/modules/auth/auth.types.ts b/civic-platform/backend/src/modules/auth/auth.types.ts new file mode 100644 index 0000000000000000000000000000000000000000..ed678d1d58fc6338aba2ceb105daa573a3675663 --- /dev/null +++ b/civic-platform/backend/src/modules/auth/auth.types.ts @@ -0,0 +1,20 @@ +export type AuthUser = { + id: string; + fullName: string; + email: string | null; + role: string; + departmentId: string | null; + wardId: string | null; +}; + +export type LoginInput = { + email: string; + password: string; +}; + +export type RegisterInput = { + fullName: string; + email: string; + phone?: string; + password: string; +}; diff --git a/civic-platform/backend/src/modules/complaints/.gitkeep b/civic-platform/backend/src/modules/complaints/.gitkeep new file mode 100644 index 0000000000000000000000000000000000000000..8b137891791fe96927ad78e64b0aad7bded08bdc --- /dev/null +++ b/civic-platform/backend/src/modules/complaints/.gitkeep @@ -0,0 +1 @@ + diff --git a/civic-platform/backend/src/modules/complaints/complaint.geo.ts b/civic-platform/backend/src/modules/complaints/complaint.geo.ts new file mode 100644 index 0000000000000000000000000000000000000000..e1f7925be055214b98d5e185975e2eb64e098e39 --- /dev/null +++ b/civic-platform/backend/src/modules/complaints/complaint.geo.ts @@ -0,0 +1,75 @@ +const wardCentroids = [ + { + id: "30000000-0000-0000-0000-000000000001", + code: "ward-4", + name: "Ward 4", + latitude: 17.4012, + longitude: 78.4749, + }, + { + id: "30000000-0000-0000-0000-000000000002", + code: "ward-8", + name: "Ward 8", + latitude: 17.3725, + longitude: 78.5028, + }, + { + id: "30000000-0000-0000-0000-000000000003", + code: "ward-12", + name: "Ward 12", + latitude: 17.3562, + longitude: 78.4728, + }, +] as const; + +function toRadians(value: number) { + return (value * Math.PI) / 180; +} + +export function calculateDistanceKm( + firstLatitude: number, + firstLongitude: number, + secondLatitude: number, + secondLongitude: number, +) { + const earthRadiusKm = 6371; + const latitudeDelta = toRadians(secondLatitude - firstLatitude); + const longitudeDelta = toRadians(secondLongitude - firstLongitude); + const originLatitude = toRadians(firstLatitude); + const targetLatitude = toRadians(secondLatitude); + + const a = + Math.sin(latitudeDelta / 2) ** 2 + + Math.sin(longitudeDelta / 2) ** 2 * Math.cos(originLatitude) * Math.cos(targetLatitude); + + return 2 * earthRadiusKm * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); +} + +export function inferWardFromCoordinates(latitude: number, longitude: number) { + const ranked = wardCentroids + .map((ward) => ({ + ...ward, + distanceKm: calculateDistanceKm(latitude, longitude, ward.latitude, ward.longitude), + })) + .sort((left, right) => left.distanceKm - right.distanceKm); + + const nearest = ranked[0]; + + if (!nearest) { + return null; + } + + return { + id: nearest.id, + code: nearest.code, + name: nearest.name, + distanceKm: nearest.distanceKm, + confidence: + nearest.distanceKm < 1.5 + ? "high" + : nearest.distanceKm < 3 + ? "medium" + : "low", + } as const; +} + diff --git a/civic-platform/backend/src/modules/complaints/complaint.repository.ts b/civic-platform/backend/src/modules/complaints/complaint.repository.ts new file mode 100644 index 0000000000000000000000000000000000000000..2990d057b7c5827b3a077aef2ec7f214c7b779c2 --- /dev/null +++ b/civic-platform/backend/src/modules/complaints/complaint.repository.ts @@ -0,0 +1,1902 @@ +import type { PoolClient } from "pg"; +import { db } from "../../db/pool.js"; +import type { + AnalyticsSummary, + AssignComplaintInput, + ComplaintNotificationItem, + ComplaintAssignmentItem, + ComplaintNoteItem, + ComplaintDetail, + ComplaintHotspotItem, + DashboardSummary, + ComplaintFeedbackItem, + ComplaintListItem, + ComplaintMapItem, + ComplaintMediaItem, + NearbyComplaintItem, + ComplaintStatusHistoryItem, + CreateComplaintInput, + CreateComplaintNoteInput, + SubmitComplaintFeedbackInput, + UpdateAssignmentStatusInput, + UploadComplaintMediaInput, + UpdateComplaintStatusInput, +} from "./complaint.types.js"; +import { calculateDistanceKm, inferWardFromCoordinates } from "./complaint.geo.js"; +import { clearCache } from "../../lib/cache.js"; +import { writeAuditLog } from "../../lib/audit.js"; + +type DomainMeta = { + id: string; + name: string; + isEmergency: boolean; +}; + +type RoutingMatch = { + departmentId: string; + priorityOverride: string | null; +}; + +const fallbackDepartmentCodeByDomain: Record = { + "Roads and Transportation": "roads-public-works", + "Sanitation and Waste Management": "sanitation-solid-waste", + "Water Supply, Sewerage, and Drainage": "water-sewerage", + "Street Lighting and Electrical Infrastructure": "electrical-street-lighting", + "Public Infrastructure and Amenities": "municipal-maintenance", + "Environment and Public Health": "sanitation-solid-waste", + "Fire Emergencies": "disaster-management", + "Flood and Water Disaster": "disaster-management", + "Structural and Infrastructure Hazard": "disaster-management", + "Electrical Hazard": "disaster-management", + "Disaster and Rescue": "disaster-management", +}; + +const domainBasePriority: Record = { + "Roads and Transportation": "P3", + "Sanitation and Waste Management": "P3", + "Water Supply, Sewerage, and Drainage": "P2", + "Street Lighting and Electrical Infrastructure": "P3", + "Public Infrastructure and Amenities": "P2", + "Environment and Public Health": "P3", + "Fire Emergencies": "P1", + "Flood and Water Disaster": "P1", + "Structural and Infrastructure Hazard": "P1", + "Electrical Hazard": "P1", + "Disaster and Rescue": "P1", +}; + +function buildComplaintCode() { + const now = new Date(); + const year = now.getFullYear(); + const millis = now.getTime().toString().slice(-6); + + return `CP-${year}-${millis}`; +} + +function priorityToScore(priority: string) { + switch (priority) { + case "P1": + return 4; + case "P2": + return 3; + case "P3": + return 2; + default: + return 1; + } +} + +function scoreToPriority(score: number): "P1" | "P2" | "P3" | "P4" { + if (score >= 4) return "P1"; + if (score === 3) return "P2"; + if (score === 2) return "P3"; + return "P4"; +} + +function keywordPriorityBoost(input: CreateComplaintInput) { + const text = `${input.title} ${input.description ?? ""} ${input.addressLine ?? ""} ${input.landmark ?? ""}`.toLowerCase(); + + const criticalKeywords = [ + "fire", + "flood", + "collapse", + "collapsing", + "live wire", + "electrical hazard", + "electrocution", + "blast", + "explosion", + "rescue", + "landslide", + "open manhole", + ]; + + if (criticalKeywords.some((keyword) => text.includes(keyword))) { + return 4; + } + + const highKeywords = [ + "sewage", + "overflow", + "waterlogging", + "blocked drain", + "drainage", + "transformer", + "wire", + "pothole", + "road blockage", + "unsafe", + "hazard", + ]; + + if (highKeywords.some((keyword) => text.includes(keyword))) { + return 3; + } + + const mediumKeywords = ["garbage", "streetlight", "leak", "broken", "damaged", "mosquito", "fallen tree"]; + + if (mediumKeywords.some((keyword) => text.includes(keyword))) { + return 2; + } + + return 0; +} + +function sensitiveLocationBoost(input: CreateComplaintInput) { + const text = `${input.addressLine ?? ""} ${input.landmark ?? ""}`.toLowerCase(); + const sensitiveKeywords = ["school", "hospital", "junction", "bus stop", "market", "highway", "underpass"]; + + return sensitiveKeywords.some((keyword) => text.includes(keyword)) ? 1 : 0; +} + +async function getDomainMeta(client: PoolClient, domainId?: string) { + if (!domainId) { + return null; + } + + const result = await client.query( + ` + SELECT id, name, is_emergency AS "isEmergency" + FROM domains + WHERE id = $1 + LIMIT 1 + `, + [domainId], + ); + + return result.rows[0] ?? null; +} + +async function findRoutingRule(client: PoolClient, input: CreateComplaintInput, isEmergency: boolean) { + const result = await client.query( + ` + SELECT + rr.department_id AS "departmentId", + rr.priority_override AS "priorityOverride" + FROM routing_rules rr + WHERE rr.is_active = TRUE + AND (rr.domain_id IS NULL OR rr.domain_id = $1) + AND (rr.sub_problem_id IS NULL OR rr.sub_problem_id = $2) + AND (rr.ward_id IS NULL OR rr.ward_id = $3) + ORDER BY + CASE WHEN rr.domain_id = $1 THEN 1 ELSE 0 END DESC, + CASE WHEN rr.sub_problem_id = $2 THEN 1 ELSE 0 END DESC, + CASE WHEN rr.ward_id = $3 THEN 1 ELSE 0 END DESC, + CASE WHEN $4 = TRUE AND rr.is_emergency_route = TRUE THEN 1 ELSE 0 END DESC, + rr.created_at ASC + LIMIT 1 + `, + [input.domainId ?? null, input.subProblemId ?? null, input.wardId ?? null, isEmergency], + ); + + return result.rows[0] ?? null; +} + +async function getDepartmentIdByCode(client: PoolClient, code: string) { + const result = await client.query<{ id: string }>( + ` + SELECT id + FROM departments + WHERE code = $1 + LIMIT 1 + `, + [code], + ); + + return result.rows[0]?.id ?? null; +} + +async function findPriorityRule(client: PoolClient, input: CreateComplaintInput) { + const result = await client.query<{ basePriority: string }>( + ` + SELECT base_priority AS "basePriority" + FROM priority_rules + WHERE is_active = TRUE + AND (domain_id IS NULL OR domain_id = $1) + AND (sub_problem_id IS NULL OR sub_problem_id = $2) + ORDER BY + CASE WHEN domain_id = $1 THEN 1 ELSE 0 END DESC, + CASE WHEN sub_problem_id = $2 THEN 1 ELSE 0 END DESC, + created_at ASC + LIMIT 1 + `, + [input.domainId ?? null, input.subProblemId ?? null], + ); + + return result.rows[0]?.basePriority ?? null; +} + +async function deriveWorkflow(client: PoolClient, input: CreateComplaintInput) { + const domain = await getDomainMeta(client, input.domainId); + const isEmergency = Boolean(input.isEmergency || domain?.isEmergency); + const routingRule = await findRoutingRule(client, input, isEmergency); + + let departmentId = routingRule?.departmentId ?? null; + + if (!departmentId && domain?.name) { + const fallbackCode = fallbackDepartmentCodeByDomain[domain.name]; + + if (fallbackCode) { + departmentId = await getDepartmentIdByCode(client, fallbackCode); + } + } + + const baseFromRules = routingRule?.priorityOverride ?? (await findPriorityRule(client, input)); + const basePriority = + (baseFromRules as "P1" | "P2" | "P3" | "P4" | null) ?? + (domain?.name ? domainBasePriority[domain.name] : null) ?? + (isEmergency ? "P1" : "P3"); + + const derivedScore = Math.max( + priorityToScore(basePriority), + keywordPriorityBoost(input), + Math.min(4, priorityToScore(basePriority) + sensitiveLocationBoost(input)), + ); + + const priorityLevel = scoreToPriority(derivedScore); + const status = input.domainId ? "prioritized" : "submitted"; + + return { + departmentId, + isEmergency, + priorityLevel, + status, + domainName: domain?.name ?? null, + }; +} + +async function createNotification( + client: PoolClient, + input: { + userId: string; + complaintId?: string | null; + title: string; + message: string; + notificationType: string; + }, +) { + await client.query( + ` + INSERT INTO notifications ( + user_id, + complaint_id, + title, + message, + notification_type + ) + VALUES ($1, $2, $3, $4, $5) + `, + [input.userId, input.complaintId ?? null, input.title, input.message, input.notificationType], + ); +} + +async function createAuditLog( + client: PoolClient, + input: { + actorUserId?: string | null; + entityType: string; + entityId?: string | null; + action: string; + details?: Record; + }, +) { + await client.query( + ` + INSERT INTO audit_logs ( + actor_user_id, + entity_type, + entity_id, + action, + details + ) + VALUES ($1, $2, $3, $4, $5::jsonb) + `, + [ + input.actorUserId ?? null, + input.entityType, + input.entityId ?? null, + input.action, + JSON.stringify(input.details ?? {}), + ], + ); +} + +export async function createComplaint(input: CreateComplaintInput) { + const complaintCode = buildComplaintCode(); + const client = await db.connect(); + const inferredWard = input.wardId ? null : inferWardFromCoordinates(input.latitude, input.longitude); + const normalizedInput = { + ...input, + wardId: input.wardId ?? inferredWard?.id, + }; + + try { + await client.query("BEGIN"); + + const workflow = await deriveWorkflow(client, normalizedInput); + + const complaintResult = await client.query( + ` + INSERT INTO complaints ( + complaint_code, + citizen_id, + domain_id, + sub_problem_id, + title, + description, + status, + priority_level, + is_emergency, + department_id, + ward_id + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + RETURNING id, complaint_code, status, priority_level, submitted_at + `, + [ + complaintCode, + normalizedInput.citizenId, + normalizedInput.domainId ?? null, + normalizedInput.subProblemId ?? null, + normalizedInput.title, + normalizedInput.description ?? null, + workflow.status, + workflow.priorityLevel, + workflow.isEmergency, + workflow.departmentId, + normalizedInput.wardId ?? null, + ], + ); + + const complaint = complaintResult.rows[0]; + + await client.query( + ` + INSERT INTO complaint_locations ( + complaint_id, + latitude, + longitude, + address_line, + landmark, + city_name, + state_name, + postal_code, + ward_id + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + `, + [ + complaint.id, + normalizedInput.latitude, + normalizedInput.longitude, + normalizedInput.addressLine ?? null, + normalizedInput.landmark ?? null, + normalizedInput.cityName ?? null, + normalizedInput.stateName ?? null, + normalizedInput.postalCode ?? null, + normalizedInput.wardId ?? null, + ], + ); + + await client.query( + ` + INSERT INTO complaint_status_history ( + complaint_id, + old_status, + new_status, + changed_by, + change_reason + ) + VALUES ($1, $2, $3, $4, $5) + `, + [complaint.id, null, "submitted", input.citizenId, "Complaint submitted by citizen"], + ); + + if (input.domainId) { + await client.query( + ` + INSERT INTO complaint_status_history ( + complaint_id, + old_status, + new_status, + changed_by, + change_reason + ) + VALUES ($1, $2, $3, $4, $5) + `, + [ + complaint.id, + "submitted", + "classified", + input.citizenId, + workflow.domainName ? `Complaint classified under ${workflow.domainName}` : "Complaint classified", + ], + ); + + await client.query( + ` + INSERT INTO complaint_status_history ( + complaint_id, + old_status, + new_status, + changed_by, + change_reason + ) + VALUES ($1, $2, $3, $4, $5) + `, + [ + complaint.id, + "classified", + workflow.status, + input.citizenId, + workflow.departmentId + ? `Priority ${workflow.priorityLevel} and routed to department queue` + : `Priority ${workflow.priorityLevel} assigned`, + ], + ); + } + + await createAuditLog(client, { + actorUserId: normalizedInput.citizenId, + entityType: "complaint", + entityId: complaint.id, + action: "complaint_created", + details: { + complaintCode, + priorityLevel: workflow.priorityLevel, + departmentId: workflow.departmentId, + wardId: normalizedInput.wardId ?? null, + isEmergency: workflow.isEmergency, + }, + }); + + await client.query("COMMIT"); + clearCache(); + return complaint; + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } +} + +export async function listComplaints(): Promise { + const result = await db.query( + ` + SELECT + c.id, + c.complaint_code AS "complaintCode", + c.title, + c.status, + c.priority_level AS "priorityLevel", + c.is_emergency AS "isEmergency", + c.submitted_at AS "submittedAt", + d.name AS "domainName", + dept.name AS "departmentName", + cl.address_line AS "addressLine", + cl.landmark + FROM complaints c + LEFT JOIN domains d ON d.id = c.domain_id + LEFT JOIN departments dept ON dept.id = c.department_id + LEFT JOIN complaint_locations cl ON cl.complaint_id = c.id + ORDER BY c.submitted_at DESC + `, + ); + + return result.rows; +} + +export async function listComplaintsByCitizen(citizenId: string): Promise { + const result = await db.query( + ` + SELECT + c.id, + c.complaint_code AS "complaintCode", + c.title, + c.status, + c.priority_level AS "priorityLevel", + c.is_emergency AS "isEmergency", + c.submitted_at AS "submittedAt", + d.name AS "domainName", + dept.name AS "departmentName", + cl.address_line AS "addressLine", + cl.landmark + FROM complaints c + LEFT JOIN domains d ON d.id = c.domain_id + LEFT JOIN departments dept ON dept.id = c.department_id + LEFT JOIN complaint_locations cl ON cl.complaint_id = c.id + WHERE c.citizen_id = $1 + ORDER BY c.submitted_at DESC + `, + [citizenId], + ); + + return result.rows; +} + +export async function listMapComplaints(options?: { + publicOnly?: boolean; + status?: string; + domainId?: string; + priorityLevel?: string; + emergencyOnly?: boolean; +}): Promise { + const result = await db.query( + ` + SELECT + c.id, + c.complaint_code AS "complaintCode", + c.title, + c.status, + c.priority_level AS "priorityLevel", + c.is_emergency AS "isEmergency", + d.name AS "domainName", + dept.name AS "departmentName", + w.name AS "wardName", + cl.latitude::text AS latitude, + cl.longitude::text AS longitude, + cl.address_line AS "addressLine", + cl.landmark, + c.submitted_at AS "submittedAt" + FROM complaints c + LEFT JOIN domains d ON d.id = c.domain_id + LEFT JOIN departments dept ON dept.id = c.department_id + INNER JOIN complaint_locations cl ON cl.complaint_id = c.id + LEFT JOIN wards w ON w.id = COALESCE(c.ward_id, cl.ward_id) + WHERE ($1::boolean = FALSE OR c.status NOT IN ('rejected', 'duplicate', 'on_hold')) + AND ($2::text IS NULL OR c.status = $2) + AND ($3::uuid IS NULL OR c.domain_id = $3) + AND ($4::text IS NULL OR c.priority_level = $4) + AND ($5::boolean = FALSE OR c.is_emergency = TRUE) + ORDER BY c.submitted_at DESC + `, + [ + options?.publicOnly ?? false, + options?.status ?? null, + options?.domainId ?? null, + options?.priorityLevel ?? null, + options?.emergencyOnly ?? false, + ], + ); + + return result.rows; +} + +export async function listPublicHotspots(): Promise { + const complaints = await listMapComplaints({ publicOnly: true }); + const hotspotMap = new Map< + string, + { + latitudeSum: number; + longitudeSum: number; + complaintCount: number; + criticalCount: number; + emergencyCount: number; + domainCounts: Map; + } + >(); + + for (const complaint of complaints) { + const latitude = Number(complaint.latitude); + const longitude = Number(complaint.longitude); + const hotspotKey = `${latitude.toFixed(2)}:${longitude.toFixed(2)}`; + const current = hotspotMap.get(hotspotKey) ?? { + latitudeSum: 0, + longitudeSum: 0, + complaintCount: 0, + criticalCount: 0, + emergencyCount: 0, + domainCounts: new Map(), + }; + + current.latitudeSum += latitude; + current.longitudeSum += longitude; + current.complaintCount += 1; + current.criticalCount += complaint.priorityLevel === "P1" ? 1 : 0; + current.emergencyCount += complaint.isEmergency ? 1 : 0; + + if (complaint.domainName) { + current.domainCounts.set(complaint.domainName, (current.domainCounts.get(complaint.domainName) ?? 0) + 1); + } + + hotspotMap.set(hotspotKey, current); + } + + return [...hotspotMap.entries()] + .map(([hotspotKey, value]) => { + const topDomain = + [...value.domainCounts.entries()].sort((left, right) => right[1] - left[1])[0]?.[0] ?? null; + + return { + hotspotKey, + latitude: value.latitudeSum / value.complaintCount, + longitude: value.longitudeSum / value.complaintCount, + complaintCount: value.complaintCount, + criticalCount: value.criticalCount, + emergencyCount: value.emergencyCount, + topDomain, + }; + }) + .sort((left, right) => right.complaintCount - left.complaintCount) + .slice(0, 12); +} + +export async function listNearbyComplaints(input: { + latitude: number; + longitude: number; + radiusKm: number; + domainId?: string; + publicOnly?: boolean; +}): Promise { + const complaints = await listMapComplaints({ + publicOnly: input.publicOnly ?? true, + domainId: input.domainId, + }); + + return complaints + .map((complaint) => { + const distanceKm = calculateDistanceKm( + input.latitude, + input.longitude, + Number(complaint.latitude), + Number(complaint.longitude), + ); + + return { + id: complaint.id, + complaintCode: complaint.complaintCode, + title: complaint.title, + status: complaint.status, + priorityLevel: complaint.priorityLevel, + domainName: complaint.domainName, + distanceKm, + addressLine: complaint.addressLine, + landmark: complaint.landmark, + }; + }) + .filter((complaint) => complaint.distanceKm <= input.radiusKm) + .sort((left, right) => left.distanceKm - right.distanceKm) + .slice(0, 8); +} + +export async function getDashboardSummary(): Promise { + const [overviewResult, urgentQueueResult, departmentWorkloadResult] = await Promise.all([ + db.query( + ` + SELECT + COUNT(*) FILTER (WHERE status NOT IN ('closed', 'resolved', 'citizen_verified', 'rejected', 'duplicate'))::int AS "openComplaints", + COUNT(*) FILTER (WHERE priority_level = 'P1' AND status NOT IN ('closed', 'resolved', 'citizen_verified', 'rejected', 'duplicate'))::int AS "criticalComplaints", + COUNT(*) FILTER ( + WHERE status NOT IN ('closed', 'resolved', 'citizen_verified', 'rejected', 'duplicate') + AND ( + reopened_count > 0 + OR submitted_at < NOW() - INTERVAL '24 hours' + ) + )::int AS "slaRisk", + COUNT(*) FILTER (WHERE resolved_at >= CURRENT_DATE)::int AS "resolvedToday" + FROM complaints + `, + ), + db.query( + ` + SELECT + c.id, + c.complaint_code AS "complaintCode", + c.title, + d.name AS "domainName", + c.priority_level AS "priorityLevel", + COALESCE(cl.address_line, cl.landmark, dept.name, 'Location pending') AS location, + c.submitted_at AS "submittedAt" + FROM complaints c + LEFT JOIN domains d ON d.id = c.domain_id + LEFT JOIN complaint_locations cl ON cl.complaint_id = c.id + LEFT JOIN departments dept ON dept.id = c.department_id + WHERE c.status NOT IN ('closed', 'resolved', 'citizen_verified', 'rejected', 'duplicate') + ORDER BY + CASE c.priority_level + WHEN 'P1' THEN 1 + WHEN 'P2' THEN 2 + WHEN 'P3' THEN 3 + ELSE 4 + END, + c.submitted_at ASC + LIMIT 5 + `, + ), + db.query( + ` + SELECT + COALESCE(dept.name, 'Unassigned') AS "departmentName", + COUNT(*)::int AS "pendingCount" + FROM complaints c + LEFT JOIN departments dept ON dept.id = c.department_id + WHERE c.status NOT IN ('closed', 'resolved', 'citizen_verified', 'rejected', 'duplicate') + GROUP BY COALESCE(dept.name, 'Unassigned') + ORDER BY "pendingCount" DESC, "departmentName" ASC + LIMIT 6 + `, + ), + ]); + + return { + overview: overviewResult.rows[0] ?? { + openComplaints: 0, + criticalComplaints: 0, + slaRisk: 0, + resolvedToday: 0, + }, + urgentQueue: urgentQueueResult.rows, + departmentWorkload: departmentWorkloadResult.rows, + }; +} + +export async function getAnalyticsSummary(): Promise { + const [trendRows, domainRows, departmentRows, kpiRows] = await Promise.all([ + db.query( + ` + WITH days AS ( + SELECT generate_series(CURRENT_DATE - INTERVAL '6 days', CURRENT_DATE, INTERVAL '1 day')::date AS day + ) + SELECT + to_char(days.day, 'YYYY-MM-DD') AS day, + COUNT(c.id) FILTER (WHERE c.submitted_at::date = days.day)::int AS "reportedCount", + COUNT(c.id) FILTER (WHERE c.resolved_at IS NOT NULL AND c.resolved_at::date = days.day)::int AS "resolvedCount" + FROM days + LEFT JOIN complaints c + ON c.submitted_at::date = days.day + OR (c.resolved_at IS NOT NULL AND c.resolved_at::date = days.day) + GROUP BY days.day + ORDER BY days.day ASC + `, + ), + db.query( + ` + SELECT + COALESCE(d.name, 'Unclassified') AS "domainName", + COUNT(*)::int AS "complaintCount" + FROM complaints c + LEFT JOIN domains d ON d.id = c.domain_id + GROUP BY COALESCE(d.name, 'Unclassified') + ORDER BY "complaintCount" DESC, "domainName" ASC + LIMIT 8 + `, + ), + db.query( + ` + SELECT + COALESCE(dept.name, 'Unassigned') AS "departmentName", + COUNT(*) FILTER (WHERE c.resolved_at IS NOT NULL)::int AS "resolvedCount", + COUNT(*) FILTER (WHERE c.status NOT IN ('closed', 'resolved', 'citizen_verified', 'rejected', 'duplicate'))::int AS "pendingCount", + COALESCE( + ROUND( + AVG( + EXTRACT(EPOCH FROM (c.resolved_at - c.submitted_at)) / 3600 + ) FILTER (WHERE c.resolved_at IS NOT NULL) + , 2), + 0 + )::float AS "averageResolutionHours" + FROM complaints c + LEFT JOIN departments dept ON dept.id = c.department_id + GROUP BY COALESCE(dept.name, 'Unassigned') + ORDER BY "pendingCount" DESC, "resolvedCount" DESC + LIMIT 8 + `, + ), + db.query( + ` + SELECT + COUNT(*) FILTER (WHERE is_emergency = TRUE)::int AS "emergencyCount", + COUNT(*) FILTER (WHERE reopened_count > 0)::int AS "reopenedCount", + COUNT(*) FILTER (WHERE reopened_count > 1)::int AS "repeatComplaintRiskCount", + COUNT(*) FILTER ( + WHERE status NOT IN ('closed', 'resolved', 'citizen_verified', 'rejected', 'duplicate') + AND submitted_at < NOW() - INTERVAL '24 hours' + )::int AS "slaBreaches" + FROM complaints + `, + ), + ]); + + return { + trends: trendRows.rows, + domainBreakdown: domainRows.rows, + departmentPerformance: departmentRows.rows, + kpis: kpiRows.rows[0] ?? { + emergencyCount: 0, + reopenedCount: 0, + repeatComplaintRiskCount: 0, + slaBreaches: 0, + }, + }; +} + +export async function exportAnalyticsCsv() { + const analytics = await getAnalyticsSummary(); + const lines = [ + "section,name,value,secondary", + ...analytics.domainBreakdown.map((row) => `domain,${row.domainName},${row.complaintCount},`), + ...analytics.departmentPerformance.map( + (row) => + `department,${row.departmentName},${row.pendingCount},avg_resolution_hours:${row.averageResolutionHours}`, + ), + ...analytics.trends.map((row) => `trend,${row.day},reported:${row.reportedCount},resolved:${row.resolvedCount}`), + `kpi,emergency_count,${analytics.kpis.emergencyCount},`, + `kpi,reopened_count,${analytics.kpis.reopenedCount},`, + `kpi,repeat_risk_count,${analytics.kpis.repeatComplaintRiskCount},`, + `kpi,sla_breaches,${analytics.kpis.slaBreaches},`, + ]; + + return lines.join("\n"); +} + +export async function getComplaintById(id: string): Promise { + const result = await db.query( + ` + SELECT + c.id, + c.complaint_code AS "complaintCode", + c.title, + c.description, + c.status, + c.priority_level AS "priorityLevel", + c.is_emergency AS "isEmergency", + c.submitted_at AS "submittedAt", + c.department_id AS "departmentId", + c.ward_id AS "wardId", + c.citizen_id AS "citizenId", + d.name AS "domainName", + dept.name AS "departmentName", + cl.latitude, + cl.longitude, + cl.address_line AS "addressLine", + cl.landmark + FROM complaints c + LEFT JOIN domains d ON d.id = c.domain_id + LEFT JOIN departments dept ON dept.id = c.department_id + LEFT JOIN complaint_locations cl ON cl.complaint_id = c.id + WHERE c.id = $1 OR c.complaint_code = $1 + LIMIT 1 + `, + [id], + ); + + return result.rows[0] ?? null; +} + +export async function getComplaintStatusHistory(id: string): Promise { + const result = await db.query( + ` + SELECT + csh.id, + csh.complaint_id AS "complaintId", + csh.old_status AS "oldStatus", + csh.new_status AS "newStatus", + csh.changed_by AS "changedBy", + csh.change_reason AS "changeReason", + csh.created_at AS "createdAt" + FROM complaint_status_history csh + INNER JOIN complaints c ON c.id = csh.complaint_id + WHERE c.id = $1 OR c.complaint_code = $1 + ORDER BY csh.created_at ASC + `, + [id], + ); + + return result.rows; +} + +export async function uploadComplaintMedia(input: UploadComplaintMediaInput): Promise { + const complaintResult = await db.query( + ` + SELECT id + FROM complaints + WHERE id = $1 OR complaint_code = $1 + LIMIT 1 + `, + [input.complaintId], + ); + + const complaint = complaintResult.rows[0]; + + if (!complaint) { + return null; + } + + const result = await db.query( + ` + INSERT INTO complaint_media ( + complaint_id, + uploaded_by, + media_type, + file_path, + file_url, + mime_type, + is_resolution_proof + ) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING + id, + complaint_id AS "complaintId", + media_type AS "mediaType", + file_url AS "fileUrl", + mime_type AS "mimeType", + is_resolution_proof AS "isResolutionProof", + created_at AS "createdAt" + `, + [ + complaint.id, + input.uploadedBy ?? null, + input.mediaType, + input.filePath, + input.fileUrl, + input.mimeType ?? null, + input.isResolutionProof ?? false, + ], + ); + + return result.rows[0] ?? null; +} + +export async function listComplaintMedia(id: string): Promise { + const result = await db.query( + ` + SELECT + cm.id, + cm.complaint_id AS "complaintId", + cm.media_type AS "mediaType", + cm.file_url AS "fileUrl", + cm.mime_type AS "mimeType", + cm.is_resolution_proof AS "isResolutionProof", + cm.created_at AS "createdAt" + FROM complaint_media cm + INNER JOIN complaints c ON c.id = cm.complaint_id + WHERE c.id = $1 OR c.complaint_code = $1 + ORDER BY cm.created_at ASC + `, + [id], + ); + + return result.rows; +} + +export async function listComplaintFeedback(id: string): Promise { + const result = await db.query( + ` + SELECT + f.id, + f.complaint_id AS "complaintId", + f.citizen_id AS "citizenId", + f.rating, + f.comment, + f.reopen_requested AS "reopenRequested", + f.created_at AS "createdAt" + FROM feedback f + INNER JOIN complaints c ON c.id = f.complaint_id + WHERE c.id = $1 OR c.complaint_code = $1 + ORDER BY f.created_at DESC + `, + [id], + ); + + return result.rows; +} + +export async function listNotificationsByUser(userId: string): Promise { + const result = await db.query( + ` + SELECT + n.id, + n.user_id AS "userId", + n.complaint_id AS "complaintId", + n.title, + n.message, + n.notification_type AS "notificationType", + n.is_read AS "isRead", + n.created_at AS "createdAt" + FROM notifications n + WHERE n.user_id = $1 + ORDER BY n.is_read ASC, n.created_at DESC + `, + [userId], + ); + + return result.rows; +} + +export async function markNotificationAsRead(notificationId: string, userId: string) { + const result = await db.query( + ` + UPDATE notifications + SET is_read = TRUE + WHERE id = $1 + AND user_id = $2 + RETURNING + id, + user_id AS "userId", + complaint_id AS "complaintId", + title, + message, + notification_type AS "notificationType", + is_read AS "isRead", + created_at AS "createdAt" + `, + [notificationId, userId], + ); + + return result.rows[0] ?? null; +} + +export async function markAllNotificationsAsRead(userId: string) { + const result = await db.query( + ` + UPDATE notifications + SET is_read = TRUE + WHERE user_id = $1 + AND is_read = FALSE + RETURNING id + `, + [userId], + ); + + return { + userId, + updatedCount: result.rowCount ?? 0, + }; +} + +export async function listAssignmentsByOfficer(userId: string): Promise { + const result = await db.query( + ` + SELECT + a.id, + a.complaint_id AS "complaintId", + c.complaint_code AS "complaintCode", + c.title AS "complaintTitle", + a.department_id AS "departmentId", + d.name AS "departmentName", + a.assigned_to_user_id AS "assignedToUserId", + assigned_to.full_name AS "assignedToName", + a.assigned_by_user_id AS "assignedByUserId", + assigned_by.full_name AS "assignedByName", + a.assignment_status AS "assignmentStatus", + a.assigned_at AS "assignedAt", + a.accepted_at AS "acceptedAt", + a.completed_at AS "completedAt", + COUNT(cm.id)::int AS "resolutionProofCount", + a.notes + FROM assignments a + INNER JOIN complaints c ON c.id = a.complaint_id + INNER JOIN departments d ON d.id = a.department_id + LEFT JOIN users assigned_to ON assigned_to.id = a.assigned_to_user_id + LEFT JOIN users assigned_by ON assigned_by.id = a.assigned_by_user_id + LEFT JOIN complaint_media cm + ON cm.complaint_id = a.complaint_id + AND cm.is_resolution_proof = TRUE + WHERE a.assigned_to_user_id = $1 + GROUP BY + a.id, + c.complaint_code, + c.title, + d.name, + assigned_to.full_name, + assigned_by.full_name + ORDER BY + CASE a.assignment_status + WHEN 'assigned' THEN 1 + WHEN 'accepted' THEN 2 + WHEN 'in_progress' THEN 3 + ELSE 4 + END, + a.assigned_at DESC + `, + [userId], + ); + + return result.rows; +} + +export async function listComplaintAssignments(id: string): Promise { + const result = await db.query( + ` + SELECT + a.id, + a.complaint_id AS "complaintId", + c.complaint_code AS "complaintCode", + c.title AS "complaintTitle", + a.department_id AS "departmentId", + d.name AS "departmentName", + a.assigned_to_user_id AS "assignedToUserId", + assigned_to.full_name AS "assignedToName", + a.assigned_by_user_id AS "assignedByUserId", + assigned_by.full_name AS "assignedByName", + a.assignment_status AS "assignmentStatus", + a.assigned_at AS "assignedAt", + a.accepted_at AS "acceptedAt", + a.completed_at AS "completedAt", + COUNT(cm.id)::int AS "resolutionProofCount", + a.notes + FROM assignments a + INNER JOIN complaints c ON c.id = a.complaint_id + INNER JOIN departments d ON d.id = a.department_id + LEFT JOIN users assigned_to ON assigned_to.id = a.assigned_to_user_id + LEFT JOIN users assigned_by ON assigned_by.id = a.assigned_by_user_id + LEFT JOIN complaint_media cm + ON cm.complaint_id = a.complaint_id + AND cm.is_resolution_proof = TRUE + WHERE c.id = $1 OR c.complaint_code = $1 + GROUP BY + a.id, + c.complaint_code, + c.title, + d.name, + assigned_to.full_name, + assigned_by.full_name + ORDER BY a.assigned_at DESC + `, + [id], + ); + + return result.rows; +} + +export async function getAssignmentById(assignmentId: string) { + const result = await db.query( + ` + SELECT + a.id, + a.complaint_id AS "complaintId", + a.department_id AS "departmentId", + a.assigned_to_user_id AS "assignedToUserId", + a.assigned_by_user_id AS "assignedByUserId", + a.assignment_status AS "assignmentStatus", + c.status AS "complaintStatus" + FROM assignments a + INNER JOIN complaints c ON c.id = a.complaint_id + WHERE a.id = $1 + LIMIT 1 + `, + [assignmentId], + ); + + return result.rows[0] ?? null; +} + +export async function complaintHasResolutionProof(complaintId: string) { + const result = await db.query<{ hasProof: boolean }>( + ` + SELECT EXISTS ( + SELECT 1 + FROM complaint_media + WHERE complaint_id = $1 + AND is_resolution_proof = TRUE + ) AS "hasProof" + `, + [complaintId], + ); + + return result.rows[0]?.hasProof ?? false; +} + +export async function hasOfficerAssignment(complaintId: string, officerId: string) { + const result = await db.query<{ hasAssignment: boolean }>( + ` + SELECT EXISTS ( + SELECT 1 + FROM assignments + WHERE complaint_id = $1 + AND assigned_to_user_id = $2 + ) AS "hasAssignment" + `, + [complaintId, officerId], + ); + + return result.rows[0]?.hasAssignment ?? false; +} + +export async function listComplaintNotes(id: string): Promise { + const result = await db.query( + ` + SELECT + cn.id, + cn.complaint_id AS "complaintId", + cn.author_id AS "authorId", + cn.note_type AS "noteType", + cn.note_text AS "noteText", + cn.is_internal AS "isInternal", + cn.created_at AS "createdAt", + u.full_name AS "authorName" + FROM complaint_notes cn + INNER JOIN complaints c ON c.id = cn.complaint_id + LEFT JOIN users u ON u.id = cn.author_id + WHERE c.id = $1 OR c.complaint_code = $1 + ORDER BY cn.created_at DESC + `, + [id], + ); + + return result.rows; +} + +export async function createComplaintNote(input: CreateComplaintNoteInput): Promise { + const complaintResult = await db.query( + ` + SELECT id + FROM complaints + WHERE id = $1 OR complaint_code = $1 + LIMIT 1 + `, + [input.complaintId], + ); + + const complaint = complaintResult.rows[0]; + + if (!complaint) { + return null; + } + + const result = await db.query( + ` + INSERT INTO complaint_notes ( + complaint_id, + author_id, + note_type, + note_text, + is_internal + ) + VALUES ($1, $2, $3, $4, $5) + RETURNING + id, + complaint_id AS "complaintId", + author_id AS "authorId", + note_type AS "noteType", + note_text AS "noteText", + is_internal AS "isInternal", + created_at AS "createdAt" + `, + [ + complaint.id, + input.authorId, + input.noteType, + input.noteText, + input.isInternal ?? true, + ], + ); + + const note = result.rows[0]; + + if (!note) { + return null; + } + + const authorResult = await db.query<{ fullName: string }>( + ` + SELECT full_name AS "fullName" + FROM users + WHERE id = $1 + LIMIT 1 + `, + [input.authorId], + ); + + await writeAuditLog({ + actorUserId: input.authorId, + entityType: "complaint_note", + entityId: note.id, + action: "complaint_note_created", + details: { + complaintId: complaint.id, + noteType: input.noteType, + isInternal: input.isInternal ?? true, + }, + }); + clearCache(); + + return { + ...note, + authorName: authorResult.rows[0]?.fullName ?? null, + }; +} + +export async function submitComplaintFeedback(input: SubmitComplaintFeedbackInput): Promise { + const client = await db.connect(); + + try { + await client.query("BEGIN"); + + const complaintResult = await client.query( + ` + SELECT id, status + FROM complaints + WHERE id = $1 OR complaint_code = $1 + LIMIT 1 + `, + [input.complaintId], + ); + + const complaint = complaintResult.rows[0]; + + if (!complaint) { + await client.query("ROLLBACK"); + return null; + } + + const feedbackResult = await client.query( + ` + INSERT INTO feedback ( + complaint_id, + citizen_id, + rating, + comment, + reopen_requested + ) + VALUES ($1, $2, $3, $4, $5) + RETURNING + id, + complaint_id AS "complaintId", + citizen_id AS "citizenId", + rating, + comment, + reopen_requested AS "reopenRequested", + created_at AS "createdAt" + `, + [ + complaint.id, + input.citizenId, + input.rating ?? null, + input.comment ?? null, + input.reopenRequested ?? false, + ], + ); + + if (input.reopenRequested) { + await client.query( + ` + UPDATE complaints + SET + status = 'reopened', + updated_at = NOW(), + reopened_count = reopened_count + 1 + WHERE id = $1 + `, + [complaint.id], + ); + + await client.query( + ` + INSERT INTO complaint_status_history ( + complaint_id, + old_status, + new_status, + changed_by, + change_reason + ) + VALUES ($1, $2, 'reopened', $3, $4) + `, + [ + complaint.id, + complaint.status, + input.citizenId, + input.comment?.trim() || "Complaint reopened by citizen after follow-up feedback", + ], + ); + + const ownerResult = await client.query<{ assignedByUserId: string | null }>( + ` + SELECT assigned_by_user_id AS "assignedByUserId" + FROM assignments + WHERE complaint_id = $1 + ORDER BY assigned_at DESC + LIMIT 1 + `, + [complaint.id], + ); + + if (ownerResult.rows[0]?.assignedByUserId) { + await createNotification(client, { + userId: ownerResult.rows[0].assignedByUserId, + complaintId: complaint.id, + title: "Complaint reopened by citizen", + message: input.comment?.trim() || "A citizen reopened the complaint after follow-up.", + notificationType: "complaint_reopened", + }); + } + } + + await createAuditLog(client, { + actorUserId: input.citizenId, + entityType: "feedback", + entityId: feedbackResult.rows[0]?.id ?? null, + action: input.reopenRequested ? "complaint_reopened_by_feedback" : "complaint_feedback_submitted", + details: { + complaintId: complaint.id, + rating: input.rating ?? null, + }, + }); + + await client.query("COMMIT"); + clearCache(); + return feedbackResult.rows[0] ?? null; + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } +} + +export async function updateComplaintStatus(input: UpdateComplaintStatusInput) { + const currentResult = await db.query( + ` + SELECT id, status + FROM complaints + WHERE id = $1 OR complaint_code = $1 + LIMIT 1 + `, + [input.complaintId], + ); + + const currentComplaint = currentResult.rows[0]; + + if (!currentComplaint) { + return null; + } + + const updatedResult = await db.query( + ` + UPDATE complaints + SET + status = $2, + updated_at = NOW(), + resolved_at = CASE WHEN $2 = 'resolved' THEN NOW() ELSE resolved_at END, + closed_at = CASE WHEN $2 = 'closed' THEN NOW() ELSE closed_at END, + reopened_count = CASE WHEN $2 = 'reopened' THEN reopened_count + 1 ELSE reopened_count END + WHERE id = $1 + RETURNING id, complaint_code AS "complaintCode", status, updated_at AS "updatedAt" + `, + [currentComplaint.id, input.newStatus], + ); + + await db.query( + ` + INSERT INTO complaint_status_history ( + complaint_id, + old_status, + new_status, + changed_by, + change_reason + ) + VALUES ($1, $2, $3, $4, $5) + `, + [ + currentComplaint.id, + currentComplaint.status, + input.newStatus, + input.changedBy, + input.reason ?? null, + ], + ); + + await writeAuditLog({ + actorUserId: input.changedBy, + entityType: "complaint", + entityId: currentComplaint.id, + action: "complaint_status_updated", + details: { + newStatus: input.newStatus, + oldStatus: currentComplaint.status, + }, + }); + clearCache(); + + return updatedResult.rows[0]; +} + +export async function updateAssignmentStatus(input: UpdateAssignmentStatusInput) { + const client = await db.connect(); + + try { + await client.query("BEGIN"); + + const assignmentResult = await client.query( + ` + SELECT + a.id, + a.complaint_id AS "complaintId", + a.assignment_status AS "assignmentStatus", + a.assigned_to_user_id AS "assignedToUserId", + a.assigned_by_user_id AS "assignedByUserId", + c.status AS "complaintStatus", + c.citizen_id AS "citizenId", + c.complaint_code AS "complaintCode" + FROM assignments a + INNER JOIN complaints c ON c.id = a.complaint_id + WHERE a.id = $1 + LIMIT 1 + `, + [input.assignmentId], + ); + + const assignment = assignmentResult.rows[0]; + + if (!assignment) { + await client.query("ROLLBACK"); + return null; + } + + await client.query( + ` + UPDATE assignments + SET + assignment_status = $2, + accepted_at = CASE WHEN $2 = 'accepted' AND accepted_at IS NULL THEN NOW() ELSE accepted_at END, + completed_at = CASE WHEN $2 = 'completed' THEN NOW() ELSE completed_at END, + notes = COALESCE($3, notes) + WHERE id = $1 + `, + [input.assignmentId, input.newStatus, input.notes ?? null], + ); + + let complaintStatus = assignment.complaintStatus as string; + if (input.newStatus === "accepted") complaintStatus = "accepted"; + if (input.newStatus === "in_progress") complaintStatus = "in_progress"; + if (input.newStatus === "completed") complaintStatus = "resolved"; + + await client.query( + ` + UPDATE complaints + SET + status = $2, + updated_at = NOW(), + resolved_at = CASE WHEN $2 = 'resolved' THEN NOW() ELSE resolved_at END + WHERE id = $1 + `, + [assignment.complaintId, complaintStatus], + ); + + await client.query( + ` + INSERT INTO complaint_status_history ( + complaint_id, + old_status, + new_status, + changed_by, + change_reason + ) + VALUES ($1, $2, $3, $4, $5) + `, + [ + assignment.complaintId, + assignment.complaintStatus, + complaintStatus, + input.changedByUserId, + input.notes ?? + (input.newStatus === "completed" + ? "Field officer marked the assignment as completed" + : `Assignment updated to ${input.newStatus}`), + ], + ); + + await createNotification(client, { + userId: assignment.citizenId, + complaintId: assignment.complaintId, + title: + input.newStatus === "completed" + ? "Complaint resolved by field team" + : input.newStatus === "in_progress" + ? "Field work started" + : "Assignment accepted", + message: + input.newStatus === "completed" + ? `Complaint ${assignment.complaintCode} was marked resolved and is ready for your review.` + : input.newStatus === "in_progress" + ? `Field work has started for complaint ${assignment.complaintCode}.` + : `A field officer accepted complaint ${assignment.complaintCode}.`, + notificationType: + input.newStatus === "completed" + ? "complaint_resolved" + : input.newStatus === "in_progress" + ? "field_work_started" + : "assignment_accepted", + }); + + if (assignment.assignedByUserId) { + await createNotification(client, { + userId: assignment.assignedByUserId, + complaintId: assignment.complaintId, + title: + input.newStatus === "completed" + ? "Field team completed work" + : input.newStatus === "in_progress" + ? "Field team started work" + : input.newStatus === "reassigned" + ? "Assignment was reassigned" + : "Field team accepted assignment", + message: + input.newStatus === "completed" + ? `The field team completed complaint ${assignment.complaintCode}. Citizen verification can begin.` + : input.newStatus === "in_progress" + ? `A field officer started work on complaint ${assignment.complaintCode}.` + : input.newStatus === "reassigned" + ? `Complaint ${assignment.complaintCode} was flagged for reassignment.` + : `A field officer accepted complaint ${assignment.complaintCode}.`, + notificationType: + input.newStatus === "completed" + ? "field_work_completed" + : input.newStatus === "in_progress" + ? "field_work_started_internal" + : input.newStatus === "reassigned" + ? "assignment_reassigned" + : "assignment_accepted_internal", + }); + } + + await createAuditLog(client, { + actorUserId: input.changedByUserId, + entityType: "assignment", + entityId: assignment.id, + action: "assignment_status_updated", + details: { + complaintId: assignment.complaintId, + oldStatus: assignment.assignmentStatus, + newStatus: input.newStatus, + complaintStatus, + }, + }); + + await client.query("COMMIT"); + clearCache(); + + return { + id: assignment.id, + complaintId: assignment.complaintId, + assignmentStatus: input.newStatus, + complaintStatus, + }; + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } +} + +export async function verifyComplaintByCitizen(input: { + complaintId: string; + citizenId: string; + comment?: string; +}) { + const client = await db.connect(); + + try { + await client.query("BEGIN"); + + const complaintResult = await client.query( + ` + SELECT id, status, complaint_code AS "complaintCode" + FROM complaints + WHERE (id = $1 OR complaint_code = $1) AND citizen_id = $2 + LIMIT 1 + `, + [input.complaintId, input.citizenId], + ); + + const complaint = complaintResult.rows[0]; + + if (!complaint) { + await client.query("ROLLBACK"); + return null; + } + + await client.query( + ` + UPDATE complaints + SET + status = 'citizen_verified', + updated_at = NOW() + WHERE id = $1 + `, + [complaint.id], + ); + + await client.query( + ` + INSERT INTO complaint_status_history ( + complaint_id, + old_status, + new_status, + changed_by, + change_reason + ) + VALUES ($1, $2, 'citizen_verified', $3, $4) + `, + [ + complaint.id, + complaint.status, + input.citizenId, + input.comment?.trim() || "Citizen confirmed that the issue is resolved", + ], + ); + + const ownerResult = await client.query<{ assignedByUserId: string | null }>( + ` + SELECT assigned_by_user_id AS "assignedByUserId" + FROM assignments + WHERE complaint_id = $1 + ORDER BY assigned_at DESC + LIMIT 1 + `, + [complaint.id], + ); + + if (ownerResult.rows[0]?.assignedByUserId) { + await createNotification(client, { + userId: ownerResult.rows[0].assignedByUserId, + complaintId: complaint.id, + title: "Citizen verified resolution", + message: `Complaint ${complaint.complaintCode} was verified by the citizen.`, + notificationType: "citizen_verified", + }); + } + + await createAuditLog(client, { + actorUserId: input.citizenId, + entityType: "complaint", + entityId: complaint.id, + action: "complaint_verified_by_citizen", + details: { + oldStatus: complaint.status, + }, + }); + + await client.query("COMMIT"); + clearCache(); + + return { + complaintId: complaint.id, + status: "citizen_verified", + }; + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } +} + +export async function assignComplaint(input: AssignComplaintInput) { + const client = await db.connect(); + + try { + await client.query("BEGIN"); + + const complaintResult = await client.query( + ` + SELECT id, status, citizen_id AS "citizenId", complaint_code AS "complaintCode" + FROM complaints + WHERE id = $1 OR complaint_code = $1 + LIMIT 1 + `, + [input.complaintId], + ); + + const complaint = complaintResult.rows[0]; + + if (!complaint) { + await client.query("ROLLBACK"); + return null; + } + + const assignmentResult = await client.query( + ` + INSERT INTO assignments ( + complaint_id, + department_id, + assigned_to_user_id, + assigned_by_user_id, + assignment_status, + notes + ) + VALUES ($1, $2, $3, $4, 'assigned', $5) + RETURNING id, complaint_id AS "complaintId", department_id AS "departmentId", assigned_at AS "assignedAt" + `, + [ + complaint.id, + input.departmentId, + input.assignedToUserId ?? null, + input.assignedByUserId, + input.notes ?? null, + ], + ); + + await client.query( + ` + UPDATE complaints + SET + department_id = $2, + status = 'assigned', + updated_at = NOW() + WHERE id = $1 + `, + [complaint.id, input.departmentId], + ); + + await client.query( + ` + INSERT INTO complaint_status_history ( + complaint_id, + old_status, + new_status, + changed_by, + change_reason + ) + VALUES ($1, $2, 'assigned', $3, $4) + `, + [ + complaint.id, + complaint.status, + input.assignedByUserId, + input.notes ?? "Complaint assigned to department", + ], + ); + + await createNotification(client, { + userId: complaint.citizenId, + complaintId: complaint.id, + title: "Complaint assigned", + message: `Complaint ${complaint.complaintCode} has been assigned to a department team.`, + notificationType: "complaint_assigned", + }); + + if (input.assignedToUserId) { + await createNotification(client, { + userId: input.assignedToUserId, + complaintId: complaint.id, + title: "New field assignment", + message: `You were assigned complaint ${complaint.complaintCode}.`, + notificationType: "field_assignment", + }); + } + + await createAuditLog(client, { + actorUserId: input.assignedByUserId, + entityType: "assignment", + entityId: assignmentResult.rows[0]?.id ?? null, + action: "complaint_assigned", + details: { + complaintId: complaint.id, + departmentId: input.departmentId, + assignedToUserId: input.assignedToUserId ?? null, + }, + }); + + await client.query("COMMIT"); + clearCache(); + return assignmentResult.rows[0]; + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } +} diff --git a/civic-platform/backend/src/modules/complaints/complaint.routes.ts b/civic-platform/backend/src/modules/complaints/complaint.routes.ts new file mode 100644 index 0000000000000000000000000000000000000000..54deea81d237a1fe74c01b80d40bbba7bde4f493 --- /dev/null +++ b/civic-platform/backend/src/modules/complaints/complaint.routes.ts @@ -0,0 +1,466 @@ +import { randomUUID } from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { Router } from "express"; +import multer from "multer"; +import { + assignComplaintService, + createComplaintService, + createComplaintNoteService, + getDashboardSummaryService, + getAnalyticsSummaryService, + getMapComplaintsService, + getNearbyComplaintsService, + markAllNotificationsReadService, + markNotificationReadService, + getNotificationsService, + getOfficerAssignmentsService, + getPublicHotspotsService, + exportAnalyticsCsvService, + getComplaintAssignmentsService, + getComplaintDetailService, + getComplaintFeedbackService, + getComplaintMediaService, + getComplaintNotesService, + getComplaintStatusHistoryService, + listCitizenComplaintsService, + listComplaintsService, + submitComplaintFeedbackService, + updateAssignmentStatusService, + uploadComplaintMediaService, + updateComplaintStatusService, + verifyComplaintByCitizenService, +} from "./complaint.service.js"; + +export const complaintRouter = Router(); + +const uploadsRoot = path.resolve(process.cwd(), "uploads", "complaints"); +fs.mkdirSync(uploadsRoot, { recursive: true }); + +const upload = multer({ + storage: multer.diskStorage({ + destination: (_req, _file, callback) => { + callback(null, uploadsRoot); + }, + filename: (_req, file, callback) => { + const safeExtension = path.extname(file.originalname) || ""; + callback(null, `${Date.now()}-${randomUUID()}${safeExtension}`); + }, + }), + limits: { + fileSize: 25 * 1024 * 1024, + }, +}); + +function singleValue(value: string | string[] | undefined) { + return Array.isArray(value) ? value[0] : value; +} + +function normalizeMediaType(value: string | undefined) { + if (value === "image" || value === "video" || value === "audio") { + return value; + } + + return undefined; +} + +complaintRouter.get("/", async (_req, res) => { + try { + const complaints = await listComplaintsService(); + res.json({ data: complaints }); + } catch (error) { + res.status(500).json({ + error: error instanceof Error ? error.message : "Failed to fetch complaints", + }); + } +}); + +complaintRouter.get("/summary/dashboard", async (_req, res) => { + try { + const summary = await getDashboardSummaryService(); + res.json({ data: summary }); + } catch (error) { + res.status(500).json({ + error: error instanceof Error ? error.message : "Failed to fetch dashboard summary", + }); + } +}); + +complaintRouter.get("/summary/analytics", async (_req, res) => { + try { + const summary = await getAnalyticsSummaryService(); + res.json({ data: summary }); + } catch (error) { + res.status(500).json({ + error: error instanceof Error ? error.message : "Failed to fetch analytics summary", + }); + } +}); + +complaintRouter.get("/summary/export.csv", async (_req, res) => { + try { + const csv = await exportAnalyticsCsvService(); + res.setHeader("Content-Type", "text/csv; charset=utf-8"); + res.setHeader("Content-Disposition", "attachment; filename=\"civicpulse-analytics.csv\""); + res.send(csv); + } catch (error) { + res.status(500).json({ + error: error instanceof Error ? error.message : "Failed to export analytics", + }); + } +}); + +complaintRouter.get("/map", async (req, res) => { + try { + const complaints = await getMapComplaintsService({ + publicOnly: req.query.publicOnly === "true", + status: typeof req.query.status === "string" ? req.query.status : undefined, + domainId: typeof req.query.domainId === "string" ? req.query.domainId : undefined, + priorityLevel: typeof req.query.priorityLevel === "string" ? req.query.priorityLevel : undefined, + emergencyOnly: req.query.emergencyOnly === "true", + }); + + res.json({ data: complaints }); + } catch (error) { + res.status(400).json({ + error: error instanceof Error ? error.message : "Failed to fetch map complaints", + }); + } +}); + +complaintRouter.get("/public/hotspots", async (_req, res) => { + try { + const hotspots = await getPublicHotspotsService(); + res.json({ data: hotspots }); + } catch (error) { + res.status(500).json({ + error: error instanceof Error ? error.message : "Failed to fetch hotspots", + }); + } +}); + +complaintRouter.get("/nearby", async (req, res) => { + try { + const nearby = await getNearbyComplaintsService({ + latitude: typeof req.query.latitude === "string" ? Number(req.query.latitude) : undefined, + longitude: typeof req.query.longitude === "string" ? Number(req.query.longitude) : undefined, + radiusKm: typeof req.query.radiusKm === "string" ? Number(req.query.radiusKm) : undefined, + domainId: typeof req.query.domainId === "string" ? req.query.domainId : undefined, + publicOnly: req.query.publicOnly !== "false", + }); + + res.json({ data: nearby }); + } catch (error) { + res.status(400).json({ + error: error instanceof Error ? error.message : "Failed to fetch nearby complaints", + }); + } +}); + +complaintRouter.get("/citizen/:citizenId", async (req, res) => { + try { + const complaints = await listCitizenComplaintsService(req.params.citizenId); + res.json({ data: complaints }); + } catch (error) { + res.status(400).json({ + error: error instanceof Error ? error.message : "Failed to fetch citizen complaints", + }); + } +}); + +complaintRouter.get("/officer/:officerId/assignments", async (req, res) => { + try { + const assignments = await getOfficerAssignmentsService(req.params.officerId); + res.json({ data: assignments }); + } catch (error) { + res.status(400).json({ + error: error instanceof Error ? error.message : "Failed to fetch officer assignments", + }); + } +}); + +complaintRouter.get("/notifications/user/:userId", async (req, res) => { + try { + const notifications = await getNotificationsService(req.params.userId); + res.json({ data: notifications }); + } catch (error) { + res.status(400).json({ + error: error instanceof Error ? error.message : "Failed to fetch notifications", + }); + } +}); + +complaintRouter.patch("/notifications/:notificationId/read", async (req, res) => { + try { + const notification = await markNotificationReadService(req.params.notificationId, req.body.userId); + + if (!notification) { + res.status(404).json({ error: "Notification not found" }); + return; + } + + res.json({ data: notification }); + } catch (error) { + res.status(400).json({ + error: error instanceof Error ? error.message : "Failed to update notification", + }); + } +}); + +complaintRouter.post("/notifications/user/:userId/read-all", async (req, res) => { + try { + const result = await markAllNotificationsReadService(req.params.userId); + res.json({ data: result }); + } catch (error) { + res.status(400).json({ + error: error instanceof Error ? error.message : "Failed to update notifications", + }); + } +}); + +complaintRouter.get("/:id", async (req, res) => { + try { + const complaint = await getComplaintDetailService(req.params.id); + + if (!complaint) { + res.status(404).json({ error: "Complaint not found" }); + return; + } + + res.json({ data: complaint }); + } catch (error) { + res.status(500).json({ + error: error instanceof Error ? error.message : "Failed to fetch complaint detail", + }); + } +}); + +complaintRouter.get("/:id/history", async (req, res) => { + try { + const history = await getComplaintStatusHistoryService(req.params.id); + res.json({ data: history }); + } catch (error) { + res.status(400).json({ + error: error instanceof Error ? error.message : "Failed to fetch complaint history", + }); + } +}); + +complaintRouter.get("/:id/media", async (req, res) => { + try { + const media = await getComplaintMediaService(req.params.id); + res.json({ data: media }); + } catch (error) { + res.status(400).json({ + error: error instanceof Error ? error.message : "Failed to fetch complaint media", + }); + } +}); + +complaintRouter.get("/:id/feedback", async (req, res) => { + try { + const feedback = await getComplaintFeedbackService(req.params.id); + res.json({ data: feedback }); + } catch (error) { + res.status(400).json({ + error: error instanceof Error ? error.message : "Failed to fetch complaint feedback", + }); + } +}); + +complaintRouter.get("/:id/assignments", async (req, res) => { + try { + const assignments = await getComplaintAssignmentsService(req.params.id); + res.json({ data: assignments }); + } catch (error) { + res.status(400).json({ + error: error instanceof Error ? error.message : "Failed to fetch complaint assignments", + }); + } +}); + +complaintRouter.get("/:id/notes", async (req, res) => { + try { + const notes = await getComplaintNotesService(req.params.id); + res.json({ data: notes }); + } catch (error) { + res.status(400).json({ + error: error instanceof Error ? error.message : "Failed to fetch complaint notes", + }); + } +}); + +complaintRouter.post("/", async (req, res) => { + try { + const complaint = await createComplaintService(req.body); + res.status(201).json({ data: complaint }); + } catch (error) { + res.status(400).json({ + error: error instanceof Error ? error.message : "Failed to create complaint", + }); + } +}); + +complaintRouter.post("/:id/notes", async (req, res) => { + try { + const note = await createComplaintNoteService({ + complaintId: req.params.id, + authorId: req.body.authorId, + noteType: req.body.noteType, + noteText: req.body.noteText, + isInternal: req.body.isInternal, + }); + + if (!note) { + res.status(404).json({ error: "Complaint not found" }); + return; + } + + res.status(201).json({ data: note }); + } catch (error) { + res.status(400).json({ + error: error instanceof Error ? error.message : "Failed to create complaint note", + }); + } +}); + +complaintRouter.patch("/:id/status", async (req, res) => { + try { + const complaint = await updateComplaintStatusService({ + complaintId: req.params.id, + newStatus: req.body.newStatus, + changedBy: req.body.changedBy, + reason: req.body.reason, + }); + + if (!complaint) { + res.status(404).json({ error: "Complaint not found" }); + return; + } + + res.json({ data: complaint }); + } catch (error) { + res.status(400).json({ + error: error instanceof Error ? error.message : "Failed to update complaint status", + }); + } +}); + +complaintRouter.post("/:id/assign", async (req, res) => { + try { + const assignment = await assignComplaintService({ + complaintId: req.params.id, + departmentId: req.body.departmentId, + assignedToUserId: req.body.assignedToUserId, + assignedByUserId: req.body.assignedByUserId, + notes: req.body.notes, + }); + + if (!assignment) { + res.status(404).json({ error: "Complaint not found" }); + return; + } + + res.status(201).json({ data: assignment }); + } catch (error) { + res.status(400).json({ + error: error instanceof Error ? error.message : "Failed to assign complaint", + }); + } +}); + +complaintRouter.patch("/assignments/:assignmentId/status", async (req, res) => { + try { + const assignment = await updateAssignmentStatusService({ + assignmentId: req.params.assignmentId, + newStatus: req.body.newStatus, + changedByUserId: req.body.changedByUserId, + notes: req.body.notes, + }); + + if (!assignment) { + res.status(404).json({ error: "Assignment not found" }); + return; + } + + res.json({ data: assignment }); + } catch (error) { + res.status(400).json({ + error: error instanceof Error ? error.message : "Failed to update assignment status", + }); + } +}); + +complaintRouter.post("/:id/media", upload.single("file"), async (req, res) => { + try { + if (!req.file) { + res.status(400).json({ error: "File is required" }); + return; + } + + const media = await uploadComplaintMediaService({ + complaintId: String(req.params.id), + uploadedBy: singleValue(req.body.uploadedBy), + mediaType: normalizeMediaType(singleValue(req.body.mediaType)), + filePath: req.file.path, + fileUrl: `${req.protocol}://${req.get("host")}/uploads/complaints/${req.file.filename}`, + mimeType: req.file.mimetype, + isResolutionProof: singleValue(req.body.isResolutionProof) === "true", + }); + + if (!media) { + res.status(404).json({ error: "Complaint not found" }); + return; + } + + res.status(201).json({ data: media }); + } catch (error) { + res.status(400).json({ + error: error instanceof Error ? error.message : "Failed to upload complaint media", + }); + } +}); + +complaintRouter.post("/:id/feedback", async (req, res) => { + try { + const feedback = await submitComplaintFeedbackService({ + complaintId: req.params.id, + citizenId: req.body.citizenId, + rating: req.body.rating, + comment: req.body.comment, + reopenRequested: req.body.reopenRequested, + }); + + if (!feedback) { + res.status(404).json({ error: "Complaint not found" }); + return; + } + + res.status(201).json({ data: feedback }); + } catch (error) { + res.status(400).json({ + error: error instanceof Error ? error.message : "Failed to submit complaint feedback", + }); + } +}); + +complaintRouter.post("/:id/verify", async (req, res) => { + try { + const verification = await verifyComplaintByCitizenService({ + complaintId: req.params.id, + citizenId: req.body.citizenId, + comment: req.body.comment, + }); + + if (!verification) { + res.status(404).json({ error: "Complaint not found" }); + return; + } + + res.status(201).json({ data: verification }); + } catch (error) { + res.status(400).json({ + error: error instanceof Error ? error.message : "Failed to verify complaint", + }); + } +}); diff --git a/civic-platform/backend/src/modules/complaints/complaint.service.ts b/civic-platform/backend/src/modules/complaints/complaint.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..6402f37bc91f5f142f8b5a7896679c0ed0771360 --- /dev/null +++ b/civic-platform/backend/src/modules/complaints/complaint.service.ts @@ -0,0 +1,590 @@ +import type { + AnalyticsSummary, + AssignComplaintInput, + ComplaintHotspotItem, + ComplaintMapItem, + ComplaintNotificationItem, + CreateComplaintNoteInput, + CreateComplaintInput, + NearbyComplaintItem, + SubmitComplaintFeedbackInput, + UpdateAssignmentStatusInput, + UpdateComplaintStatusInput, + UploadComplaintMediaInput, +} from "./complaint.types.js"; +import { + assignComplaint, + complaintHasResolutionProof, + createComplaintNote, + createComplaint, + exportAnalyticsCsv, + getAssignmentById, + getAnalyticsSummary, + getDashboardSummary, + listAssignmentsByOfficer, + listMapComplaints, + listNearbyComplaints, + listNotificationsByUser, + listComplaintAssignments, + getComplaintById, + listComplaintFeedback, + listPublicHotspots, + listComplaintMedia, + listComplaintNotes, + getComplaintStatusHistory, + listComplaints, + listComplaintsByCitizen, + markAllNotificationsAsRead, + markNotificationAsRead, + submitComplaintFeedback, + hasOfficerAssignment, + updateAssignmentStatus, + uploadComplaintMedia, + updateComplaintStatus, + verifyComplaintByCitizen, +} from "./complaint.repository.js"; +import { getUserContext } from "../users/user.repository.js"; +import { sanitizeText } from "../../lib/security.js"; +import { withCache } from "../../lib/cache.js"; + +function validateCreateComplaintInput(input: Partial) { + const missing: string[] = []; + + if (!input.citizenId) missing.push("citizenId"); + if (!input.title) missing.push("title"); + if (typeof input.latitude !== "number") missing.push("latitude"); + if (typeof input.longitude !== "number") missing.push("longitude"); + + if (missing.length > 0) { + throw new Error(`Missing required complaint fields: ${missing.join(", ")}`); + } +} + +function isOperatorRole(role: string) { + return role === "department_operator" || role === "municipal_admin"; +} + +async function requireUserContext(userId: string) { + const user = await getUserContext(userId); + + if (!user) { + throw new Error("User not found"); + } + + return user; +} + +export async function createComplaintService(input: Partial) { + validateCreateComplaintInput(input); + + const citizen = await requireUserContext(input.citizenId as string); + + if (citizen.role !== "citizen") { + throw new Error("Only citizens can create complaints"); + } + + return createComplaint({ + ...(input as CreateComplaintInput), + title: sanitizeText(input.title, 220) ?? "", + description: sanitizeText(input.description, 2000), + addressLine: sanitizeText(input.addressLine, 220), + landmark: sanitizeText(input.landmark, 220), + cityName: sanitizeText(input.cityName, 120), + stateName: sanitizeText(input.stateName, 120), + postalCode: sanitizeText(input.postalCode, 20), + }); +} + +export async function listComplaintsService() { + return listComplaints(); +} + +export async function getDashboardSummaryService() { + return withCache("analytics:dashboard", 15000, () => getDashboardSummary()); +} + +export async function getMapComplaintsService(input: { + publicOnly?: boolean; + status?: string; + domainId?: string; + priorityLevel?: string; + emergencyOnly?: boolean; +}): Promise { + return listMapComplaints(input); +} + +export async function getPublicHotspotsService(): Promise { + return withCache("analytics:hotspots", 15000, () => listPublicHotspots()); +} + +export async function getAnalyticsSummaryService(): Promise { + return withCache("analytics:summary", 20000, () => getAnalyticsSummary()); +} + +export async function exportAnalyticsCsvService() { + return withCache("analytics:csv", 20000, () => exportAnalyticsCsv()); +} + +export async function getNearbyComplaintsService(input: { + latitude?: number; + longitude?: number; + radiusKm?: number; + domainId?: string; + publicOnly?: boolean; +}): Promise { + if (typeof input.latitude !== "number" || Number.isNaN(input.latitude)) { + throw new Error("latitude is required"); + } + + if (typeof input.longitude !== "number" || Number.isNaN(input.longitude)) { + throw new Error("longitude is required"); + } + + return listNearbyComplaints({ + latitude: input.latitude, + longitude: input.longitude, + radiusKm: input.radiusKm ?? 0.75, + domainId: input.domainId, + publicOnly: input.publicOnly ?? true, + }); +} + +export async function listCitizenComplaintsService(citizenId: string) { + if (!citizenId) { + throw new Error("citizenId is required"); + } + + return listComplaintsByCitizen(citizenId); +} + +export async function getComplaintDetailService(id: string) { + if (!id) { + throw new Error("Complaint id is required"); + } + + return getComplaintById(id); +} + +export async function getComplaintStatusHistoryService(id: string) { + if (!id) { + throw new Error("Complaint id is required"); + } + + return getComplaintStatusHistory(id); +} + +export async function getComplaintMediaService(id: string) { + if (!id) { + throw new Error("Complaint id is required"); + } + + return listComplaintMedia(id); +} + +export async function getComplaintFeedbackService(id: string) { + if (!id) { + throw new Error("Complaint id is required"); + } + + return listComplaintFeedback(id); +} + +export async function getComplaintAssignmentsService(id: string) { + if (!id) { + throw new Error("Complaint id is required"); + } + + return listComplaintAssignments(id); +} + +export async function getOfficerAssignmentsService(userId: string) { + if (!userId) { + throw new Error("userId is required"); + } + + return listAssignmentsByOfficer(userId); +} + +export async function getNotificationsService(userId: string): Promise { + if (!userId) { + throw new Error("userId is required"); + } + + return listNotificationsByUser(userId); +} + +export async function markNotificationReadService(notificationId: string, userId: string) { + if (!notificationId) { + throw new Error("notificationId is required"); + } + + if (!userId) { + throw new Error("userId is required"); + } + + return markNotificationAsRead(notificationId, userId); +} + +export async function markAllNotificationsReadService(userId: string) { + if (!userId) { + throw new Error("userId is required"); + } + + return markAllNotificationsAsRead(userId); +} + +export async function getComplaintNotesService(id: string) { + if (!id) { + throw new Error("Complaint id is required"); + } + + return listComplaintNotes(id); +} + +const allowedStatuses = new Set([ + "submitted", + "validated", + "classified", + "prioritized", + "assigned", + "accepted", + "in_progress", + "resolved", + "citizen_verified", + "closed", + "escalated", + "reopened", + "duplicate", + "rejected", + "on_hold", +]); + +const allowedTransitions: Record = { + submitted: ["validated", "escalated", "rejected"], + validated: ["classified", "prioritized", "assigned", "escalated"], + classified: ["prioritized", "assigned"], + prioritized: ["assigned", "escalated", "on_hold"], + assigned: ["accepted", "in_progress", "escalated", "on_hold"], + accepted: ["in_progress", "escalated", "on_hold"], + in_progress: ["resolved", "escalated", "on_hold"], + resolved: ["citizen_verified", "closed", "reopened"], + citizen_verified: ["closed"], + reopened: ["assigned", "in_progress", "escalated"], + escalated: ["assigned", "in_progress", "resolved"], + on_hold: ["assigned", "in_progress", "escalated"], + rejected: ["reopened"], + duplicate: ["reopened"], + closed: ["reopened"], +}; + +export async function updateComplaintStatusService(input: Partial) { + if (!input.complaintId) { + throw new Error("complaintId is required"); + } + + if (!input.changedBy) { + throw new Error("changedBy is required"); + } + + if (!input.newStatus || !allowedStatuses.has(input.newStatus)) { + throw new Error("newStatus is invalid"); + } + + const actor = await requireUserContext(input.changedBy); + + if (!isOperatorRole(actor.role)) { + throw new Error("Only operators and municipal admins can update complaint status"); + } + + const complaint = await getComplaintById(input.complaintId); + + if (!complaint) { + return null; + } + + const nextStatuses = allowedTransitions[complaint.status] ?? []; + + if (!nextStatuses.includes(input.newStatus)) { + throw new Error(`Cannot move complaint from ${complaint.status} to ${input.newStatus}`); + } + + return updateComplaintStatus(input as UpdateComplaintStatusInput); +} + +export async function assignComplaintService(input: Partial) { + if (!input.complaintId) { + throw new Error("complaintId is required"); + } + + if (!input.departmentId) { + throw new Error("departmentId is required"); + } + + if (!input.assignedByUserId) { + throw new Error("assignedByUserId is required"); + } + + const actor = await requireUserContext(input.assignedByUserId); + + if (!isOperatorRole(actor.role)) { + throw new Error("Only operators and municipal admins can assign complaints"); + } + + if (actor.role === "department_operator" && actor.departmentId !== input.departmentId) { + throw new Error("Department operators can only assign complaints within their own department"); + } + + if (input.assignedToUserId) { + const assignee = await requireUserContext(input.assignedToUserId); + + if (assignee.role !== "field_officer") { + throw new Error("Assigned user must be a field officer"); + } + + if (assignee.departmentId !== input.departmentId) { + throw new Error("Field officer must belong to the selected department"); + } + } + + return assignComplaint(input as AssignComplaintInput); +} + +const allowedAssignmentStatuses = new Set(["accepted", "in_progress", "completed", "reassigned"]); + +export async function updateAssignmentStatusService(input: Partial) { + if (!input.assignmentId) { + throw new Error("assignmentId is required"); + } + + if (!input.changedByUserId) { + throw new Error("changedByUserId is required"); + } + + if (!input.newStatus || !allowedAssignmentStatuses.has(input.newStatus)) { + throw new Error("newStatus is invalid"); + } + + const actor = await requireUserContext(input.changedByUserId); + const assignment = await getAssignmentById(input.assignmentId); + + if (!assignment) { + return null; + } + + if (actor.role === "field_officer") { + if (assignment.assignedToUserId !== actor.id) { + throw new Error("Field officers can only update assignments assigned to them"); + } + + if (input.newStatus === "reassigned") { + throw new Error("Field officers cannot reassign tasks"); + } + + if (input.newStatus === "completed") { + const hasProof = await complaintHasResolutionProof(assignment.complaintId); + + if (!hasProof) { + throw new Error("Upload at least one resolution proof before marking the task as completed"); + } + } + } else if (isOperatorRole(actor.role)) { + if (actor.role === "department_operator" && actor.departmentId !== assignment.departmentId) { + throw new Error("Department operators can only manage assignments within their department"); + } + + if (input.newStatus !== "reassigned") { + throw new Error("Operators can only use this action to reassign work"); + } + } else { + throw new Error("Only field officers, operators, and municipal admins can update assignments"); + } + + return updateAssignmentStatus(input as UpdateAssignmentStatusInput); +} + +const allowedMediaTypes = new Set(["image", "video", "audio"]); + +export async function uploadComplaintMediaService(input: Partial) { + if (!input.complaintId) { + throw new Error("complaintId is required"); + } + + if (!input.filePath) { + throw new Error("filePath is required"); + } + + if (!input.fileUrl) { + throw new Error("fileUrl is required"); + } + + if (!input.mediaType || !allowedMediaTypes.has(input.mediaType)) { + throw new Error("mediaType is invalid"); + } + + if (input.mimeType) { + const mimeAllowed = + (input.mediaType === "image" && input.mimeType.startsWith("image/")) || + (input.mediaType === "video" && input.mimeType.startsWith("video/")) || + (input.mediaType === "audio" && input.mimeType.startsWith("audio/")); + + if (!mimeAllowed) { + throw new Error("Uploaded file type does not match the selected media type"); + } + } + + if (input.uploadedBy) { + const actor = await requireUserContext(input.uploadedBy); + + if (input.isResolutionProof) { + if (actor.role === "citizen") { + throw new Error("Citizens cannot upload resolution proof"); + } + + if (actor.role === "field_officer") { + const hasAssignment = await hasOfficerAssignment(input.complaintId, actor.id); + + if (!hasAssignment) { + throw new Error("Field officers can only upload proof for complaints assigned to them"); + } + } + } + } + + return uploadComplaintMedia(input as UploadComplaintMediaInput); +} + +export async function submitComplaintFeedbackService(input: Partial) { + if (!input.complaintId) { + throw new Error("complaintId is required"); + } + + if (!input.citizenId) { + throw new Error("citizenId is required"); + } + + if (input.rating !== undefined && (input.rating < 1 || input.rating > 5)) { + throw new Error("rating must be between 1 and 5"); + } + + const citizen = await requireUserContext(input.citizenId); + + if (citizen.role !== "citizen") { + throw new Error("Only citizens can submit complaint feedback"); + } + + const complaint = await getComplaintById(input.complaintId); + + if (!complaint || complaint.citizenId !== input.citizenId) { + throw new Error("Complaint does not belong to this citizen"); + } + + if (input.reopenRequested && !["resolved", "closed", "citizen_verified"].includes(complaint.status)) { + throw new Error("Complaints can only be reopened after they have been resolved or closed"); + } + + return submitComplaintFeedback({ + ...(input as SubmitComplaintFeedbackInput), + comment: sanitizeText(input.comment, 1200), + }); +} + +export async function verifyComplaintByCitizenService(input: { + complaintId?: string; + citizenId?: string; + comment?: string; +}) { + if (!input.complaintId) { + throw new Error("complaintId is required"); + } + + if (!input.citizenId) { + throw new Error("citizenId is required"); + } + + const citizen = await requireUserContext(input.citizenId); + + if (citizen.role !== "citizen") { + throw new Error("Only citizens can verify resolved complaints"); + } + + const complaint = await getComplaintById(input.complaintId); + + if (!complaint || complaint.citizenId !== input.citizenId) { + throw new Error("Complaint does not belong to this citizen"); + } + + if (complaint.status !== "resolved") { + throw new Error("Only resolved complaints can be verified by the citizen"); + } + + return verifyComplaintByCitizen({ + complaintId: input.complaintId, + citizenId: input.citizenId, + comment: sanitizeText(input.comment, 1200), + }); +} + +const allowedNoteTypes = new Set(["operator_note", "field_note", "citizen_note", "system_note"]); + +export async function createComplaintNoteService(input: Partial) { + if (!input.complaintId) { + throw new Error("complaintId is required"); + } + + if (!input.authorId) { + throw new Error("authorId is required"); + } + + if (!input.noteText?.trim()) { + throw new Error("noteText is required"); + } + + if (!input.noteType || !allowedNoteTypes.has(input.noteType)) { + throw new Error("noteType is invalid"); + } + + const author = await requireUserContext(input.authorId); + const complaint = await getComplaintById(input.complaintId); + + if (!complaint) { + return null; + } + + if (input.noteType === "system_note") { + throw new Error("System notes cannot be created manually"); + } + + if (input.noteType === "citizen_note") { + if (author.role !== "citizen" || complaint.citizenId !== author.id) { + throw new Error("Only the reporting citizen can add a citizen note"); + } + } + + if (input.noteType === "operator_note" && !isOperatorRole(author.role)) { + throw new Error("Only operators and municipal admins can add operator notes"); + } + + if (input.noteType === "field_note") { + if (author.role !== "field_officer") { + throw new Error("Only field officers can add field notes"); + } + + const hasAssignment = await hasOfficerAssignment(input.complaintId, author.id); + + if (!hasAssignment) { + throw new Error("Field notes can only be added to complaints assigned to the field officer"); + } + } + + if ((input.isInternal ?? true) && author.role === "citizen") { + throw new Error("Citizens cannot create internal notes"); + } + + return createComplaintNote({ + complaintId: input.complaintId, + authorId: input.authorId, + noteText: sanitizeText(input.noteText, 2000) ?? "", + noteType: input.noteType as CreateComplaintNoteInput["noteType"], + isInternal: input.isInternal ?? true, + }); +} diff --git a/civic-platform/backend/src/modules/complaints/complaint.types.ts b/civic-platform/backend/src/modules/complaints/complaint.types.ts new file mode 100644 index 0000000000000000000000000000000000000000..60f9e5b22ba967b1fe587b295bb832fdb9317976 --- /dev/null +++ b/civic-platform/backend/src/modules/complaints/complaint.types.ts @@ -0,0 +1,245 @@ +export type CreateComplaintInput = { + citizenId: string; + title: string; + description?: string; + domainId?: string; + subProblemId?: string; + isEmergency?: boolean; + wardId?: string; + latitude: number; + longitude: number; + addressLine?: string; + landmark?: string; + cityName?: string; + stateName?: string; + postalCode?: string; +}; + +export type ComplaintListItem = { + id: string; + complaintCode: string; + title: string; + status: string; + priorityLevel: string; + isEmergency: boolean; + submittedAt: string; + domainName: string | null; + departmentName: string | null; + addressLine: string | null; + landmark: string | null; +}; + +export type ComplaintDetail = ComplaintListItem & { + description: string | null; + departmentId: string | null; + wardId: string | null; + citizenId: string; + latitude: string | null; + longitude: string | null; + addressLine: string | null; + landmark: string | null; +}; + +export type ComplaintStatusHistoryItem = { + id: string; + complaintId: string; + oldStatus: string | null; + newStatus: string; + changedBy: string | null; + changeReason: string | null; + createdAt: string; +}; + +export type UpdateComplaintStatusInput = { + complaintId: string; + newStatus: string; + changedBy: string; + reason?: string; +}; + +export type AssignComplaintInput = { + complaintId: string; + departmentId: string; + assignedToUserId?: string; + assignedByUserId: string; + notes?: string; +}; + +export type ComplaintAssignmentItem = { + id: string; + complaintId: string; + complaintCode: string | null; + complaintTitle: string | null; + departmentId: string; + departmentName: string; + assignedToUserId: string | null; + assignedToName: string | null; + assignedByUserId: string | null; + assignedByName: string | null; + assignmentStatus: string; + assignedAt: string; + acceptedAt: string | null; + completedAt: string | null; + resolutionProofCount: number; + notes: string | null; +}; + +export type UpdateAssignmentStatusInput = { + assignmentId: string; + newStatus: "accepted" | "in_progress" | "completed" | "reassigned"; + changedByUserId: string; + notes?: string; +}; + +export type UploadComplaintMediaInput = { + complaintId: string; + uploadedBy?: string; + mediaType: "image" | "video" | "audio"; + filePath: string; + fileUrl: string; + mimeType?: string; + isResolutionProof?: boolean; +}; + +export type ComplaintMediaItem = { + id: string; + complaintId: string; + mediaType: "image" | "video" | "audio"; + fileUrl: string | null; + mimeType: string | null; + isResolutionProof: boolean; + createdAt: string; +}; + +export type ComplaintMapItem = { + id: string; + complaintCode: string; + title: string; + status: string; + priorityLevel: string; + isEmergency: boolean; + domainName: string | null; + departmentName: string | null; + wardName: string | null; + latitude: string; + longitude: string; + addressLine: string | null; + landmark: string | null; + submittedAt: string; +}; + +export type ComplaintHotspotItem = { + hotspotKey: string; + latitude: number; + longitude: number; + complaintCount: number; + criticalCount: number; + emergencyCount: number; + topDomain: string | null; +}; + +export type NearbyComplaintItem = { + id: string; + complaintCode: string; + title: string; + status: string; + priorityLevel: string; + domainName: string | null; + distanceKm: number; + addressLine: string | null; + landmark: string | null; +}; + +export type SubmitComplaintFeedbackInput = { + complaintId: string; + citizenId: string; + rating?: number; + comment?: string; + reopenRequested?: boolean; +}; + +export type ComplaintFeedbackItem = { + id: string; + complaintId: string; + citizenId: string; + rating: number | null; + comment: string | null; + reopenRequested: boolean; + createdAt: string; +}; + +export type ComplaintNotificationItem = { + id: string; + userId: string; + complaintId: string | null; + title: string; + message: string; + notificationType: string; + isRead: boolean; + createdAt: string; +}; + +export type DashboardSummary = { + overview: { + openComplaints: number; + criticalComplaints: number; + slaRisk: number; + resolvedToday: number; + }; + urgentQueue: Array<{ + id: string; + complaintCode: string; + title: string; + domainName: string | null; + priorityLevel: string; + location: string | null; + submittedAt: string; + }>; + departmentWorkload: Array<{ + departmentName: string; + pendingCount: number; + }>; +}; + +export type AnalyticsSummary = { + trends: Array<{ + day: string; + reportedCount: number; + resolvedCount: number; + }>; + domainBreakdown: Array<{ + domainName: string; + complaintCount: number; + }>; + departmentPerformance: Array<{ + departmentName: string; + resolvedCount: number; + pendingCount: number; + averageResolutionHours: number; + }>; + kpis: { + emergencyCount: number; + reopenedCount: number; + repeatComplaintRiskCount: number; + slaBreaches: number; + }; +}; + +export type ComplaintNoteItem = { + id: string; + complaintId: string; + authorId: string | null; + noteType: "operator_note" | "field_note" | "citizen_note" | "system_note"; + noteText: string; + isInternal: boolean; + createdAt: string; + authorName: string | null; +}; + +export type CreateComplaintNoteInput = { + complaintId: string; + authorId: string; + noteType: "operator_note" | "field_note" | "citizen_note" | "system_note"; + noteText: string; + isInternal?: boolean; +}; diff --git a/civic-platform/backend/src/modules/departments/.gitkeep b/civic-platform/backend/src/modules/departments/.gitkeep new file mode 100644 index 0000000000000000000000000000000000000000..8b137891791fe96927ad78e64b0aad7bded08bdc --- /dev/null +++ b/civic-platform/backend/src/modules/departments/.gitkeep @@ -0,0 +1 @@ + diff --git a/civic-platform/backend/src/modules/departments/department.repository.ts b/civic-platform/backend/src/modules/departments/department.repository.ts new file mode 100644 index 0000000000000000000000000000000000000000..d978b6daad648b7f1c301a0028afb612a9ca654e --- /dev/null +++ b/civic-platform/backend/src/modules/departments/department.repository.ts @@ -0,0 +1,20 @@ +import { db } from "../../db/pool.js"; + +export async function listDepartments() { + const result = await db.query( + ` + SELECT + id, + name, + code, + description, + is_emergency AS "isEmergency", + is_active AS "isActive" + FROM departments + WHERE is_active = TRUE + ORDER BY is_emergency DESC, name ASC + `, + ); + + return result.rows; +} diff --git a/civic-platform/backend/src/modules/departments/department.routes.ts b/civic-platform/backend/src/modules/departments/department.routes.ts new file mode 100644 index 0000000000000000000000000000000000000000..e8e5fa1a4a4ffdbc09cb663eb846efc49b7cd4ca --- /dev/null +++ b/civic-platform/backend/src/modules/departments/department.routes.ts @@ -0,0 +1,15 @@ +import { Router } from "express"; +import { listDepartmentsService } from "./department.service.js"; + +export const departmentRouter = Router(); + +departmentRouter.get("/", async (_req, res) => { + try { + const departments = await listDepartmentsService(); + res.json({ data: departments }); + } catch (error) { + res.status(500).json({ + error: error instanceof Error ? error.message : "Failed to fetch departments", + }); + } +}); diff --git a/civic-platform/backend/src/modules/departments/department.service.ts b/civic-platform/backend/src/modules/departments/department.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..487c3090f07055c02a81538065cb5f61cf9c000d --- /dev/null +++ b/civic-platform/backend/src/modules/departments/department.service.ts @@ -0,0 +1,5 @@ +import { listDepartments } from "./department.repository.js"; + +export async function listDepartmentsService() { + return listDepartments(); +} diff --git a/civic-platform/backend/src/modules/domains/domain.repository.ts b/civic-platform/backend/src/modules/domains/domain.repository.ts new file mode 100644 index 0000000000000000000000000000000000000000..1efea4ae8acfbb0a70ae05e3e79da68130a0a357 --- /dev/null +++ b/civic-platform/backend/src/modules/domains/domain.repository.ts @@ -0,0 +1,19 @@ +import { db } from "../../db/pool.js"; + +export async function listDomains() { + const result = await db.query( + ` + SELECT + id, + name, + description, + is_emergency AS "isEmergency", + is_active AS "isActive" + FROM domains + WHERE is_active = TRUE + ORDER BY is_emergency ASC, name ASC + `, + ); + + return result.rows; +} diff --git a/civic-platform/backend/src/modules/domains/domain.routes.ts b/civic-platform/backend/src/modules/domains/domain.routes.ts new file mode 100644 index 0000000000000000000000000000000000000000..322ad21fd7062b14e06da7a860a07e2bcff3f8af --- /dev/null +++ b/civic-platform/backend/src/modules/domains/domain.routes.ts @@ -0,0 +1,15 @@ +import { Router } from "express"; +import { listDomainsService } from "./domain.service.js"; + +export const domainRouter = Router(); + +domainRouter.get("/", async (_req, res) => { + try { + const domains = await listDomainsService(); + res.json({ data: domains }); + } catch (error) { + res.status(500).json({ + error: error instanceof Error ? error.message : "Failed to fetch domains", + }); + } +}); diff --git a/civic-platform/backend/src/modules/domains/domain.service.ts b/civic-platform/backend/src/modules/domains/domain.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..b6cc12ddac141929d3f0bba43a326439b1158c86 --- /dev/null +++ b/civic-platform/backend/src/modules/domains/domain.service.ts @@ -0,0 +1,5 @@ +import { listDomains } from "./domain.repository.js"; + +export async function listDomainsService() { + return listDomains(); +} diff --git a/civic-platform/backend/src/modules/notifications/.gitkeep b/civic-platform/backend/src/modules/notifications/.gitkeep new file mode 100644 index 0000000000000000000000000000000000000000..8b137891791fe96927ad78e64b0aad7bded08bdc --- /dev/null +++ b/civic-platform/backend/src/modules/notifications/.gitkeep @@ -0,0 +1 @@ + diff --git a/civic-platform/backend/src/modules/routing/.gitkeep b/civic-platform/backend/src/modules/routing/.gitkeep new file mode 100644 index 0000000000000000000000000000000000000000..8b137891791fe96927ad78e64b0aad7bded08bdc --- /dev/null +++ b/civic-platform/backend/src/modules/routing/.gitkeep @@ -0,0 +1 @@ + diff --git a/civic-platform/backend/src/modules/users/.gitkeep b/civic-platform/backend/src/modules/users/.gitkeep new file mode 100644 index 0000000000000000000000000000000000000000..8b137891791fe96927ad78e64b0aad7bded08bdc --- /dev/null +++ b/civic-platform/backend/src/modules/users/.gitkeep @@ -0,0 +1 @@ + diff --git a/civic-platform/backend/src/modules/users/user.repository.ts b/civic-platform/backend/src/modules/users/user.repository.ts new file mode 100644 index 0000000000000000000000000000000000000000..69747abc2262e8821b1fd122982a2b94ad5cf292 --- /dev/null +++ b/civic-platform/backend/src/modules/users/user.repository.ts @@ -0,0 +1,52 @@ +import { db } from "../../db/pool.js"; +import type { UserContext } from "./user.types.js"; + +export async function getUserContext(userId: string): Promise { + const result = await db.query( + ` + SELECT + u.id, + u.full_name AS "fullName", + u.email, + r.name AS role, + u.department_id AS "departmentId", + d.name AS "departmentName", + u.ward_id AS "wardId" + FROM users u + INNER JOIN roles r ON r.id = u.role_id + LEFT JOIN departments d ON d.id = u.department_id + WHERE u.id = $1 + AND u.is_active = TRUE + LIMIT 1 + `, + [userId], + ); + + return result.rows[0] ?? null; +} + +export async function listFieldOfficers(departmentId?: string): Promise { + const result = await db.query( + ` + SELECT + u.id, + u.full_name AS "fullName", + u.email, + r.name AS role, + u.department_id AS "departmentId", + d.name AS "departmentName", + u.ward_id AS "wardId" + FROM users u + INNER JOIN roles r ON r.id = u.role_id + LEFT JOIN departments d ON d.id = u.department_id + WHERE r.name = 'field_officer' + AND u.is_active = TRUE + AND ($1::uuid IS NULL OR u.department_id = $1::uuid) + ORDER BY u.full_name ASC + `, + [departmentId ?? null], + ); + + return result.rows; +} + diff --git a/civic-platform/backend/src/modules/users/user.routes.ts b/civic-platform/backend/src/modules/users/user.routes.ts new file mode 100644 index 0000000000000000000000000000000000000000..c1faea0a41d64918bfacf76f8428b04031535bdc --- /dev/null +++ b/civic-platform/backend/src/modules/users/user.routes.ts @@ -0,0 +1,35 @@ +import { Router } from "express"; +import { getUserContextService, listFieldOfficersService } from "./user.service.js"; + +export const userRouter = Router(); + +userRouter.get("/field-officers", async (req, res) => { + try { + const officers = await listFieldOfficersService( + typeof req.query.departmentId === "string" ? req.query.departmentId : undefined, + ); + + res.json({ data: officers }); + } catch (error) { + res.status(400).json({ + error: error instanceof Error ? error.message : "Failed to fetch field officers", + }); + } +}); + +userRouter.get("/:id", async (req, res) => { + try { + const user = await getUserContextService(req.params.id); + + if (!user) { + res.status(404).json({ error: "User not found" }); + return; + } + + res.json({ data: user }); + } catch (error) { + res.status(400).json({ + error: error instanceof Error ? error.message : "Failed to fetch user", + }); + } +}); diff --git a/civic-platform/backend/src/modules/users/user.service.ts b/civic-platform/backend/src/modules/users/user.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..76dfb4f1c50d689eb3bc3dd1f9db251253b72f30 --- /dev/null +++ b/civic-platform/backend/src/modules/users/user.service.ts @@ -0,0 +1,14 @@ +import { getUserContext, listFieldOfficers } from "./user.repository.js"; + +export async function getUserContextService(userId: string) { + if (!userId) { + throw new Error("userId is required"); + } + + return getUserContext(userId); +} + +export async function listFieldOfficersService(departmentId?: string) { + return listFieldOfficers(departmentId); +} + diff --git a/civic-platform/backend/src/modules/users/user.types.ts b/civic-platform/backend/src/modules/users/user.types.ts new file mode 100644 index 0000000000000000000000000000000000000000..78919fb1013a7c18235a9229f9cf29027450128e --- /dev/null +++ b/civic-platform/backend/src/modules/users/user.types.ts @@ -0,0 +1,10 @@ +export type UserContext = { + id: string; + fullName: string; + email: string | null; + role: "citizen" | "department_operator" | "municipal_admin" | "field_officer"; + departmentId: string | null; + departmentName: string | null; + wardId: string | null; +}; + diff --git a/civic-platform/backend/src/routes/health.ts b/civic-platform/backend/src/routes/health.ts new file mode 100644 index 0000000000000000000000000000000000000000..f22b63dcefca007432c5fb26f6f81f9633854122 --- /dev/null +++ b/civic-platform/backend/src/routes/health.ts @@ -0,0 +1,28 @@ +import { Router } from "express"; +import { db } from "../db/pool.js"; + +export const healthRouter = Router(); + +const startedAt = Date.now(); + +healthRouter.get("/", async (_req, res) => { + try { + await db.query("SELECT 1"); + + res.json({ + status: "ok", + service: "civicpulse-backend", + timestamp: new Date().toISOString(), + uptimeSeconds: Math.round((Date.now() - startedAt) / 1000), + database: "connected", + }); + } catch { + res.status(503).json({ + status: "degraded", + service: "civicpulse-backend", + timestamp: new Date().toISOString(), + uptimeSeconds: Math.round((Date.now() - startedAt) / 1000), + database: "unavailable", + }); + } +}); diff --git a/civic-platform/backend/src/scripts/init-db.ts b/civic-platform/backend/src/scripts/init-db.ts new file mode 100644 index 0000000000000000000000000000000000000000..8323b01446ece1c5a00cffdb5cdd5fb96a9a1901 --- /dev/null +++ b/civic-platform/backend/src/scripts/init-db.ts @@ -0,0 +1,88 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { db } from "../db/pool.js"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const sqlDir = path.resolve(__dirname, "../../sql"); + +async function markLegacyMigrations() { + const rolesCheck = await db.query<{ exists: string | null }>( + `SELECT to_regclass('public.roles')::text AS exists`, + ); + + if (rolesCheck.rows[0]?.exists) { + await db.query( + ` + INSERT INTO schema_migrations (file_name) + VALUES ('001_initial_schema.sql') + ON CONFLICT (file_name) DO NOTHING + `, + ); + } + + const seedCheck = await db.query<{ hasSeed: boolean }>( + ` + SELECT EXISTS (SELECT 1 FROM domains LIMIT 1) + AND EXISTS (SELECT 1 FROM departments LIMIT 1) + AND EXISTS (SELECT 1 FROM users LIMIT 1) AS "hasSeed" + `, + ); + + if (seedCheck.rows[0]?.hasSeed) { + await db.query( + ` + INSERT INTO schema_migrations (file_name) + VALUES ('002_seed_core_data.sql') + ON CONFLICT (file_name) DO NOTHING + `, + ); + } +} + +async function run() { + const files = ["001_initial_schema.sql", "002_seed_core_data.sql", "003_performance_indexes.sql"]; + + await db.query(` + CREATE TABLE IF NOT EXISTS schema_migrations ( + id BIGSERIAL PRIMARY KEY, + file_name TEXT NOT NULL UNIQUE, + applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + `); + + await markLegacyMigrations(); + + for (const file of files) { + const existing = await db.query<{ file_name: string }>( + ` + SELECT file_name + FROM schema_migrations + WHERE file_name = $1 + LIMIT 1 + `, + [file], + ); + + if (existing.rows[0]) { + console.log(`Skipped ${file}`); + continue; + } + + const fullPath = path.join(sqlDir, file); + const sql = await fs.readFile(fullPath, "utf8"); + await db.query(sql); + await db.query(`INSERT INTO schema_migrations (file_name) VALUES ($1)`, [file]); + console.log(`Applied ${file}`); + } + + await db.end(); +} + +run().catch(async (error) => { + console.error("Database initialization failed:"); + console.error(error instanceof Error ? error.message : error); + await db.end().catch(() => undefined); + process.exit(1); +}); diff --git a/civic-platform/backend/src/scripts/seed-db.ts b/civic-platform/backend/src/scripts/seed-db.ts new file mode 100644 index 0000000000000000000000000000000000000000..b1b76632b551547d337c8b275a6104bf9372f3ca --- /dev/null +++ b/civic-platform/backend/src/scripts/seed-db.ts @@ -0,0 +1,23 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { db } from "../db/pool.js"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const sqlDir = path.resolve(__dirname, "../../sql"); + +async function run() { + const fullPath = path.join(sqlDir, "002_seed_core_data.sql"); + const sql = await fs.readFile(fullPath, "utf8"); + await db.query(sql); + console.log("Applied 002_seed_core_data.sql"); + await db.end(); +} + +run().catch(async (error) => { + console.error("Database seed failed:"); + console.error(error instanceof Error ? error.message : error); + await db.end().catch(() => undefined); + process.exit(1); +}); diff --git a/civic-platform/backend/src/scripts/smoke-test.ts b/civic-platform/backend/src/scripts/smoke-test.ts new file mode 100644 index 0000000000000000000000000000000000000000..c494b975d3b8e6818ea6beac0502d93e76509b83 --- /dev/null +++ b/civic-platform/backend/src/scripts/smoke-test.ts @@ -0,0 +1,72 @@ +import process from "node:process"; + +const baseUrl = process.env.API_BASE_URL ?? "http://localhost:4000/api"; + +type SmokeResult = { + name: string; + ok: boolean; + status?: number; + details?: string; +}; + +async function checkJson(path: string, name: string) { + const response = await fetch(`${baseUrl}${path}`); + + if (!response.ok) { + return { + name, + ok: false, + status: response.status, + details: await response.text(), + } satisfies SmokeResult; + } + + await response.json(); + + return { + name, + ok: true, + status: response.status, + } satisfies SmokeResult; +} + +async function checkText(path: string, name: string) { + const response = await fetch(`${baseUrl}${path}`); + + return { + name, + ok: response.ok, + status: response.status, + details: response.ok ? "ok" : await response.text(), + } satisfies SmokeResult; +} + +async function main() { + const checks = await Promise.all([ + checkJson("/health", "Health"), + checkJson("/departments", "Departments"), + checkJson("/domains", "Domains"), + checkJson("/complaints/summary/dashboard", "Dashboard summary"), + checkJson("/complaints/summary/analytics", "Analytics summary"), + checkText("/complaints/summary/export.csv", "Analytics export"), + ]); + + const failed = checks.filter((check) => !check.ok); + + for (const check of checks) { + console.log( + `${check.ok ? "PASS" : "FAIL"} ${check.name}${check.status ? ` (${check.status})` : ""}${ + check.details && !check.ok ? ` - ${check.details}` : "" + }`, + ); + } + + if (failed.length > 0) { + process.exitCode = 1; + } +} + +main().catch((error) => { + console.error("Smoke test failed:", error); + process.exitCode = 1; +}); diff --git a/civic-platform/backend/src/server.ts b/civic-platform/backend/src/server.ts new file mode 100644 index 0000000000000000000000000000000000000000..d71f5c14ebfde0ad1290ece580ed7c3adedbfc99 --- /dev/null +++ b/civic-platform/backend/src/server.ts @@ -0,0 +1,8 @@ +import { createApp } from "./app.js"; +import { env } from "./config/env.js"; + +const app = createApp(); + +app.listen(env.port, () => { + console.log(`CivicPulse backend listening on port ${env.port}`); +}); diff --git a/civic-platform/backend/tsconfig.json b/civic-platform/backend/tsconfig.json new file mode 100644 index 0000000000000000000000000000000000000000..c4462c78c1c622cbb1e0a30d40b2ffde1a03ce83 --- /dev/null +++ b/civic-platform/backend/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*.ts"] +} diff --git a/civic-platform/build.log b/civic-platform/build.log new file mode 100644 index 0000000000000000000000000000000000000000..a4d5c59e48468ac33cb8deb84b833a8a4fbdd922 Binary files /dev/null and b/civic-platform/build.log differ diff --git a/civic-platform/build_utf8.log b/civic-platform/build_utf8.log new file mode 100644 index 0000000000000000000000000000000000000000..9964e2277b08fc7f3c29254f6c0f70f6a180c74d --- /dev/null +++ b/civic-platform/build_utf8.log @@ -0,0 +1,46 @@ +๏ปฟ +> civic-platform@0.1.0 build +> next build + + ฮ“รปโ–“ Next.js 15.5.14 + + Creating an optimized production build ... +node.exe : Failed to +compile. +At C:\Program +Files\nodejs\npm.ps1:29 +char:3 ++ & $NODE_EXE +$NPM_CLI_JS $args ++ ~~~~~~~~~~~~~~~~~~~~~~ +~~~~~~~ + + CategoryInfo + : NotSpecified: (F + ailed to compile.:Str + ing) [], RemoteExcept +ion + + FullyQualifiedError + Id : NativeCommandErr + or + + +./components/issue-map-boa +rd.tsx +Module not found: Package +path . is not exported +from package C:\Users\Prav +een\Documents\New project\ +civic-platform\node_module +s\react-map-gl (see +exports field in C:\Users\ +Praveen\Documents\New proj +ect\civic-platform\node_mo +dules\react-map-gl\package +.json) + +https://nextjs.org/docs/me +ssages/module-not-found + + +> Build failed because of +webpack errors diff --git a/civic-platform/components/admin-assign-action.tsx b/civic-platform/components/admin-assign-action.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f413e826e4f351c2f7e5351d19078201700dd8a7 --- /dev/null +++ b/civic-platform/components/admin-assign-action.tsx @@ -0,0 +1,157 @@ +"use client"; + +import { FormEvent, useEffect, useMemo, useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { LoaderCircle } from "lucide-react"; +import { assignComplaint, getDepartments, getFieldOfficers, type DepartmentOption, type UserOption } from "@/lib/api"; + +export function AdminAssignAction({ + complaintId, + suggestedDepartment, + operatorId, +}: { + complaintId: string; + suggestedDepartment?: string; + operatorId: string; +}) { + const router = useRouter(); + const [departments, setDepartments] = useState([]); + const [officers, setOfficers] = useState([]); + const [selectedDepartment, setSelectedDepartment] = useState(""); + const [selectedOfficer, setSelectedOfficer] = useState(""); + const [notes, setNotes] = useState(""); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + const [isPending, startTransition] = useTransition(); + + const resolvedSuggestion = useMemo(() => suggestedDepartment ?? "Department suggestion pending", [suggestedDepartment]); + + useEffect(() => { + async function loadDepartments() { + try { + const result = await getDepartments(); + setDepartments(result); + + if (result.length > 0) { + const suggestedMatch = result.find((department) => department.name === suggestedDepartment); + setSelectedDepartment(suggestedMatch?.id ?? result[0].id); + } + } catch (loadError) { + setError(loadError instanceof Error ? loadError.message : "Failed to load departments"); + } + } + + void loadDepartments(); + }, [suggestedDepartment]); + + useEffect(() => { + async function loadOfficers() { + if (!selectedDepartment) { + setOfficers([]); + setSelectedOfficer(""); + return; + } + + try { + const result = await getFieldOfficers(selectedDepartment); + setOfficers(result); + setSelectedOfficer((current) => (result.some((officer) => officer.id === current) ? current : "")); + } catch (loadError) { + setOfficers([]); + setSelectedOfficer(""); + setError(loadError instanceof Error ? loadError.message : "Failed to load field officers"); + } + } + + void loadOfficers(); + }, [selectedDepartment]); + + function handleSubmit(event: FormEvent) { + event.preventDefault(); + setError(null); + setSuccess(null); + + startTransition(async () => { + try { + await assignComplaint({ + complaintId, + departmentId: selectedDepartment, + assignedToUserId: selectedOfficer || undefined, + assignedByUserId: operatorId, + notes: notes.trim() || undefined, + }); + + setSuccess(selectedOfficer ? "Complaint assigned to the department and field officer." : "Complaint assigned successfully."); + router.refresh(); + } catch (assignError) { + setError(assignError instanceof Error ? assignError.message : "Failed to assign complaint"); + } + }); + } + + return ( +
+
+ Suggested department: {resolvedSuggestion} +
+ + + + + +