← eventa overview

eventa — Technical Specification

Python + FastAPI + PostgreSQL + WebSockets + FCM backend; Kotlin + Compose + Room cache client.

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
Email 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}).

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:

  1. Resolve the event's other members (excluding the actor).
  2. For each member, look up their FCM tokens.
  3. 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