Built with Georgia π¬πͺ in mind β point it at a different map extract and it works anywhere.
π Live Demo Β Β·Β π Getting Started Β Β·Β π‘ API Overview
Preview deployment of the current feature branch β Aiven + Render + Vercel. See Cloud Deployment.
π Table of Contents
RoadReport is a full-stack web app where drivers report live road hazards on an interactive map, the community moderates those reports through voting, and a self-hosted routing engine automatically steers new routes around active danger zones.
It has grown to include email-verified accounts, an admin moderation panel, automatic report merging/expiry/promotion, a user reputation system, and hazard-aware routing calculated by a self-hosted engine.
The default map view is centered on Tbilisi and the app currently ships with an OpenStreetMap extract for Georgia, but nothing about the code is Georgia-specific beyond that one data file β point it at a different .osm.pbf extract and it works anywhere.
- Interactive Leaflet map with 7 report types, each with its own marker: π³οΈ Pothole Β· π₯ Accident Β· π Heavy Traffic Β· π§ Road Closure Β· πΈ Speed Camera Β· π Police Β·
β οΈ Custom - Two modes: Report mode (tap the map to file a report at that spot) and Route mode (pick an origin & destination)
- Hazard-aware routing via a self-hosted GraphHopper engine: routes are penalized around active potholes/accidents/heavy-traffic/closures (closures are avoided outright), scaled by how credible and how "heavy" the report is; speed camera and police reports are shown on the map but don't alter the calculated route
- Search reports within a radius, or switch to "My Reports"
- Follow-me mode: keep the map centered on your live GPS location as you move
- Per-device proximity radius preference
- Upvote / downvote any report, with optimistic UI updates
- Reports are automatically removed once they collect enough negative votes, or promoted to a permanent, non-expiring status once they collect enough confirming votes
- A scheduled job periodically finds duplicate reports of the same type near each other and merges them (combining votes, comments and weight)
- Another scheduled job purges expired/removed reports from the database
- Comments on every report (create / edit / delete), sanitized server-side against XSS
- A reputation score per user, with "Driver Status" ranks, and automatic temporary bans for repeatedly-rejected reporters
- JWT auth over a secure, HttpOnly cookie (Spring Security + BCrypt)
- Email verification on sign-up (Brevo transactional email)
- Role-based access control (
USER/ADMIN) - A dedicated Admin Panel: ban/unban/delete users, adjust reputation, force a report's status, delete reports/comments
- "Admin Shield" β moderation actions can never be applied to another admin account
- Deleting your account reassigns your reports/comments to an internal "ghost" user, so history isn't lost
| Layer | Technologies |
|---|---|
| Backend | Java 17 Β· Spring Boot Β· Spring Security Β· Spring Data JPA / Hibernate Β· MySQL 8 Β· Lombok Β· Jsoup (XSS sanitization) Β· GraphHopper (self-hosted routing) Β· JUnit 5 Β· Mockito Β· Maven |
| Frontend | React Β· React Router Β· Leaflet / react-leaflet Β· Axios Β· Vite |
| Infra | Docker & Docker Compose Β· Nginx Β· Aiven (MySQL) Β· Render (backend) Β· Vercel (frontend) Β· Brevo (email) |
Note
The JWT cookie is issued with Secure + SameSite=None (needed since the deployed frontend and backend live on different domains), so browsers will only send/accept it over HTTPS β which is why even local development runs behind a TLS certificate instead of plain HTTP.
flowchart TB
U["π§βπ» Browser<br/>React + Vite + Leaflet"]
N["Nginx<br/>TLS termination Β· SPA Β· reverse proxy"]
S["Spring Boot API<br/>Spring Security Β· JWT Β· JPA/Hibernate"]
G["GraphHopper<br/>self-hosted routing engine"]
D[("MySQL 8")]
BR["Brevo<br/>transactional email"]
U -->|HTTPS| N
N -->|"/api/** proxy"| S
S --> G
S -->|JPA/Hibernate| D
S -->|verification emails| BR
classDef frontend fill:#fef9c3,stroke:#ca8a04,color:#1f2937
classDef backend fill:#dbeafe,stroke:#1d4ed8,color:#1f2937
classDef external fill:#f3f4f6,stroke:#6b7280,color:#1f2937
class N frontend
class S,G backend
class D,BR external
Three Docker Compose services do the work: db (MySQL, internal-only), backend (Spring Boot with the embedded GraphHopper engine β shown in blue above), and frontend (Nginx, serving the built SPA and terminating TLS β shown in yellow). In local dev, backend is also reachable directly at localhost:8082 for debugging β but in production, only frontend needs to be public; Nginx proxies /api requests straight through.
.
βββ docker-compose.yml
βββ .env # you create this β see Getting Started
βββ .env.example
βββ server/ # Spring Boot backend
β βββ src/
β β βββ main/java/RoadReport/...
β β βββ main/resources/application.properties
β β βββ test/java/RoadReport/... # JUnit test suite β see Testing
β βββ data/ # OSM extract + GraphHopper cache (not committed)
β βββ dummy-cache/ # lightweight GraphHopper cache used by tests
β βββ Dockerfile
βββ web/ # React frontend
βββ src/...
βββ vite.config.js # dev-mode HTTPS + /api proxy
βββ nginx.conf
βββ vercel.json
βββ localhost.pem / localhost-key.pem # you create these β see Getting Started
βββ Dockerfile
The fastest path is Docker Compose, which builds and wires up the database, backend, and frontend (with HTTPS) in one command.
- Docker Engine + Docker Compose v2 (
docker compose) - mkcert (or
openssl) to generate a local TLS certificate - (only if running things outside Docker) Node.js 20+, Java 17+, Maven, MySQL 8
git clone <repo-url>
cd <repo-folder>GraphHopper needs a .osm.pbf file to build its road graph. Download an extract covering Georgia (e.g. from Geofabrik) and place it at:
server/data/georgia-260525.osm.pbf
Tip
Using a different file name or region? Either update graphhopper.osm-file in application.properties, or just set the OSM_FILE_PATH environment variable β it overrides the default without touching any code. The first backend startup will take a few minutes while GraphHopper imports the file and builds its cache β later starts are fast as long as the container isn't rebuilt/recreated.
Copy the example file into a .env at the project root (next to docker-compose.yml), then fill it in:
cp server/.env.example .env| Variable | Description | Example |
|---|---|---|
SPRING_DATASOURCE_URL |
JDBC URL for MySQL | jdbc:mysql://db:3306/roadreport |
DB_USERNAME |
MySQL username | root |
DB_PASSWORD |
MySQL password | must match MYSQL_ROOT_PASSWORD in docker-compose.yml |
JWT_SECRET_KEY |
Base64-encoded HMAC signing key | generate with openssl rand -base64 32 |
JWT_EXPIRATION |
Token lifetime, in milliseconds | 86400000 (24h) |
FRONTEND_URL |
Public URL of the frontend, used inside verification emails | https://localhost |
BREVO_API_KEY |
API key for Brevo transactional email | β |
SENDER_EMAIL |
Verified "from" address for verification emails | no-reply@roadreport.ge |
OSM_FILE_PATH |
(optional) Overrides where GraphHopper looks for the .osm.pbf extract |
defaults to data/georgia-260525.osm.pbf |
Note
The bundled docker-compose.yml hardcodes the MySQL root password (chipolino123) and database name (roadreport) for the db service β keep DB_USERNAME / DB_PASSWORD / SPRING_DATASOURCE_URL in sync with those, or edit docker-compose.yml if you want different credentials. No manual migrations needed β Hibernate creates the schema automatically (ddl-auto=update) against the empty database on first boot.
Warning
No real Brevo key yet? Registration still "succeeds", but the verification email silently fails to send and the new account stays disabled. Either add real Brevo credentials, or manually set that user's is_enabled column to 1/TRUE in the users table for local testing.
The frontend container serves the app over HTTPS using a certificate you provide. Generate one for localhost and place it in web/:
mkcert -install
mkcert -cert-file web/localhost.pem -key-file web/localhost-key.pem localhostWarning
Without these two files, the frontend image won't build β the Dockerfile copies them in directly. (The same two files are also picked up automatically if you run the frontend outside Docker β see below.)
docker compose up --build| Service | Purpose | Exposed as |
|---|---|---|
db |
MySQL 8 | internal only |
backend |
Spring Boot API + GraphHopper | localhost:8082 (direct/debug access) |
frontend |
Nginx: serves the SPA, proxies /api to the backend, terminates TLS |
https://localhost |
Open https://localhost (accept the self-signed certificate warning if you skipped mkcert -install).
Important
First login: the backend seeds a default admin on first boot β road_admin / road_admin (see SystemDataInitializer). Change or remove it before deploying anywhere public. A second internal account, ghostUser, is also created to hold content from deleted users and has no usable password.
Tip
The GraphHopper cache isn't mounted as a Docker volume, so --build or down && up re-imports the OSM graph from scratch. Mount server/data/graphhopper-cache as a volume if you want faster rebuild cycles.
Database β install MySQL 8 and create an empty database:
CREATE DATABASE roadreport;Hibernate creates all the tables automatically on first run (ddl-auto=update), so no manual migration is required. If you'd rather set the schema up explicitly, a plain SQL version (matching what Hibernate would generate) is also included in the repo β note that it targets a database named road_report_db, so either rename it to match your SPRING_DATASOURCE_URL or point the URL at that name instead.
Backend:
cd server
# export the variables from your .env into the shell, or configure them in your IDE run config
./mvnw spring-boot:runRuns at http://localhost:8080. You need to set OSM_FILE_PATH if your .osm.pbf file lives somewhere other than app/data/georgia-260525.osm.pbf β that's the default.
Frontend:
cd web
npm install
npm run devRuns at http://localhost:5173. vite.config.js already proxies /api to http://localhost:8080, so the dev server talks to your local backend with no extra setup. If web/localhost.pem / web/localhost-key.pem exist (the same certificate pair from step 4 above), Vite automatically serves over HTTPS too β otherwise it just falls back to plain HTTP.
The backend has a JUnit 5 test suite under server/src/test/java/RoadReport/, mirroring the main source layout:
| Package | Covers |
|---|---|
TestControllers |
REST endpoint behavior |
TestExceptionHandler |
Global exception β HTTP status mapping |
TestRepositories |
JPA queries, via @DataJpaTest + TestEntityManager |
TestSecurity |
JWT filter & authentication flow |
TestServices |
Business logic β reports, votes, users, email, etc. |
Repository tests spin up an isolated JPA context and exercise the real queries β e.g. TestCommentRepository covers per-report/per-user ordering and the comment-migration query used when reports get merged. Service-layer tests lean on Mockito β TestEmailService, for example, mocks RestClient to confirm both the happy path and that a failed Brevo call is caught and logged instead of breaking registration.
Run everything with:
cd server
./mvnw testNote
A dummy-cache folder alongside src/ gives GraphHopper a small, isolated cache to boot from during tests, so the suite doesn't need to import the full real-world OSM extract just to start the Spring context.
The live demo runs on a three-provider setup:
| Layer | Provider |
|---|---|
| Database | Aiven (managed MySQL) |
| Backend API | Render (Docker deploy of server/) |
| Frontend | Vercel (static build of web/) |
To deploy your own copy:
- Spin up a MySQL database on Aiven and grab its connection details.
- Deploy
server/as a Docker web service on Render, setting the same environment variables from the table above (pointed at your Aiven database). - Deploy
web/on Vercel, updating thedestinationinvercel.jsonto your Render backend's URL. - Set
FRONTEND_URL(backend env var) to your Vercel domain, so verification emails link to the right place. - Confirm your Vercel domain is covered by the CORS allow-list in
SecurityConfig.java(*.vercel.appis allowed by default).
All endpoints are prefixed with /api. π = public, β
= requires a logged-in user (JWT cookie or Authorization: Bearer header).
Auth (/auth)
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /register |
π | Create an account (sends a verification email) |
| POST | /login |
π | Authenticate, sets the JWT cookie |
| POST | /logout |
π | Clears the JWT cookie |
| GET | /verify?token= |
π | Confirm an email verification token |
Users (/users)
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /me |
β | Current user's private profile |
| GET | /{id} |
β | Another user's public profile |
| PUT | /me |
β | Update the current user's profile |
| DELETE | /me |
β | Delete the current user's account |
Reports (/reports)
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | / |
β | Submit a report at a location |
| GET | /?latitude=&longitude=&radius= |
π | Reports within a radius (radius<=0 β all reports) |
| GET | /me |
β | Reports submitted by the current user |
Votes (/vote)
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /{reportId}/upvote |
β | Upvote a report |
| POST | /{reportId}/downvote |
β | Downvote a report |
| GET | /{reportId}/votes |
π | Current up/down vote counts |
Comments
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /reports/{reportId}/comments |
β | Add a comment |
| GET | /reports/{reportId}/comments |
π | List comments on a report |
| PUT | /comments/{commentId} |
β | Edit your own comment |
| DELETE | /comments/{commentId} |
β | Delete your own comment |
Routes (/routes)
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /calculate |
β | Compute a hazard-aware route through a list of waypoints |
Admin (/admin, requires ROLE_ADMIN)
| Method | Path | Description |
|---|---|---|
| GET | /users/{id} |
Full account details for a user |
| PATCH | /users/{id}/ban?daysToBan= |
Ban a user for N days |
| PATCH | /users/{id}/unban |
Lift a ban |
| DELETE | /users/{id} |
Delete a user (content reassigned to the ghost user) |
| PATCH | /users/{id}/reputation?isReset=&score= |
Adjust or reset reputation |
| PATCH | /reports/{id}/status?newReportStatus= |
Force a report's status |
| DELETE | /reports/{id} |
Delete a report |
| DELETE | /comments/{id} |
Delete a comment |
All admin actions are blocked against other ADMIN accounts (the "Admin Shield").
Note
Per the current SecurityConfig, only /auth/**, GET /reports/**, and GET /vote/*/votes are open to anonymous requests β everything else (including viewing another user's profile) currently needs a session. Adjust SecurityConfig.java if public profile pages are meant to work for logged-out visitors too.
- Passwords hashed with BCrypt
- JWT stored in a
Secure,HttpOnly,SameSite=Nonecookie (also accepted viaAuthorization: Bearer) - Comment and report-description input is sanitized server-side with Jsoup before it's stored
- Endpoint-level RBAC via Spring Security +
@PreAuthorize - "Admin Shield": moderation actions are rejected outright if the target is also an
ADMIN - Optimistic locking (
@Version) guardsUserandReportagainst concurrent-update conflicts
| Job | Frequency | What it does |
|---|---|---|
| Report cleanup | every 2 minutes | Deletes reports that are expired or already REMOVED |
| Duplicate merge | every hour | Merges active reports of the same type within ~50m of each other (votes, comments, and weight combined) |
| Rule | Condition | Effect |
|---|---|---|
| Auto-remove | β₯ 5 total votes, β₯ 50% downvotes | Status β REMOVED; author loses reputation |
| Auto-promote to permanent* | β₯ 10 total votes, β₯ 95% upvotes | Status β PERMANENT (stops expiring) |
| Reject-streak ban | 3 auto-removed reports | Author banned for 3 days |
| Rejected-report penalty | A report is auto-removed | Author reputation β5 |
| Per-vote reputation | Each up/downvote received on a report | Author reputation +1 / β1 |
| Default report lifetime | β | 1 day from creation, unless promoted to PERMANENT |
* Only for non-ephemeral types: POTHOLE, SPEED_CAMERA, POLICE. ACCIDENT, HEAVY_TRAFFIC, ROAD_CLOSURE, and CUSTOM are always temporary and simply expire.
Built by a five-person team as a Freeuni OOP final project:
- Giorgi Ezugbaia
- Luka Tasoshvili
- Nikoloz Bendianishvili
- Luka Tsitsilashvili
- Nikoloz Shubitidze
β If RoadReport is useful to you, consider starring the repo!