Skip to content

Architecture

Austin Stanfield edited this page Sep 29, 2026 · 12 revisions

Architecture

Where data lives, the database tables, and the stack it is built on.

File Storage

Data Location Persisted via
Uploaded photos public/photos/ Docker volume
Site config public/config/site.json Docker volume
Photo metadata public/config/photos.json Docker volume
Timeline public/config/timeline.json Docker volume
Registry items inside site.json (registryItems[]) Docker volume
RSVPs / guest list PostgreSQL Docker volume

Photos are served via /api/photos/[filename] — not as static files — because Next.js standalone doesn't serve runtime volume files statically.

Database Tables

Table Purpose
rsvps Guest RSVP submissions. dietary_restrictions is a JSONB array, one entry per attending person, matched to a seat by name: { name, vegetarian, vegan, gluten_free, nut_allergy, other, other_text, kids_meal, no_meal }. The last two decide the plate rather than modify it — a kids' meal is a different plate, no_meal is a chair with no plate against it, so plates ≠ chairs once anyone is marked not eating
guest_list Pre-populated guest list with contact info
wip_toggles Per-page WIP/Hidden toggle state
seating_* Seating chart room/table/assignment data (floor_plans, floor_plan_room, floor_plan_walls, seating_tables, seat_assignments)
vendors The photographer, the DJ, the planner — name, role, company, contact, needs_meal, and a dietary JSONB in the same shape an RSVP stores. Deliberately not guest_list rows: a vendor is never invited, never RSVPs, never belongs to a party and never takes a chair. One row is one person
donations Wedding/shower gifts from guests (Registry page)
finance_settings Headcount (adults, minors, drinkers) and payment-plan config (single row)
finance_categories / finance_items / finance_subitems Budget sections, line items, and component parts
finance_payers Who pays, with share percentages
finance_purchases Spend log, linked to a payer and either a budget line or a whole section
finance_contributors / finance_receipts Gift-money pledges and the payments actually received, earmarkable to a line or section
finance_schedule Scheduled payments — deposits, instalments, final balances, with due dates
finance_snapshots One row per day of headline figures, for the trend chart
honeymoon_trip Trip title, optional start date, currency (single row)
honeymoon_regions Areas (Canggu, Ubud, Singapore…) with the guide's write-up
honeymoon_places The candidate library — coordinates, category, status, links
honeymoon_days / honeymoon_stops / honeymoon_travel Itinerary: numbered days, ordered stops, travel legs
honeymoon_journeys One ticket, however many legs. A leg with journey_id IS NULL is a journey of one, which is why this needed no migration
honeymoon_notes Know Before You Go cards (money, water, transport, culture)
honeymoon_todos The trip checklist — visas, jabs, insurance
honeymoon_categories Your own place categories, with their map colours and icons
honeymoon_bookings The paperwork behind "booked": confirmation, cost vs paid, deposit and cancellation dates, party size — one table for stays, excursions, flights and dinner tables
honeymoon_documents Passports, visas, insurance, tickets (files in the photos volume)
honeymoon_comments Short notes on a place, from one of you to the other
honeymoon_shares Read-only link tokens — revocable, expiring, and what the calendar feed authenticates with
honeymoon_views Named filter sets ("Ubud eats")
honeymoon_archives A whole trip frozen as JSON, for snapshots and for outliving the honeymoon
honeymoon_routes / honeymoon_weather / honeymoon_rates Fetch caches: OSRM driving times and geometry, Open-Meteo forecasts and normals, exchange rates
honeymoon_price_checks Price history for a shortlisted stay, recorded by the bookmarklet

One shape in the finance API is worth knowing about:

  • A PATCH whose body is a bare array reorders, in one transaction, with each row's index becoming its sort_order. A row may also carry category_id, and that is how a budget line dragged into another section moves: the reparenting and the ordering are a single write, so the line is never momentarily ordered against a section it does not belong to. Only honoured on tables that have the column. The arithmetic behind the drag — what order results from dropping one line on another — lives in src/lib/budgetOrder.ts, apart from the component, because the same answer drives both the optimistic redraw and the rows sent to the server; written twice, a drop could look like one thing and be stored as another.

Two columns on guest_list are worth knowing about:

  • guest_list.under_21 — the household themself; each companion carries their own under21 inside party_members, the same way attending does. It is not a dietary answer and changes no plate — an under-21 guest eats the adult dinner. It exists so a budget line set to Drinkers (21+) is charged for the people who can drink. The flag travels with the person, never by their index in the party: partyAttendees() skips anyone who declined, so an index lookup afterwards would hand one person's age to the next one along.
  • guest_list.kind — guest for everyone invited, couple for the two getting married. The couple are on the guest list so the seating chart can seat them and the kitchen can count their plates, but they were not invited to their own wedding, so every statistic, the mailing export and the RSVP responses list filter to kind = 'guest'. Rows written before the column carry NULL, which reads as guest — the absence of an answer means guest, never "unknown".

Three columns on those honeymoon tables are worth knowing about, because each one encodes a decision:

  • honeymoon_places.rank — where a stay sits in the shortlist's own ranking (1 is the favourite, NULL unranked). Deliberately not sort_order: that one orders the whole place library, and ranking hotels must not reshuffle it.
  • honeymoon_places.cost / cost_per — a real number and what it is per (night / person / total), beside the free-text price_note it does not replace. Only a number can be added up, and nights × rate is what makes the trip total arithmetic rather than a guess.
  • honeymoon_places.ratings — a rating per person ({"Austin":"yes"}) alongside the shared rating, so a disagreement is visible instead of being decided by whoever tapped last.
  • honeymoon_stops.duration_minutes — optional, and what turns a list of stops into a day that can be checked against the clock.
  • honeymoon_travel.depart_tz / arrive_tz — IANA zones. Times are stored as the local clock at each end, which is what the ticket says; without the zones a westbound flight reads as taking minus twenty minutes.
  • honeymoon_travel.depart_date / arrive_date are the input; day_id and arrive_day_offset are the output. A leg's day is derived from its dates by placementFor() and written in the same request, so it is never briefly filed on the wrong day. Everything that draws the trip — itinerary, calendar file, print sheet, map — still reads day_id/arrive_day_offset, so they stay; they are just no longer chosen by hand.
  • honeymoon_regions.boundary — a polygon, which is what lets "assign regions by location" be exact rather than nearest-centre. It is drawn with the map's lasso and saved from the lasso's own menu; the separate boundary tool was removed in v0.9.57, since it drew the same shape worse.
  • honeymoon_travel.arrive_day_offset — how many days after departure a leg lands (0 same day, 1 a red-eye). Relative to the leg's own day rather than an absolute date, so inserting or reordering days can't strand an arrival on the wrong one.
  • honeymoon_travel.from_lat/lng + to_lat/lng — nullable, because a leg is useful as "DPS → SIN, 14:05" long before anyone pins it. The map simply doesn't draw the legs it cannot place.
  • honeymoon_places.archived — removed from the shortlist without being destroyed. Hidden from the stays list, both maps, the dashboard count and the itinerary's Base picker; reachable only through the Removed bucket, which can restore it or delete it for good. A place you ruled out is still worth keeping: "why did we say no to that one?" comes back a fortnight later.

Auth

One admin password (ADMIN_PASSWORD), a JWT session in an HTTP-only cookie, and src/middleware.ts deciding who gets in. It covers two things:

  • /admin/* — no cookie, no admin panel; it also redirects non-admins away from pages marked work-in-progress.
  • /api/admin/* — the cookie is required for every method, GET included. Those endpoints hold names, addresses, phone numbers and dietary requirements, so reading them matters as much as writing them.

Three GETs are allowlisted as public, in a named set in that file: site-config, registry-items and timeline. They sit under /api/admin/ by an accident of naming but back the public pages — the nav and the RSVP form read the site config, the registry page reads the registry, our-story reads the timeline — and each returns content already rendered publicly. Adding to that list makes something world-readable.

New rules belong in the middleware rather than in the route handlers: one place covers every route, including the ones added next month. Until v0.9.35 the API half of this did not exist, and the entire admin API answered anonymous requests.

Demo mode

An instance is the demo when DEMO_MODE=true and the database in DATABASE_URL is named demo — both, checked in src/lib/demo.ts, which logs once and returns false when the second half fails. Every default fails towards "this is production".

Piece Where
The flag, server-side src/lib/demo.ts — isDemoMode(), demoStatus()
The flag, in the browser src/lib/demoClient.ts → GET /api/demo-status (cached per page load)
Dropping writes, and opening /admin src/middleware.ts
The banner src/components/DemoBanner.tsx
Room for the banner --demo-banner-h, set on :root in src/app/layout.tsx

Writes are blocked in the middleware, not in the 26 routes that write: one place covers every route including ones added later, so a new feature cannot forget to be immutable. That is also what makes running with no login safe. The matcher therefore covers /api/:path* as well as /admin/:path*; on a normal instance those API requests fall straight through.

The browser cannot be told the flag directly. It depends on DATABASE_URL, which is not shipped to the client, and a NEXT_PUBLIC_ mirror would be a second source of truth able to disagree with the real one — for a flag whose job is "is it safe to drop writes", not a trade worth making. Hence the tiny status endpoint.

The banner's height is stated once. It is --demo-banner-h (1.75rem in demo, 0px otherwise), and everything that measures from the top of the window reads it: the nav bar and its scrolled island, the mobile drawer, and the fixed admin shell. Nothing hard-codes 28px, so the banner cannot end up sitting on top of the nav again.

Tech Stack

  • Frontend: Next.js 16, React 19, TypeScript, Tailwind CSS
  • Backend: Next.js API Routes, Node.js 20
  • Database: PostgreSQL 15
  • Auth: Cookie-based with bcrypt
  • File Storage: Docker volumes, served via API route
  • Deployment: GitHub Actions builds the multi-stage image on every merge to main → GitHub Container Registry → Portainer pulls :latest

← All pages

Full-screen mode (hm-fullscreen)

The honeymoon map's ⤢ Full screen button hides the site nav and the admin sidebar. What it hides lives in ancestor trees — AppShell renders the nav, AdminShell the sidebar — while the button lives in HoneymoonShell far below both, so it is driven by a class on <html> rather than by React state. AppShell already toggles a root class this way for the scrollbar gutter.

  • The rules are four selectors at the end of src/app/globals.css, keyed on data-site-nav, data-admin-sidebar, data-admin-topbar, data-admin-frame and data-demo-banner. They sit outside every @layer, so they beat the Tailwind utilities they override without !important.
  • HoneymoonShell removes the class on unmount, on Escape, and when the route leaves the Map tab. Any of those failing would leave the whole admin panel without navigation and nothing on screen to explain why, so all three are there rather than only the one the button needs.

Clone this wiki locally