Repository navigation
Architecture
Where data lives, the database tables, and the stack it is built on.
| 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.
| 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
PATCHwhose body is a bare array reorders, in one transaction, with each row's index becoming itssort_order. A row may also carrycategory_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 insrc/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 ownunder21insideparty_members, the same wayattendingdoes. 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—guestfor everyone invited,couplefor 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 tokind = 'guest'. Rows written before the column carryNULL, which reads asguest— 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,NULLunranked). Deliberately notsort_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-textprice_noteit 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 sharedrating, 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_dateare the input;day_idandarrive_day_offsetare the output. A leg's day is derived from its dates byplacementFor()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 readsday_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.
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.
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.
- 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
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 ondata-site-nav,data-admin-sidebar,data-admin-topbar,data-admin-frameanddata-demo-banner. They sit outside every@layer, so they beat the Tailwind utilities they override without!important. -
HoneymoonShellremoves 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.
Setting it up
Using the admin panel
- Guests and RSVPs
- Seating and the Export
- Registry
- Finances
- Honeymoon Portal
- Content and Settings
- Offline
How it works