Scaffold the Go app: auth, migrations, bundle import

Bring up the stage 1 skeleton described in the PRD, enough that the app
builds, serves, and can be populated with dishes.

- net/http server with shared-password Basic auth, /healthz outside it,
  graceful shutdown, and TZ-aware calendar days
- SQLite via modernc (pure Go, static binary), opened with WAL and a single
  connection
- migration runner: numbered SQL files embedded and applied once each inside
  a transaction, recorded in schema_migrations
- bundle import (PRD §7.3) as a live feature on the Ruoat tab: paste JSON or
  upload a file, get a per-row Finnish report. The same importer is reachable
  as `foodster -import` for repopulating a scratch database
- templ views and hand-written CSS with light-dark() theming; the Datastar
  v1.0.3 client is vendored, since the Go SDK ships no browser asset and a
  CDN would break an offline LAN

Names are normalized to sentence case rather than title case: Finnish
capitalizes only the first word of a phrase, so "Keitetyt perunat" is right
and "Keitetyt Perunat" is not. PRD §6 and §7.3 are amended to match.

Testing is behind make targets rather than ad-hoc commands: `make check` runs
lint, unit tests and scripts/smoke.sh, which exercises auth, static assets
and every import path against a scratch database on a spare port.
This commit is contained in:
Esa Kataja
2026-09-05 18:15:17 +03:00
parent 6ed047aafd
commit c9a63bdd9e
17 changed files with 1503 additions and 12 deletions
+29 -10
View File
@@ -84,7 +84,11 @@ in English.
### Main dish (stage 1)
- `id`
- `name` — stored in Title Case (server normalizes on save).
- `name` — stored in sentence case: the server collapses whitespace and
capitalizes the first letter only, leaving the rest as typed. Finnish
capitalizes just the first word of a phrase, so "Keitetyt perunat" is
correct and "Keitetyt Perunat" is not. Existing capitals are preserved, so
"BBQ-kylkeä" and "Kotipizza" survive.
- `categories` — non-empty set drawn from `meat`, `chicken`, `fish`,
`vegetarian`. Most mains will have a single category. Dishes where each
diner builds their own plate from a shared spread (e.g., tortillas,
@@ -101,7 +105,7 @@ in English.
### Side dish (stage 1)
- `id`
- `name` — stored in Title Case.
- `name` — stored in sentence case, as for mains.
- `created_at`
- `deleted_at` — nullable. Same soft-delete semantics as mains.
@@ -168,8 +172,8 @@ regenerates or swaps.
### 7.3 Admin (stage 1 — catalog; stage 2 — settings)
- Add / edit / delete main dishes (with categories and `has_sides`). Names
are normalized to Title Case on save. Delete is a soft-delete.
- Add / edit / delete side dishes. Same Title Case normalization and
are normalized to sentence case on save. Delete is a soft-delete.
- Add / edit / delete side dishes. Same sentence-case normalization and
soft-delete behavior.
- Duplicate detection on create/edit is **case-insensitive** against the
set of non-soft-deleted items ("chicken curry" and "Chicken Curry" are
@@ -191,8 +195,8 @@ regenerates or swaps.
```
- Import is **best-effort, not atomic**: valid rows are inserted, invalid
rows (bad categories, missing fields, duplicates) are skipped and
reported back to the user in a per-row summary. Names are Title-Cased
on import; duplicate detection is case-insensitive.
reported back to the user in a per-row summary. Names are normalized to
sentence case on import; duplicate detection is case-insensitive.
- Settings page for the cooldown window (default **14 days**, configurable).
*(Stage 2 — only relevant once the suggester exists.)*
@@ -290,9 +294,20 @@ build and no asset bundler.
volume; there is no separate database service. A single household writing
one row per day does not need Postgres.
- **Data access**: `database/sql` and hand-written SQL. No ORM.
- **Schema**: a single `schema.sql` embedded with `embed.FS` and applied on
startup with `CREATE TABLE IF NOT EXISTS`. A migration tool arrives the
first time a live table genuinely needs altering, not before.
- **Migrations**: numbered `.sql` files under `cmd/foodster/migrations`,
embedded with `embed.FS` and applied in filename order on startup. Each one
runs inside a transaction and is recorded in a `schema_migrations` table, so
it applies exactly once. No migration library — the runner is about sixty
lines of `database/sql`. There are no down-migrations: restoring the
database file is the rollback for a single-household app.
- **Bundle import**: the §7.3 mass import is a live feature of the running
app, on the Ruoat tab — paste JSON or upload a file, get a per-row report
back. A plain multipart form rather than a Datastar round trip, since the
response is a whole-page report and a form needs no client code. Uploads
are capped at 1 MiB. The same importer is also reachable as
`foodster -import <file.json>` for repopulating a scratch database without
starting the server; `seeds/testi.json` is the committed fixture. One code
path serves both, so the format is exercised twice.
- **Time**: the `TZ` environment variable, defaulting to `Europe/Helsinki`,
loaded via `time.LoadLocation` and fatal on a bad value — a silent fallback
to UTC would shift logged dinners to the wrong calendar day. `time/tzdata`
@@ -342,7 +357,11 @@ on the server and run with Docker Compose.
has no shell to run one and `restart: unless-stopped` already covers a dead
process. Adding one would mean giving the binary a `-healthcheck` flag that
calls its own endpoint.
- Schema is applied on app start.
- Pending migrations are applied on app start.
- The Datastar client is vendored at `cmd/foodster/static/datastar.js` and
served from the app's own origin — the SDK ships no browser asset, and a
CDN link would break an offline LAN. `make vendor` refreshes it; the pinned
version lives in the `Makefile` and in the file's first line.
- No internet exposure; the server binds to the LAN.
## 11. Licensing