Technical Documentation

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).

Live URLsjdm-report.vercel.app
Target areaSan Jose Del Monte, Bulacan
AuthNone — fully anonymous
LicenseMIT

Tech Stack

LayerTechnologyNotes
FrameworkNext.js 14 (App Router)Fullstack — no separate API server
LanguageTypeScriptStrict mode
StylingTailwind CSSWith shadcn/ui primitives
UI Componentsshadcn/ui (Radix UI)Dialog, DropdownMenu, Sheet
MapLeaflet + leaflet.markerclusterVanilla imperative, SSR-disabled
Map TilesOpenStreetMap / ArcGIS / CartoDB3 switchable styles
DatabasePostgreSQL + PostGISHosted on Neon
Image StorageCloudinaryVia /api/upload (multer)
GeocodingNominatim (OpenStreetMap)Reverse geocode on submission
DeploymentVercelAuto-deploy from main branch
PWAnext-pwaInstallable, manifest.json
FontsInter (Google Fonts)Variable weight 400–700

Features

  • Anonymous reportingNo account, login, or email required. Identity is never stored.
  • Geotagged photo submissionAttach a photo (JPG/PNG/WebP, max 5 MB) and drop a pin on the map.
  • Live community mapAll approved reports are pinned on an interactive Leaflet map with color-coded markers.
  • Category filtersFilter the map by report category (roads, waste, flooding, etc.).
  • Near Me filterShow only reports within 500 m of the user's current GPS location.
  • Marker clusteringNearby pins collapse into numbered clusters to reduce visual noise at low zoom.
  • Map stylesSwitch between Standard, Satellite, and Dark tile layers.
  • Heatmap overlayPoint-based heat layer (leaflet.heat) toggled via a map toolbar button. Intensity reflects report density at each location across the map.
  • Community votingTwo 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 warningBefore 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'.
  • FlaggingUsers can flag a report as Spam, Fake, or Duplicate. Reports with 5+ flags are hidden.
  • Share reportCopies a deep-link URL to clipboard (e.g. /app?report=<id>).
  • Rate limitingMax 5 reports per IP per hour (in-memory, resets on server restart).
  • Duplicate detectionPostGIS 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 geocodingNominatim auto-fills a human-readable address on submission.
  • Edit windowSubmitters receive a one-time edit token valid for 30 minutes.
  • PWA installInstallable on Android and iOS via the browser install prompt.
  • Dark modeSystem-preference aware; persisted in localStorage.
  • OG / social previewCustom 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 types

Map 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

Roads
Waste
Flood
Light
Other
Brgy 1
Brgy 2
About
+ Report

DesktopSidebar

MapView (Leaflet)
+
🗺
🔥

Desktop — report detail view

Roads
Waste
Flood
Light
Other
About
‹ Back to list
Roads
Share
👎 3
👍 1
🚩 Flag

ReportCard (inline)

MapView (Leaflet)
+
🗺
🔥

Mobile < 768px

Default (peek)

MapView
logo
🔍
📍
+ Report a Problem

swipe up to browse reports

MobileBottomSheet

Sheet expanded

MapView
logo
🔍
RoadsWasteFloodOther

MobileBottomSheet

Report card

Roads
Share
👎 3
👍 1
🚩 Flag

MobileSheet → ReportCard


Report Categories

ValueLabelColorIcon
roadsRoad Damage#f97316 (orange)Construction
wasteWaste / Garbage#84cc16 (lime)Trash2
utilitiesUtilities#3b82f6 (blue)Zap
floodingFlooding#06b6d4 (cyan)Droplets
lightingStreet Lighting#eab308 (yellow)Lightbulb
otherOther#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.

GET/api/reports

Returns 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"
  }
]
POST/api/reports

Submit 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
POST/api/reports/[id]/flag

Flag 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
POST/api/reports/[id]/vote

Submit 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
POST/api/upload

Upload 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 file

Database 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.

VariableRequiredDescription
DATABASE_URLYesPostgreSQL connection string (Neon, Supabase, etc.)
IP_SALTYesSecret used to hash submitter IPs before storage
CLOUDINARY_CLOUD_NAMEYesYour Cloudinary cloud name
CLOUDINARY_API_KEYYesCloudinary API key
CLOUDINARY_API_SECRETYesCloudinary API secret
NEXT_PUBLIC_SITE_URLNoCanonical URL for OG meta (defaults to Vercel production URL)
NEXT_PUBLIC_FB_APP_IDNoFacebook App ID for fb:app_id OG tag

Components

ComponentDescription
MapViewVanilla Leaflet map — markers, clustering, tile switcher, pending pin. Imperative pattern, no react-leaflet.
ReportFormSubmission form with photo upload, location picker, and 100 m client-side duplicate warning.
ReportCardFull report detail — photo, meta, community vote buttons (👍 / 👎), flag dropdown, share.
ReportModalshadcn Dialog wrapper for ReportForm (desktop new-submission flow).
DesktopSidebarTwo-column desktop layout: left = filters, right = report list or inline report detail.
MobileBottomSheetDraggable bottom sheet showing category filters + scrollable report list on mobile.
MobileSheetAnimated slide-up sheet container used for report detail and form on mobile.
FilterPanelCategory and barangay filter chip rows.
AboutModalApp info dialog — story, features, support QR.
ThemeToggleDark/light mode switch; preference persisted in localStorage.
PWARegisterRegisters service worker for PWA install prompt.
CountUpAnimated number counter used on the landing page.
AnimatedContentFade + 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.


“Para sa bawat San Joseño”

(For every San Joseno)

← Back to home