P(l)otHole: Building a Civic Hazard Platform at a Hackathon
Last Saturday I led the engineering team for PhilaConValley at the Good Neighbors Hackathon here in Philadelphia. We built P(l)otHole — a civic platform for mapping, naming, and shaming road hazards until they get fixed. We placed second. This is the technical postmortem.
The event brought together developers, designers, and community organizers building technology for Philadelphia neighborhoods. The premise of our project: every city has potholes that have been reported, ignored, and re-reported for months. Residents file complaints that disappear into bureaucratic black holes. No accountability, no visibility, no pressure. P(l)otHole was built around one idea — make road hazards a social and political liability. If a pothole gets named, voted up, and publicized, it gets harder to ignore.
Architecture
Full-stack TypeScript monorepo, targeting production deployment from day one. We weren't building a demo.
| Layer | Technology |
|---|---|
| Frontend | Next.js 14 App Router + TypeScript (strict mode) |
| Database | PostgreSQL 15 + PostGIS |
| Cache | Redis 7 |
| Storage | S3-compatible (Cloudflare R2) |
| Map UI | Mapbox GL JS |
| Auth | NextAuth.js |
| ORM | Prisma with @prisma/adapter-pg |
| CI/CD | GitHub Actions |
| Deployment | Docker on EC2 via rsync/SSH, nginx |
I made the call to use PostGIS from the start rather than bolting on geospatial support later. Road hazards are geographic data. The schema should model them that way.
Documentation Before Code
This might be the most important thing I can say about the hackathon workflow: we started with docs, not code.
Six architecture documents existed before a single file was scaffolded: data model, gamification system, API conventions, governance plan, roadmap, and deployment strategy. Before we touched the keyboard, I had Claude Code read all six and produce two outputs:
-
CLAUDE.md— a token-optimized context file for future sessions. Full stack, monorepo layout, key commands, condensed data model, API conventions, geospatial constraints. Designed so any future session could front-load context without re-reading everything. -
docs/implementation_plan.md— a phased build plan with parallel work maps, owner columns for task distribution, and an explicit minimum demo scope: submit a hazard with image and map pin, view hazards on a filtered map, see a detail page with days-open counter, export as GeoJSON or CSV.
The plan let me hand off the backend API to a team contributor while I focused on the scaffold and deployment. Without it, we would have been stepping on each other all day.
Scaffold and CI
The monorepo scaffold (feat/scaffold, 26 files, 859 insertions) set up pnpm workspaces, Docker Compose with PostGIS and Redis, shared packages for the DB client and validation schemas, the Next.js app shell, and a GitHub Actions CI pipeline.
CI required 6 fix iterations before going green:
- Missing
pnpm-lock.yamldue to SSL proxy blocking localpnpm install— switched to--no-frozen-lockfile pnpm -r linterroring on packages without lint scripts — added--if-presentnext.config.tsnot supported in Next.js 14 (added in 15) — renamed to.mjseslint-config-nextnot declared as explicit dep in pnpm's strict isolation modeTransactionSqltagged template type issue — replaced withtx.unsafe()- Missing
.eslintrc.jsoninapps/web/
Six CI failures in the first commit is not a bad start for a hackathon. It's a good start for a production codebase — we had CI at all, which meant every subsequent push got validated.
Database Schema
Eight SQL migration files. The schema had a few decisions worth calling out:
Partial index on slug. The unique constraint on hazard slugs is a partial index (WHERE slug IS NOT NULL). This requires ON CONFLICT DO NOTHING rather than ON CONFLICT (slug) in seed SQL — a subtle PostgreSQL behavior that would cause a silent failure if missed. I caught it in review.
Geography type from day one. Hazard location is stored as geography(Point, 4326) — the proper spherical coordinate type, not a flat geometry. The radius search queries (ST_DWithin) work correctly on geography without approximation.
Seed data: 3 users, 15 Philadelphia hazards spread across city neighborhoods, 4 badge definitions (Spotter → Scout → Inspector → Commissioner).
Backend API Integration
A team contributor had built complete API routes on a separate branch using Prisma. Rather than rebuild, I extracted the valuable pieces and integrated them into the monorepo structure. The result was 21 files and 2,803 insertions covering hazard CRUD with cursor-based pagination, community voting with unique-per-user enforcement, full-text and geospatial search, user profiles, badges, leaderboard, GeoJSON/CSV export, and auth integration.
The integration required resolving conflicts between his flat app structure and the monorepo layout, adapting Prisma as a query client (not a migration manager — the SQL migration files own schema), and upgrading Next.js from 14 to 16 along the way.
Deployment
The deployment story is the part I'm most satisfied with. We had a real production deploy working before the judging deadline.
The Next.js app ships via Docker (next build --standalone). GitHub Actions builds the image on the runner, rsyncs the standalone output to the server, and restarts the container with injected environment variables.
One interesting production issue: two simultaneous deploy runs conflicted on the container name when a push and a merge landed within seconds of each other. Fixed with concurrency groups in the workflow:
concurrency:
group: deploy-web
cancel-in-progress: true
TypeScript strict mode also surfaced a typed-routes error — Next.js's typedRoutes: true experimental feature required nav link href values to be cast with import type { Route } from "next". That would have been a runtime failure in a less strict codebase.
The Result and What's Next
We placed second. PhilaConValley, representing Philadelphia. The live platform is deployed, the codebase is open-sourced under the PhilaConValley organization, and the data model is ready for real hazard data.
There are two things I'd build next: real city API integration (Philadelphia's 311 system is accessible) and push notifications when a hazard's status changes. The gamification hooks are there. The pressure mechanism is there. The missing piece is closing the loop with the city.
The codebase is at github.com/philaconvalley if you want to extend it.
