MappedIn
not yet deployedA travel portfolio on a rotatable 3D globe where the geography is computed in PostGIS rather than faked in JavaScript.
Build log: The repository carries a full engineering build log recording every problem hit during the build and how each one was solved.
Architecture
- Next.js 16 + MapLibre GL (globe client), client. Renders the rotatable globe, draws journey arcs from server-computed geometry, and manages auth and place lists. Connected to Security filter chain over REST + JWT. Connected to OpenStreetMap Nominatim over geocoding. Connected to OpenFreeMap over vector tiles.
- Security filter chain (hand-written), service. Orders every security concern explicitly: CORS, exception translation, the JWT filter, and per-route authorisation rules. Connected from Next.js 16 + MapLibre GL over REST + JWT. Connected to JWT filter.
- JWT filter (HS256, Nimbus JOSE), service. Validates HS256 tokens via Nimbus JOSE and installs the authenticated principal for the request. Connected from Security filter chain. Connected to Ownership-scoped repositories.
- Ownership-scoped repositories, service. Every query is scoped by owner at the repository signature: no method can fetch a list without being told whose it is. Connected from JWT filter. Connected to PostgreSQL 18 over JDBC.
- Flyway (7 migrations), service. Applies the seven migrations at boot, so schema state is a property of the code rather than of whoever touched the database last. Connected to PostgreSQL 18 over DDL. Connected from Testcontainers over integration tests.
- PostgreSQL 18 (PostGIS 3.6, uuidv7()), datastore. Stores users, places, lists, and memberships; generates UUIDv7 keys natively; runs all geography in SQL. Connected from Ownership-scoped repositories over JDBC. Connected from Flyway over DDL. Connected to ST_DWithin.
- ST_DWithin (GIST index scan), datastore. Answers proximity queries as an index scan over the GIST index. Connected from PostgreSQL 18. Connected to ST_Segmentize.
- ST_Segmentize (geography(Point,4326)), datastore. Densifies each journey line along the true geodesic so arcs drape over the sphere instead of cutting through it. Connected from ST_DWithin. Connected to ST_Distance.
- ST_Distance (spheroid metres), datastore. Reports real spheroid metres between places, used for journey totals. Connected from ST_Segmentize.
- OpenStreetMap Nominatim (geocoding), external. Turns a typed place name into candidate coordinates, ranked by Wikipedia-derived prominence. Connected from Next.js 16 + MapLibre GL over geocoding.
- OpenFreeMap (vector tiles), external. Serves the vector tiles MapLibre renders. No key, no meter. Connected from Next.js 16 + MapLibre GL over vector tiles.
- Testcontainers (real PostGIS), service. Runs the backend suite against a real PostGIS container, never H2. Connected to Flyway over integration tests.
- Boundary: Spring Boot 4.1, Java 25.
- Boundary: PostgreSQL 18 + PostGIS 3.6.
- Boundary: Test boundary.
Decisions
Geography lives in the database
- The decision
- Proximity is ST_DWithin against a GIST index; journey arcs come from ST_Segmentize on a geography column, which densifies each line along the true geodesic; distances are real spheroid metres from ST_Distance.
- The constraint that forced it
- Routes must drape over the sphere and distances must be true metres, not planar approximations.
- Rejected
- Faking the geography in JavaScript with client-side interpolation.
- Consequence accepted
- The database is the geometry engine. Query cost and tuning live in SQL, and the client stays thin.
Index behaviour verified, not assumed
- The decision
- EXPLAIN (ANALYZE, BUFFERS) confirms ST_DWithin produces an index scan.
- The constraint that forced it
- The equivalent ST_Distance(...) < n degrades to a sequential scan because the planner cannot convert a scalar comparison into a box predicate.
- Rejected
- Trusting that any spatial predicate would use the spatial index.
- Consequence accepted
- Query shapes are pinned by evidence. A regression shows up as a plan change, not as a slow page.
Authorization is structural, not procedural
- The decision
- No repository method can fetch a list without being told whose it is, so "forgot the permission check" is not a mistake the code permits.
- The constraint that forced it
- Ownership rules must hold across every current and future endpoint.
- Rejected
- Procedural permission checks sprinkled ahead of generic repository calls.
- Consequence accepted
- Another user's resource returns 404 rather than 403, so record identifiers cannot be enumerated by diffing status codes.
UUIDv7 primary keys, generated in the database
- The decision
- Primary keys are UUIDv7, generated natively by PostgreSQL 18.
- The constraint that forced it
- Random UUIDv4 fragments B-tree pages on insert; sequential identifiers invite enumeration.
- Rejected
- A bigint would be smaller and naturally ordered, but the 404-not-403 defence is far weaker against sequential identifiers.
- Consequence accepted
- Time-ordered inserts append at the right edge of the B-tree, and keys stay opaque to users.
List membership is an entity
- The decision
- List membership is a first-class association entity carrying visited_on and notes.
- The constraint that forced it
- The relationship itself holds data: when a place was visited, and what was noted there.
- Rejected
- A JPA @ManyToMany, which has nowhere to put relationship attributes and rewrites every join row on modification.
- Consequence accepted
- One more entity and repository to maintain, in exchange for stable rows and a home for the data.
Tests run against real PostGIS
- The decision
- The backend suite runs against real PostGIS through Testcontainers, never H2.
- The constraint that forced it
- uuidv7(), geography, GIST, ST_DWithin, and the trigger functions have no H2 equivalent, so H2 tests would pass while proving nothing.
- Rejected
- H2 in a compatibility mode.
- Consequence accepted
- Docker becomes a test dependency, and container start time is paid on every run.
The geocoder was chosen by measurement
- The decision
- Geocoding ranks candidates by Wikipedia-derived prominence.
- The constraint that forced it
- The first candidate ranked "Eiffel Tower" as a hill in Alberta and "Mount Fuji" as a location in Wisconsin.
- Rejected
- Keeping the first geocoder for its friendlier rate limit.
- Consequence accepted
- A stricter rate limit that the search UI was then designed around.
The antimeridian is handled, and pinned by tests
- The decision
- Longitude unwrapping keeps journey arcs continuous across the antimeridian.
- The constraint that forced it
- A naive renderer draws a Pacific crossing as a line around the whole planet.
- Rejected
- Clamping longitudes to [-180, 180] and accepting the wrap.
- Consequence accepted
- Tests assert both that no step exceeds 180 degrees and that vertices are allowed to fall outside the [-180, 180] range.
Known limitations
- The server-side bounding box is antimeridian-naive, and a test asserts that wrong behaviour on purpose, so fixing it flips a test rather than going unnoticed.
- There is no Content-Security-Policy, which is the main missing compensating control given that the token is held in localStorage.
- There is no token revocation path.