SJDM Report — Developer Docs
A fullstack civic reporting web app for residents of San Jose Del Monte, Bulacan. Built with Next.js, PostgreSQL, and Leaflet.
Overview
SJDM Report is a map-first, anonymous civic reporting platform. Residents can snap a photo of a local problem (potholes, garbage, flooding, broken streetlights), drop a pin on the exact location, and publish it to a live community map — no account needed.
The app targets mobile-first use with a PWA install prompt, offline-friendly design, and a lightweight submission flow (under 60 seconds from open to published).
Tech Stack
| Layer | Technology | Notes |
|---|---|---|
Framework | Next.js 14 (App Router) | Fullstack — no separate API server |
Language | TypeScript | Strict mode |
Styling | Tailwind CSS | With shadcn/ui primitives |
UI Components | shadcn/ui (Radix UI) | Dialog, DropdownMenu, Sheet |
Map | Leaflet + leaflet.markercluster | Vanilla imperative, SSR-disabled |
Map Tiles | OpenStreetMap / ArcGIS / CartoDB | 3 switchable styles |
Database | PostgreSQL + PostGIS | Hosted on Neon |
Image Storage | Cloudinary | Via /api/upload (multer) |
Geocoding | Nominatim (OpenStreetMap) | Reverse geocode on submission |
Deployment | Vercel | Auto-deploy from main branch |
PWA | next-pwa | Installable, manifest.json |
Fonts | Inter (Google Fonts) | Variable weight 400–700 |
Features
- Anonymous reporting — No account, login, or email required. Identity is never stored.
- Geotagged photo submission — Attach a photo (JPG/PNG/WebP, max 5 MB) and drop a pin on the map.
- Live community map — All approved reports are pinned on an interactive Leaflet map with color-coded markers.
- Category filters — Filter the map by report category (roads, waste, flooding, etc.).
- Near Me filter — Show only reports within 500 m of the user's current GPS location.
- Marker clustering — Nearby pins collapse into numbered clusters to reduce visual noise at low zoom.
- Map styles — Switch between Standard, Satellite, and Dark tile layers.
- Heatmap overlay — Point-based heat layer (leaflet.heat) toggled via a map toolbar button. Intensity reflects report density at each location across the map.
- Community voting — Two vote options per report: 'Still an issue' (👎) or 'Looks fixed' (👍). One vote per device, stored in localStorage. Vote totals shown on each report card.
- Client-side duplicate warning — Before submitting, the form scans loaded reports for any within 100 m. An amber inline notice lists nearby reports with distances — the user can override with 'Submit anyway'.
- Flagging — Users can flag a report as Spam, Fake, or Duplicate. Reports with 5+ flags are hidden.
- Share report — Copies a deep-link URL to clipboard (e.g. /app?report=<id>).
- Rate limiting — Max 5 reports per IP per hour (in-memory, resets on server restart).
- Duplicate detection — PostGIS ST_DWithin rejects duplicate reports within 50 m in the last 24 h (hard block). The client-side 100 m check is a softer first warning.
- Reverse geocoding — Nominatim auto-fills a human-readable address on submission.
- Edit window — Submitters receive a one-time edit token valid for 30 minutes.
- PWA install — Installable on Android and iOS via the browser install prompt.
- Dark mode — System-preference aware; persisted in localStorage.
- OG / social preview — Custom 1200×630 OpenGraph image generated via Next.js ImageResponse.
Architecture
There is no separate Express or backend server. All server-side logic lives in Next.js API routes under src/app/api/.
src/
app/
api/
reports/route.ts # GET list + POST create
reports/[id]/
route.ts # PATCH edit + DELETE
flag/route.ts # POST flag a report
vote/route.ts # POST community vote (still_issue / looks_fixed)
upload/route.ts # POST image → Cloudinary
app/
page.tsx # Main map shell ("use client") — all state lives here
layout.tsx # /app route metadata
documentation/
page.tsx # This page
admin/
page.tsx # Admin dashboard
login/page.tsx
page.tsx # Landing page
layout.tsx # Root layout, PWA, OG meta
opengraph-image.tsx # Dynamic OG image (1200×630)
components/
MapView.tsx # Leaflet map (imperative, SSR-off)
ReportForm.tsx # Submission form + 100 m duplicate warning
ReportCard.tsx # Report detail, community votes, flag
ReportModal.tsx # Dialog wrapper for ReportForm (desktop)
DesktopSidebar.tsx # Left filter column + right report list/detail
MobileBottomSheet.tsx # Draggable bottom sheet (mobile filters + list)
MobileSheet.tsx # Animated slide-up sheet container (mobile)
FilterPanel.tsx # Category/barangay filter chips
AboutModal.tsx # About dialog
ThemeToggle.tsx # Dark/light mode switch
constants.ts # SJDM_BOUNDS, category colors/labels
ui/ # shadcn primitives
button.tsx
input.tsx
textarea.tsx
badge.tsx
dialog.tsx
dropdown-menu.tsx
sheet.tsx
lib/
db.ts # PostgreSQL pool (pg)
api.ts # Axios client functions (incl. voteReport)
reports.ts # toReport() — maps DB snake_case to camelCase
barangay.ts # Reverse geocode lat/lng → barangay name
editTokens.ts # Client-side edit token storage (localStorage)
utils.ts # getCurrentLocation, calculateDistance, cn
schema.sql # DB init script
types/
index.ts # Shared TypeScript typesMap implementation
MapView.tsx uses vanilla Leaflet imperatively — not react-leaflet. Three separate useEffects manage: (1) map init + tile layer, (2) syncing report markers, (3) syncing the pending pin preview. Leaflet and its CSS are dynamically imported inside each effect to avoid SSR. Do not introduce react-leaflet.
Desktop layout
On desktop, clicking a report replaces the report list in the right column of DesktopSidebar — no modal. A "Back to list" button dismisses it.ReportModal is used only for new submissions.
Layout Skeletons
Visual breakdown of how the UI is structured on desktop and mobile. Every labelled zone is a distinct React component or fixed overlay.
Desktop — list view ≥ 768px
DesktopSidebar
Desktop — report detail view
ReportCard (inline)
Mobile < 768px
Default (peek)
swipe up to browse reports
MobileBottomSheet
Sheet expanded
MobileBottomSheet
Report card
MobileSheet → ReportCard
Report Categories
| Value | Label | Color | Icon |
|---|---|---|---|
roads | Road Damage | #f97316 (orange) | Construction |
waste | Waste / Garbage | #84cc16 (lime) | Trash2 |
utilities | Utilities | #3b82f6 (blue) | Zap |
flooding | Flooding | #06b6d4 (cyan) | Droplets |
lighting | Street Lighting | #eab308 (yellow) | Lightbulb |
other | Other | #8b5cf6 (violet) | AlertCircle |
Category constants (colors, labels, icons) are defined in src/components/constants.ts.
API Reference
All API routes live under /api. Requests and responses use JSON. Image upload uses multipart/form-data.
/api/reportsReturns all visible reports (flag_count < 5), newest first. Supports optional filters.
Query params
categorystring?Filter by category (roads, waste, etc.)statusstring?Filter by status (pending, in_progress, resolved)Response
// 200 OK
[
{
"id": "uuid",
"title": "Large pothole on Tungkong Mangga road",
"description": "...",
"category": "roads",
"latitude": 14.8201,
"longitude": 121.0598,
"imageUrl": "https://...",
"flagCount": 0,
"stillIssueCount": 3,
"looksFixedCount": 1,
"address": "Tungkong Mangga, San Jose Del Monte",
"barangay": "Tungkong Mangga",
"createdAt": "2025-04-24T00:00:00.000Z"
}
]/api/reportsSubmit a new report. Rate-limited to 5 per IP per hour. Rejects duplicates within 50 m in the past 24 h.
Request body
{
"title": "string (max 100 chars)",
"description": "string (min 20 chars)",
"category": "roads | waste | utilities | flooding | lighting | other",
"latitude": number,
"longitude": number,
"imageUrl": "string"
}Response
// 201 Created
{
...report,
"editToken": "hex-string", // valid for 30 min
"editExpiresAt": "ISO string"
}
// 400 Bad Request — validation failed
// 409 Conflict — duplicate nearby
// 429 Too Many Requests — rate limited
// 503 Service Unavailable — DB error/api/reports/[id]/flagFlag a report. One flag per IP per report. Reports with 5+ flags are hidden.
Request body
{
"reason": "spam | fake | duplicate"
}Response
// 200 OK — flag recorded // 409 Conflict — already flagged by this IP
/api/reports/[id]/voteSubmit a community vote on whether a report is still an issue or looks resolved. One vote per IP per report; re-voting changes the existing vote.
Request body
{
"vote": "still_issue | looks_fixed"
}Response
// 200 OK
{
"stillIssueCount": 4,
"looksFixedCount": 1
}
// 400 Bad Request — invalid vote value/api/uploadUpload an image to Cloudinary. Accepts multipart/form-data. Returns a Cloudinary secure URL.
Request body
// multipart/form-data file: <image file> // JPG, PNG, or WebP — max 5 MB
Response
// 200 OK
{ "url": "https://res.cloudinary.com/..." }
// 400 Bad Request — missing or invalid fileDatabase Schema
PostgreSQL with the PostGIS extension. Run src/lib/schema.sql once to initialize. The schema is additive — safe to re-run on existing databases.
-- Enable PostGIS
CREATE EXTENSION IF NOT EXISTS postgis;
-- Main reports table
CREATE TABLE IF NOT EXISTS reports (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title VARCHAR(100) NOT NULL,
description TEXT NOT NULL,
category VARCHAR(20) NOT NULL
CHECK (category IN ('roads','waste','utilities','flooding','lighting','other')),
status VARCHAR(20) NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending','in_progress','resolved')),
latitude DOUBLE PRECISION NOT NULL,
longitude DOUBLE PRECISION NOT NULL,
image_url TEXT NOT NULL,
flag_count INT NOT NULL DEFAULT 0,
hashed_ip VARCHAR(64),
edit_token VARCHAR(64),
edit_expires_at TIMESTAMPTZ,
address TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Flags table (one flag per IP per report)
CREATE TABLE IF NOT EXISTS report_flags (
report_id UUID NOT NULL REFERENCES reports(id) ON DELETE CASCADE,
hashed_ip VARCHAR(64) NOT NULL,
reason VARCHAR(20),
flagged_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (report_id, hashed_ip)
);
-- Community votes
CREATE TABLE IF NOT EXISTS report_votes (
report_id UUID NOT NULL REFERENCES reports(id) ON DELETE CASCADE,
hashed_ip VARCHAR(64) NOT NULL,
vote VARCHAR(20) NOT NULL CHECK (vote IN ('still_issue','looks_fixed')),
voted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (report_id, hashed_ip)
);
-- Status update timeline
CREATE TABLE IF NOT EXISTS report_updates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
report_id UUID NOT NULL REFERENCES reports(id) ON DELETE CASCADE,
status VARCHAR(20) NOT NULL,
note TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);Column naming
The database uses snake_case (image_url, flag_count, created_at) while the TypeScript Report type uses camelCase. The toReport() function in src/lib/reports.ts maps between them.
Indexes
-- Spatial index for 50 m duplicate detection CREATE INDEX reports_geog_idx ON reports USING GIST ( CAST(ST_SetSRID(ST_MakePoint(longitude, latitude), 4326) AS geography) ); CREATE INDEX reports_status_idx ON reports (status); CREATE INDEX reports_category_idx ON reports (category); CREATE INDEX reports_created_idx ON reports (created_at DESC);
Environment Variables
Copy .env.local.example to .env.local and fill in the values.
| Variable | Required | Description |
|---|---|---|
DATABASE_URL | Yes | PostgreSQL connection string (Neon, Supabase, etc.) |
IP_SALT | Yes | Secret used to hash submitter IPs before storage |
CLOUDINARY_CLOUD_NAME | Yes | Your Cloudinary cloud name |
CLOUDINARY_API_KEY | Yes | Cloudinary API key |
CLOUDINARY_API_SECRET | Yes | Cloudinary API secret |
NEXT_PUBLIC_SITE_URL | No | Canonical URL for OG meta (defaults to Vercel production URL) |
NEXT_PUBLIC_FB_APP_ID | No | Facebook App ID for fb:app_id OG tag |
Components
| Component | Description |
|---|---|
MapView | Vanilla Leaflet map — markers, clustering, tile switcher, pending pin. Imperative pattern, no react-leaflet. |
ReportForm | Submission form with photo upload, location picker, and 100 m client-side duplicate warning. |
ReportCard | Full report detail — photo, meta, community vote buttons (👍 / 👎), flag dropdown, share. |
ReportModal | shadcn Dialog wrapper for ReportForm (desktop new-submission flow). |
DesktopSidebar | Two-column desktop layout: left = filters, right = report list or inline report detail. |
MobileBottomSheet | Draggable bottom sheet showing category filters + scrollable report list on mobile. |
MobileSheet | Animated slide-up sheet container used for report detail and form on mobile. |
FilterPanel | Category and barangay filter chip rows. |
AboutModal | App info dialog — story, features, support QR. |
ThemeToggle | Dark/light mode switch; preference persisted in localStorage. |
PWARegister | Registers service worker for PWA install prompt. |
CountUp | Animated number counter used on the landing page. |
AnimatedContent | Fade + slide-in animation wrapper used on the landing page. |
All components live in src/components/. UI primitives (Button, Input, Textarea, Badge, Dialog, DropdownMenu, Sheet) are in src/components/ui/ — use these instead of raw HTML elements.
Dev Commands
# Install dependencies npm install # Start dev server (http://localhost:3000) npm run dev # Production build npm run build # Run production build locally npm start # Lint npm run lint # Initialize database (run once) psql $DATABASE_URL -f src/lib/schema.sql # Seed sample data (clears existing reports first) psql $DATABASE_URL -f src/lib/seed.sql
Deployment
The app is deployed to Vercel. Pushing to the main branch triggers an automatic production deploy. The database is hosted on Neon (serverless PostgreSQL). Cloudinary is used for image storage. No additional build configuration is needed beyond setting environment variables in the Vercel dashboard.