Commits
7a00c02
.forgejo
android
assets
migration
scripts
src
templates
tests
.dockerignore 150 B
.gitignore 24 B
build.rs 290 B
Cargo.lock 104.2 KiB
Cargo.toml 1.9 KiB
compose.yml 1.0 KiB
Dockerfile 3.0 KiB
Justfile 2.3 KiB
README.md 14.4 KiB

fieri-eternal

CI status install fieri-forever (oci) install org.neiam.fieri.app (fdroid) top language

Where are we going for lunch? Not there again.

People pin restaurants on an OpenStreetMap map and rank each one Obsessed (since a date), Always, Positive, To try, Neutral or Never. When a group goes out, the app suggests somewhere from what everyone going likes and where the team hasn’t been lately, and the group logs where it actually went so the next pick knows.

Restaurants are grouped into areas: the office, or a city someone is travelling to. Suggestions, history and the map all work within one area, and the planner can lay out several meals in a row (a week of lunches, or a trip) without repeating itself.

A revival of a decade-old work slackbot, as a web app: axum + htmx + minijinja, sea-orm on Postgres, and Argon2 session auth with invite-only registration. Maps, search and place details come from the self-hosted osm-tiles stack (deployment/argo-01-bos/osm-tiles).

Front-end is server-rendered HTML with Tailwind + DaisyUI; htmx handles boosted navigation and live partial updates — no SPA build step.

Generated from the neiam-htmx cargo-generate template.

Stack

Concern Choice
HTTP axum 0.8
Hypermedia htmx (hx-boost, polling), axum-htmx
Templating minijinja (embedded at build time)
Database Postgres via sea-orm (UUIDv7 keys)
Migrations sea-orm-migration (auto-run on boot)
Auth Argon2 password hashing, cookie sessions, invite tokens
Styling Tailwind CSS + DaisyUI + FontAwesome
Metrics Prometheus at /metrics
Maps Leaflet on tiles.neiam.org raster tiles, themed to match the page
Place lookup Photon (/geocode/) and the POI sidecar (/poi/), called server-side

Binaries

  • webservice — the HTTP server.
  • registration-token — CLI to mint/list invite tokens (bootstrap the first user).

Quick start (local dev)

Prereqs: Rust, just, Node.js (for the asset build), and Docker/Podman (for Postgres).

# 1. Build front-end assets (CSS/JS/webfonts)
just deps
just assets

# 2. Start Postgres
just infra

# 3. Run the web server (auto-runs migrations on boot)
just serve                     # -> http://localhost:4011

# 4. Mint an invite so you can register, then open the printed URL
just invite

Registration is invite-only by default. Set REGISTRATION_ENABLED=true to let anyone with a valid invite token self-serve, or keep it off and hand out links from the in-app Invites page.

Areas, members and friends

Areas are members-only: to anyone else an area, its restaurants, lunches and votes don’t exist (404). Each member has a role:

Role Can
voter see the area, rank, start/join/vote on lunches, log visits
editor also add, edit, archive and refresh restaurants
admin also rename the area and manage members and invitations

Whoever creates an area is its admin, and an area always keeps at least one. Admins invite people from the area’s Members page: an existing user gets a pending invitation on their home page to accept or decline; someone without an account gets a sign-up link from Invites, which can carry any areas the inviter administers (with a role each), joined on registration. The checks live in src/access.rs; every handler goes through them.

Friends are mutual (request, then accept). The “who’s going” picker lists you, then members who are your friends or have eaten with you in that area, most shared lunches first; everyone else in the area is folded away. Admins are offered their friends as quick invites. Restaurants show who contributed them.

The same OSM place can be in several areas (it’s unique per area), and area names only need to be unique within your own list, since other people’s areas are private.

The Android app and the API

android/ is a Compose app (see android/README.md): areas, ranking, “I went”, and lunch votes. It signs in either way:

  • username and password: POST /api/auth/login trades them for a token for that phone; or
  • QR: avatar menu → Devices → Make a pairing QR shows fieri://pair?base=<server>&token=<token> once, for the app to scan.

Both mint the same kind of API token (ff_…, stored only as a SHA-256), listed and revocable on the Devices page; the app’s sign-out revokes its own. The QR’s server address is PUBLIC_URL when set, else the proxy or Host headers.

/api/* takes Authorization: Bearer <token> and answers 401 rather than redirecting. Its endpoints (src/handlers/api.rs) return the same data as the web pages, built by the same functions and checked by the same permissions. The app’s tests decode real responses from it (android/app/src/test/resources/api/); refresh those fixtures when the JSON changes.

Deciding together: outings

“Start a vote” on an area’s planner opens an outing: a ballot of a few options (3 by default) for today. Share the link; people join, and vote for every option they’d happily go to (approval voting). The most-voted option is marked as leading, and anyone can lock one in with “Go here”, which logs the lunch for everyone going.

Re-roll keeps every option that has a vote and swaps the rest for places this outing hasn’t shown yet, re-drawn for whoever is going now. Only when an area runs out of fresh places does it reach back for ones rolled away earlier. If someone who joined after a draw ranked one of the options “never”, it’s flagged rather than silently swapped out.

Outing pages update live over SSE. Each change runs pg_notify('outing_changed', <id>); every replica LISTENs and passes the id to its open streams, and the page re-fetches its panel when it gets the event. Because it goes through Postgres, this works across any number of pods. A 30 s poll covers a dropped stream. If the app sits behind ingress-nginx, the X-Accel-Buffering: no header the stream sends stops events being buffered; long-lived connections may also need proxy-read-timeout raised past the 15 s keep-alive.

How suggestions work

All in src/suggest.rs, pure functions with unit tests. For the people going:

  • Veto. If anyone going ranked a place Never, it’s out.
  • Liked. The group’s average weight: Obsessed 6, Always 4, To try 3, Positive 2, Neutral 1. A “to try” counts as Neutral once that person has been on a logged lunch there (on or after the day they marked it), and the rank buttons ask how it was. Obsessed records when it started; picking it again keeps that date, and it can be backdated from the rank widget. Unranked counts as Neutral, so nobody has to rank everything.
  • Open. If OSM says a place is closed at the meal time (noon by default; the planner and votes take a time), it’s out. Unknown or unreadable hours count as open. Parsed with the opening-hours crate at naive local time, so trip areas in other time zones need no tz handling.
  • Freshness. 1 / (1 + 3 × load), where load sums every visit in the last 70 days, each decayed by a 10-day half-life. Yesterday’s spot keeps about a quarter of its weight; one from a month ago is mostly forgiven.

Score is liked × freshness, and the pick is a weighted random draw on score, so a favourite wins most days without winning every day. “Re-roll” draws again. A multi-meal plan counts each pick as a visit before scoring the next.

History is per team, not per person: if anyone logged a lunch at a place, it counts as recent for everyone.

Maps and place lookup

The browser loads tiles straight from TILES_URL. That server only serves referers on its allowlist (*.neiam.org, *.neiam.co, localhost), so deploy the app under one of those domains or the map will be blank 403s. The map’s tile theme follows the page’s daisyUI theme (assets/f/js/map.js).

Search and “what’s here?” are proxied through the app (/places/*), so they need no CORS and send no referer:

  • Search goes to Photon, restricted to eateries and ranked by distance from the area’s centre.
  • Clicking the map asks /poi/nearby first, which carries cuisine and website. If that fails (it has a 4 s timeout upstream and 504s when it trips), the app falls back to Photon reverse geocoding and skips POI for the next 5 minutes, so every click doesn’t wait 4 s for the timeout.

Every candidate with an OSM ref then gets its cuisine, website and address from its OSM tags, all in one batch: /poi/tags first (fast, even when /poi/nearby is timing out), then the public OSM API (OSM_API_URL) for anything the tile server’s import predates. Picking a candidate copies them into the form. Places OSM has no cuisine tag for stay blank to type in.

Restaurants added before hours were tracked can pick them up with Refresh from OSM on the restaurant page, or for every OSM-linked place at once under Manage on the area page. Refresh replaces hours (they change) but only fills cuisine, website and address where blank.

The search box also takes an OSM id: 29632970 (tries node, way and relation, keeping whichever is a named place), w29632970, way/29632970, or a pasted openstreetmap.org link. It’s looked up on the OSM API, placed at the centre of a way’s or relation’s outline, and shown however far away or whatever kind of place it is, since you asked for exactly that one.

Search results from an area are sorted nearest its centre first, and anything over 50 km away is dropped (Photon will otherwise offer a namesake on another continent).

Similar nearby, on a restaurant’s page, lists places with the same cuisine: those already in the area, nearest first, and OSM places within 1.5 or 5 km that aren’t yet, which editors can add in one click. It asks /poi/nearby for the cuisine; when that’s down, it searches Photon for the cuisine word and keeps only results whose tags really carry it, so places without the word in their name are missed until POI is back.

Cuisines (/cuisines, optionally ?area=) lists every cuisine tag with counts; each tag’s page shows every matching restaurant grouped by area, with your rank and whether it’s open at noon today. Cuisine tags link there everywhere they appear.

Anything OSM doesn’t know about can be added by dropping a pin and typing the name. Adding an OSM place that’s already here just records your rank on the existing one.

Configuration

All via environment variables (defaults suit local compose.yml):

Variable Default Purpose
DATABASE_URL postgres://app_user:app_password@localhost:5441/app Postgres DSN
BIND_ADDRESS 0.0.0.0:4011 Listen address
AUTO_MIGRATE true Run migrations on webservice boot
REGISTRATION_ENABLED false Allow public self-registration
SESSION_DURATION_HOURS 24 Session lifetime
REGISTRATION_TOKEN_DURATION_DAYS 7 Default invite lifetime
TILES_URL https://tiles.neiam.org Tile server base; themes are /{theme}/{z}/{x}/{y}.png
GEOCODE_URL https://tiles.neiam.org/geocode Photon; empty disables search
POI_URL https://tiles.neiam.org/poi POI sidecar; empty uses Photon only
OSM_API_URL https://api.openstreetmap.org Tags for places newer than the POI import; empty disables
DEFAULT_LAT / DEFAULT_LON Boston Where the “new area” map opens

Deploying

.forgejo/workflows/image.yml builds the image on every push to master and pushes ggr.neiam.co/neiam/fieri-forever:{master,latest,<sha>}; keel rolls the Deployment. The manifests live in the deployment repo under argo-01-bos/fieri-eternal/ (→ https://fe.neiam.org), with a first-deploy runbook in its README.

Cargo.lock is committed and the image builds --locked. The builder is rust:1.98: the opening-hours crate’s dependencies need newer than 1.90. On SIGTERM the server stops accepting, drains for up to 5 s, then exits — open live-vote streams don’t end by themselves, and browsers reconnect.

Copying dev data into prod

just db-export fieri.dump      # from the dev container; dev itself is untouched
just db-import fieri.dump      # into the cluster, with your kubectl context

The export leaves out sessions, device tokens and invites, plus the alice/bob/carol/dave test accounts and the “Boston office” test area (DROP_USERS / DROP_AREAS change that; "" keeps all). The import saves prod’s current data to prod-backup-*.dump first, asks you to type the namespace, stops the app, swaps the database in and starts the app again. A backup restores the same way: just db-import prod-backup-….dump.

Containers

just up      # build + run postgres, webservice
just logs    # tail the webservice
just invite-in-pod
just down

Layout

src/
  bin/            webservice, registration-token
  config.rs       env-driven configuration
  lib.rs          AppState + minijinja environment
  entities/       sea-orm models (auth, areas, restaurants, rankings, lunches, outings)
  auth/           password hashing, sessions, invite tokens, user ops
  middleware/     cookie-session auth layers
  handlers/       axum handlers (auth, areas, restaurants, places, lunch, outings)
  osm.rs          Photon + POI client
  live.rs         LISTEN/NOTIFY → SSE relay for outings
  suggest.rs      scoring and planning
migration/        sea-orm migrations
templates/        minijinja templates (excluded from generation templating)
assets/           Tailwind/esbuild sources (incl. the Leaflet glue in f/js/map.js) + vendored fonts
tests/            template render smoke tests

Regenerating entities after a schema change

sea-orm-cli generate entity -u "$DATABASE_URL" -o src/entities