eventa — Implementation Plan (Multi-User V1)
A sequenced, sized to-do list to build the multi-user MVP: Python backend with WebSockets + FCM, Android client that talks to it. Distilled from codex rounds 5 and 6.
Sizing: each task is half-a-day to 2 days. Order: dependency first. Phases end with a demo-able outcome.
The plan now has TWO tracks — backend and mobile — that have to be developed somewhat together. The phases below interleave them. By the end of phase 4 you have a single user editing real shared state on the server. By the end of phase 6 you have multi-user editing in real-time. Phase 7 layers push notifications on top.
Phase 1 — Backend skeleton
Outcome: FastAPI service runs locally, has a Postgres DB, has a migration tool, can serve
/health. Deployable to tiny1 via the existingdeploy/scaffold.
| # | Task | Done when… |
|---|---|---|
| 1.1 | Scaffold FastAPI + uvicorn project | App runs locally and serves /health 200. |
| 1.2 | Containerize Postgres for tiny1 | dedicated eventa-db postgres-16-alpine container modelled on the troostwijk-scraping pg block, named volume, 127.0.0.1 only. |
| 1.3 | Wire SQLAlchemy 2.x + Alembic | alembic upgrade head creates a tracked schema; migration #1 just creates users. |
| 1.4 | Pydantic settings + .env loader | DB DSN, postmark API key, FCM project ID etc. all read from env. |
| 1.5 | gunicorn + uvicorn-workers entrypoint | start.sh runs the prod-ish stack: gunicorn-uvicorn worker count = 2. |
| 1.6 | Deploy to tiny1 via existing scaffold | deploy/projects/eventa-api.json + tiny1 deploy eventa-api lands at api.eventa.heapzilla.eu, /health 200 over https. |
Phase 2 — Identity + auth
Outcome: a real user can request a magic link, click it, get a bearer token, hit
/me. No client yet — curl is enough.
| # | Task | Done when… |
|---|---|---|
| 2.1 | users schema + DAO |
migration adds users (id, email unique, display_name, last_seen_at, ...). |
| 2.2 | magic_links schema + creation API |
POST /auth/magic-link {email} inserts a token row, expires_at = +30min. |
| 2.3 | Resend integration | magic-link email sends successfully (test address); template branded as eventa. |
| 2.4 | POST /auth/verify exchange |
token → bearer (JWT or opaque, persisted in bearer_tokens); 410 if consumed/expired. |
| 2.5 | Bearer auth middleware | every protected endpoint validates bearer; injects current_user. |
| 2.6 | GET /me |
returns user + memberships shape. |
Phase 3 — Events + invites + memberships
Outcome: Alex can create an event, invite Gerda, Gerda can accept the invite and now both see the event in
/me.
| # | Task | Done when… |
|---|---|---|
| 3.1 | events + event_memberships schemas |
tables, FKs, version column on events, owner role on creation. |
| 3.2 | POST /events, GET /events, GET /events/{id} |
endpoints work end-to-end with bearer auth. |
| 3.3 | event_invites schema + POST /events/{id}/invites |
issues token, sends invite email via Resend. |
| 3.4 | POST /invites/{token}/accept |
resolves token, creates user if needed, joins as editor. |
| 3.5 | PATCH /events/{id} with version check |
returns 409 on stale version, 200 + new version on success. |
| 3.6 | DELETE /events/{id}/members/{uid} (owner only) |
removing a member revokes their bearer scope to that event. |
Phase 4 — Per-event resources
Outcome: All MVP resources (guests, households, tasks, budget items) have CRUD endpoints with version-checked PATCH. Still no mobile client.
| # | Task | Done when… |
|---|---|---|
| 4.1 | guests + households schemas + endpoints |
full CRUD with version and event_id membership check. |
| 4.2 | tasks schema + endpoints |
including POST /tasks/{tid}/complete toggle. |
| 4.3 | budget_items schema + endpoints |
full CRUD. |
| 4.4 | GET /events/{id}/timeline |
server-side bucketing into Overdue / This Week / This Month / Later. |
| 4.5 | GET /events/{id}/export.json |
full deterministic dump for the user-owns-their-data story. |
| 4.6 | change_log table + helper |
every write inserts a change_log row before commit (transactional). |
| 4.7 | GET /events/{id}/changes?since=… |
catch-up feed for clients reconnecting. |
Phase 5 — Android client skeleton + auth
Outcome: Android app installs, accepts an email, opens the magic link, talks to the API, lists the user's events.
| # | Task | Done when… |
|---|---|---|
| 5.1 | Scaffold Android project | Kotlin + Compose + Material 3, builds + runs via hpz-emu run. |
| 5.2 | Retrofit + OkHttp + serialization deps | EventaApi interface compiles; sample /health call works. |
| 5.3 | Magic-link UI + deep link handler | request screen, "check your email" screen, eventa://verify?token=… handler. |
| 5.4 | Bearer token storage | encrypted shared prefs; auto-attach to Retrofit via interceptor. |
| 5.5 | EventListScreen |
calls /me, displays events; tap → EventDashboardScreen. |
| 5.6 | Local Room cache | EventDao mirrors GET /events so offline read is possible. |
Phase 6 — Mobile MVP feature surfaces
Outcome: Same five MVP areas (Brief, Guests, Checklist, Timeline, Budget) now exist on Android, talking to the backend, persisting via the API. Two devices can EDIT — but they don't yet see each other's changes live (that's phase 7).
| # | Task | Done when… |
|---|---|---|
| 6.1 | Event Brief read + edit | calls GET/PATCH /events/{id}; surfaces 409 conflict path. |
| 6.2 | Guest list + add/edit guest | full CRUD via API, Room cache mirrors. |
| 6.3 | Contacts import | permission flow + picker → POST guests. |
| 6.4 | Checklist + add/edit task | full CRUD via API. |
| 6.5 | Timeline screen | renders /events/{id}/timeline. |
| 6.6 | Budget Buckets + add/edit | full CRUD via API. |
| 6.7 | Receipt photo upload | multipart upload to POST /budget-items/{bid}/receipts. |
| 6.8 | Local-cache write-through | every successful API write updates Room. |
| 6.9 | Optimistic UI + 409 retry helper | reusable hook: PATCH → on 409 refresh + retry once + then surface "someone changed this." |
Phase 7 — Real-time (WebSockets)
Outcome: Two phones in the same event see each other's edits within 1-2 seconds.
| # | Task | Done when… |
|---|---|---|
| 7.1 | FastAPI WebSocket endpoint | /api/v1/ws?bearer=… accepts auth, holds a connection. |
| 7.2 | Per-event rooms + subscribe protocol | {type:"subscribe", event_id} validates membership and joins the room. |
| 7.3 | Server-side change broadcast | every write under event_memberships fanouts a small {type, entity_type, entity_id, version} to all room members except the actor. |
| 7.4 | Android WebSocket client | OkHttp WS, lifecycle-bound, exponential-backoff reconnect. |
| 7.5 | since=… catch-up on reconnect |
client tracks last cursor; reconnect calls GET /changes?since to fill gaps. |
| 7.6 | UI auto-refresh on push | repository receives a small message → invalidates affected query → screens recompose. |
| 7.7 | Conflict UX polish | "Alex updated this 5s ago" inline hint when reconciling a stale local edit. |
Phase 8 — Push notifications (FCM)
Outcome: Even when both phones are sleeping, the second one gets a push within seconds of the first one editing.
| # | Task | Done when… |
|---|---|---|
| 8.1 | Firebase project + creds on backend | firebase-admin initialized; sends a test push to a known token. |
| 8.2 | POST /me/fcm endpoint |
client registers/refreshes its FCM token. |
| 8.3 | Server-side fanout helper | on writes, look up other-member tokens, send data-only FCM. |
| 8.4 | Android Firebase Messaging integration | client receives FCM; onMessageReceived triggers WS reconnect/refresh or shows a system notif. |
| 8.5 | POST_NOTIFICATIONS permission flow |
requested lazily after first invitation accept. |
| 8.6 | Notification kind→copy table | "Alex marked 'venue booked' done" / "Gerda invited you to Jane's wedding" / etc. |
| 8.7 | Acceptance test on two physical devices | edit on device A → device B sees push within ~3s. |
Phase 9 — Hardening, polish, release readiness
Outcome: the V1 MVP is testable end-to-end on real Android devices, doesn't accidentally violate any non-goal, and is safe to invite real beta users to.
| # | Task | Done when… |
|---|---|---|
| 9.1 | End-to-end smoke flow on 2 devices | invite, accept, brief, guest, RSVP, task, deadline, budget, receipt — verified across both. |
| 9.2 | Loading / empty / error polish | every screen has empty state, recoverable error UX, no infinite spinners. |
| 9.3 | Delete confirmations for destructive actions | events / guests / tasks / expenses / receipts / removing members. |
| 9.4 | Accessibility pass | content descriptions, scalable text, adequate touch targets. |
| 9.5 | Date / time / currency formatting pass | locale-aware rendering. |
| 9.6 | Backend rate limits + abuse protection | login throttle, invite throttle, basic per-bearer rate limit. |
| 9.7 | DB backup automation on tiny1 | nightly pg_dump to off-server storage. |
| 9.8 | Sentry-style error reporting (optional) | server + Android both report unhandled exceptions to a self-hosted (glitchtip) or hosted (sentry) endpoint. |
| 9.9 | Release-build signing + sideload APK | release variant signs, builds, installs on a real phone, MVP path verified end-to-end. |
| 9.10 | Final acceptance pass | all 5 MVP areas + invites + WS sync + FCM push + JSON export — no non-goal violations. |
Numbers
- 9 phases, ~62 tasks sized half-a-day to 2 days.
- Plain calendar estimate (one engineer, evenings + weekends): 10-16 weeks. The expensive new layers vs the codex round-5 single-user plan are websockets (phase 7) and FCM (phase 8).
- The first day a real second user is useful: end of phase 5 (login works, you can see your event list — but you're each editing alone).
- The first day you have a real product: end of phase 7 (two people editing the same event live).
- The first day it feels like a finished product: end of phase 8 + 9.
What this plan replaces
This plan supersedes the round-5 single-user plan. Specifically it adds:
- Phases 1-4 entirely (no backend before).
- Phase 7 (real-time).
- Phase 8 (push).
It drops:
- The original round-5 phase 6 (JSON export/import + Auto Backup as the
primary durability story) — backups are now
pg_dumpon the server + a per-event/export.json. Auto Backup of the Android cache is no longer load-bearing because the server owns the truth.