eventa — Technical Specification
Multi-user, real-time event-planning app. Android client + Python backend.
Distilled from a 6-round discussion with codex (transcripts in
docs/). Round 6 made the multi-user trade-offs explicit; the
user then forced a stronger position on real-time and push (no polling,
push is mandatory).
Architecture at 10,000 ft
┌────────────────┐ ┌──────────────────────┐
│ Android client │──HTTPS─▶│ FastAPI backend │
│ Kotlin+Compose │ │ (events, guests, │
│ Room (cache) │◀──WS────│ tasks, budget) │
│ FCM receiver │ │ │
└───────┬────────┘ │ PostgreSQL │
▲ │ (canonical state) │
│ └──────┬───────────────┘
│ FCM push │
┌───────┴──────────┐ │
│ Firebase Cloud │◀── HTTP ─────┘
│ Messaging │ (server
│ (Google) │ triggers
└──────────────────┘ fanout)
Source of truth: the backend's PostgreSQL. The Android client keeps a local Room cache for fast reads and offline viewing only — all writes go through the API.
Backend Stack
| Layer | Choice |
|---|---|
| Language | Python 3.12 |
| Framework | FastAPI (built-in async + WebSocket support, Pydantic models) |
| ORM | SQLAlchemy 2.x + Alembic migrations |
| DB | PostgreSQL 16 — dedicated eventa-db container on tiny1, modelled on the existing troostwijk-scraping pg setup (same image, named volume, loopback-only) |
| Real-time | WebSockets via FastAPI; one connection per client, per-event "rooms" |
| Push | Firebase Cloud Messaging (FCM) via firebase-admin SDK |
| Resend for magic-link + invite delivery (free 3 000 emails/month; clean API, minimal account setup) | |
| Auth | Passwordless magic-link → server-issued bearer token (JWT or opaque) |
| Hosting | tiny1 via the existing deploy/bin/tiny1 scaffold; gunicorn+uvicorn workers behind nginx |
Why this stack: - It slots into your existing tiny1 deploy: nginx + systemd unit + Let's Encrypt. No new operational layer to learn. - FastAPI gives WebSockets and async APIs first-class; perfect for the edit-others-see pattern. - PostgreSQL gives us proper FK constraints and version columns for optimistic concurrency control. - Postmark and FCM are vendor dependencies but neither stores user-readable data.
Android Client Stack
| Layer | Choice |
|---|---|
| Language | Kotlin 1.9+ |
| UI | Jetpack Compose + Material 3 |
| State | ViewModel + StateFlow |
| Navigation | Navigation-Compose with typed routes |
| HTTP | Retrofit + OkHttp (also gives us the WebSocket client) |
| Serialization | kotlinx-serialization (matches the backend's JSON shape) |
| Local cache | Room — no longer the source of truth, just a fast offline-readable mirror of the active event |
| Push | Firebase Messaging SDK — receives push, triggers WS reconnect / cache refresh |
| Auth storage | Encrypted shared preferences for the bearer token |
| Min/Target SDK | 24 / 34 |
Mobile architecture:
- Repository pattern. Each repository talks to the API + writes to Room
for cache.
- Writes are optimistic with version-stamping: client sends version
it last saw; server returns 409 on stale, client refetches and retries.
- WebSocket connection follows app lifecycle (open in onStart, close in
onStop), with auto-reconnect + exponential backoff.
- FCM token refresh registered server-side under the user account.
Data Model
PostgreSQL tables. version (int) is bumped on every UPDATE for
optimistic concurrency. created_by / updated_by (FK → users)
populated server-side from the authenticated bearer.
users
id, email (unique), display_name, created_at, last_seen_at,
fcm_tokens (jsonb array of {token, device, registered_at}).
magic_links
token (uuid), email, created_at, consumed_at?, expires_at.
events
id, title, start_date, end_date?, location_name?,
location_address?, event_type?, estimated_guest_count?,
rough_budget_cents?, notes?, version, created_at, updated_at,
created_by, updated_by.
event_memberships
event_id (FK), user_id (FK), role (owner | editor),
joined_at. Composite PK (event_id, user_id).
event_invites
token (uuid), event_id (FK), email, invited_by (FK → users),
role (editor only in V1), created_at, consumed_at?,
expires_at.
households
id, event_id (FK), name, primary_contact_name?, email?,
phone?, address?, notes?, version, updated_by, updated_at.
guests
id, event_id (FK), household_id? (FK), name, email?, phone?,
rsvp_status (pending | yes | no | maybe), dietary_notes?,
needs_lodging (bool), is_child (bool), version, updated_by,
updated_at.
tasks
id, event_id (FK), title, category?, assignee_user_id? (FK →
users) OR assignee_label? (text — for non-members like vendors),
due_at?, priority (low | med | high), done, done_at?,
done_by? (FK → users), version, updated_by, updated_at.
budget_items
id, event_id (FK), category, description,
estimated_cents, actual_cents?, paid_by_user_id? (FK) OR
paid_by_label?, paid_at?, version, updated_by, updated_at.
change_log
id, event_id (FK), entity_type, entity_id, action
(create|update|delete), actor_user_id (FK), at. Used for the
"someone changed this" hinting and for backend FCM fanout decisions.
No user-facing audit timeline UI in V1.
REST API
Versioned under /api/v1/. All endpoints except auth require a bearer
token. Bearer = magic-link verification result, ~30-day TTL.
Auth + identity
| Method | Path | Purpose |
|---|---|---|
| POST | /auth/magic-link |
{email} → emails a magic link, returns {ok:true} |
| POST | /auth/verify |
{token} → returns {bearer, user} |
| POST | /auth/logout |
revoke current bearer |
| GET | /me |
current user + memberships |
| POST | /me/fcm |
register/refresh FCM device token |
Events + memberships
| Method | Path | Purpose |
|---|---|---|
| GET | /events |
list events the user is a member of |
| POST | /events |
create event (caller becomes owner) |
| GET | /events/{id} |
full event payload (brief + counts + members) |
| PATCH | /events/{id} |
edit brief; requires version |
| POST | /events/{id}/invites |
issue an invite (sends email) |
| POST | /invites/{token}/accept |
accept invite, become editor |
| DELETE | /events/{id}/members/{userId} |
owner-only: remove a member |
Per-event resources
(All under /events/{id}/.... All PATCH/PUT require version.)
| Method | Path | Purpose |
|---|---|---|
| GET, POST | /guests |
list / create |
| GET, PATCH, DELETE | /guests/{gid} |
one |
| GET, POST | /households |
list / create |
| GET, PATCH, DELETE | /households/{hid} |
one |
| GET, POST | /tasks |
list / create |
| GET, PATCH, DELETE | /tasks/{tid} |
one |
| POST | /tasks/{tid}/complete |
toggle done |
| GET | /timeline |
tasks grouped by Overdue / This Week / This Month / Later |
| GET, POST | /budget-items |
list / create |
| GET, PATCH, DELETE | /budget-items/{bid} |
one |
| GET | /changes?since={cursor} |
catch-up feed of changes after cursor (used after WS reconnect or app launch) |
| GET | /export.json |
full event JSON dump (member only) |
Real-time (WebSockets)
wss://eventa.heapzilla.eu/api/v1/ws?bearer=…
After connect:
- Server reads bearer, looks up user.
- Client sends {type:"subscribe", event_id} for each event it's
watching.
- Server validates membership, joins client to the per-event "room."
Server emits {type, event_id, entity_type, entity_id, version, at, by}
on any change. Payload is intentionally small — clients fetch the full
record via REST when they receive a notification of interest.
If a client misses messages (disconnect window), it reconnects and calls
GET /events/{id}/changes?since=<last cursor> to catch up.
Push notifications (FCM)
Server-side, on a write that another member should know about:
- Resolve the event's other members (excluding the actor).
- For each member, look up their FCM tokens.
- Send a data-only FCM push:
{event_id, kind: "task_added"|"rsvp_changed"|…}.
Android client:
- On receive: trigger a WS reconnect / refresh if the app is alive,
otherwise show a system notification ("Alex marked 'venue booked'
done").
- Permission flow: request POST_NOTIFICATIONS lazily.
Notification preferences are V2. V1 sends one type of push per change class; users can mute the whole app via OS settings if they hate it.
V1 Cuts (codex round 6 + user override)
| Concern | Decision |
|---|---|
| Offline editing | CUT-V1 — must be online to write. Cached read OK. |
| Web client | CUT-V1 — Android only; tiny landing page for invite acceptance is fine. |
| Polling fallback | CUT-V1 — websockets only. (User: "polling is stupid.") |
| Viewer role / per-field permissions | CUT-V1 — owner + editor only. |
| OAuth / passwords | CUT-V1 — magic-link only. |
| OT / CRDT conflict merging | CUT-V1 — last-write-wins with version checks. |
| User-facing audit timeline | CUT-V1 — store metadata, no UI. |
| SMS | CUT-V1 — email only. |
| Notification preferences | CUT-V1 — one push per change class, OS handles muting. |
Hard non-goals
- Not a competitor to Eventbrite, Partiful, or Lu.ma. Personal scale.
- Not multi-tenant SaaS. Each event is private to its members.
- Not public RSVP links. RSVP managed by event members.
- Not a project-management tool — categories are simple labels.
- Not a payment platform —
paid_byreferences a member or a free-text label. - Not a tablet / wear / TV variant in V1. Phone portrait first.