Checked each against the implementation and fixed the doc, never the code: - FEATURES claimed the ban modal offers a choice about hiding existing uploads. There is no such choice — a ban always hides. USER_JOURNEYS §9 was already right. - FEATURES claimed hosts may demote other hosts. It is admin-only, enforced in the backend, and the two documents contradicted each other on it. - FEATURES showed the quota widget as guest-facing; it is deliberately staff-only. - The first-visit tour has six steps, not four. - USER_JOURNEYS §12.7 said export downloads are rate-limited per IP. They are per USER — a materially different thing at a shared-NAT venue, where per-IP would have locked out the fourth guest to fetch their keepsake. - §15 still described "Event verlassen"; that button is now Abmelden / Auf allen Geräten abmelden. - §4, §13, §14, §16 and §18 were marked "(planned)" and have shipped. - The lightbox row now describes what exists after this branch: prev/next controls, arrow keys and swipe. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
22 KiB
EventSnap — User Journeys
This document walks through every supported user scenario step-by-step. For a quick "who can do what" overview, see FEATURES.md. For manual QA, see TEST_GUIDE.md.
1. First-time guest (the happy path)
- Guest scans the QR code / opens the event link.
- Lands on the join page (
/join), sees the event name. A small "Ich habe bereits einen Account" link is visible below the form for returning users — it routes to/recover. - Types display name → taps Beitreten.
- Server creates the account, generates a 4-digit PIN, stores
bcrypt(PIN), signs a 30-day JWT. - A PIN modal appears: large monospace digits, a Kopieren button, a warning that
this PIN is the only way to sign in on another device. PIN is also written to
localStorage. - Guest taps Weiter zur Galerie → lands in the feed (
/feed). - The first-visit onboarding overlay appears: six dismissible steps (welcome, upload,
hashtags, long-press for more actions, hell/dunkel design pick, and PIN — the PIN step
also points at the Datenschutzhinweis in My Account when the admin has set one).
localStorage('eventsnap_guide_seen') = 'true'after dismiss. - Guest sees the bottom nav: 🏠 Feed · [📷+ FAB] · 👤 Account.
2. Returning guest, same device
- App finds a valid JWT in
localStorage. - Redirected straight to
/feed, no input required.
3. Returning guest, new device or cleared storage
- Guest opens the event link on the new device → join page.
- Types the same name they used before.
- Server detects the existing account → the join page transforms into a recovery prompt: "„Name" ist bereits vergeben" with a PIN input and an Anmelden button, plus an Anderen Namen wählen escape hatch.
- Guest types their PIN →
bcrypt.verifysucceeds → new JWT issued for the existinguser_id. PIN is written tolocalStorageon this device too. - Wrong PIN: up to 3 attempts. After the third, the account is locked for 15 minutes
(
pin_locked_untilis set; further attempts return HTTP 429 with a localized message).
4. PIN forgotten — Host or Admin resets it
The PIN is visible in My Account as long as localStorage is intact on at least one
of the user's devices. If lost everywhere, the user asks a Host (or Admin) for a reset.
- Guest approaches the Host: "I can't sign in on my new phone."
- Host opens the Host Dashboard → Nutzerverwaltung and finds the user.
- Host taps PIN zurücksetzen on that row.
- A confirmation prompt explains what happens; on confirm the server generates a fresh
4-digit PIN, replaces
recovery_pin_hashwith the new bcrypt, clears any activepin_locked_until, and returns the new plaintext PIN in the response. - A modal shows the new PIN ONCE — large, with a copy button. The Host shows the screen to the guest or sends it via another channel (SMS, slip of paper, …). Closing the modal forgets the plaintext on the operator's device too.
- Guest goes to
/recover(or taps "Ich habe bereits einen Account" on/join), enters their name + the new PIN, signs in, and the PIN is persisted tolocalStorageon their device — exactly like a fresh join.
Permission rules:
- Host can reset PINs for guests only.
- Admin can reset PINs for hosts and guests (not other admins; admins use the password login).
- Anyone whose PIN was reset retains all their uploads, comments, and likes — only the PIN changes.
If no Host or Admin is reachable, the guest can still re-join under a new name (a clean account; their previous uploads remain attributed to the abandoned account, which the Host can clean up later).
5. Posting a photo / video
- Guest taps the central 📷+ FAB in the bottom nav.
- A bottom sheet slides up offering Kamera (in-app capture) or Galerie (file
picker, multi-select).
3a. Camera path — CameraCapture
opens the back camera (
facingMode: 'environment'), with toggle for front camera, photo button, and a video-record button usingMediaRecorder. 3b. Gallery path — native picker, multiple selection. - Preview screen (
/upload) shows staged files as horizontal thumbnails. The user can:- Remove individual files.
- Type a caption with
#hashtags. - Tap quick-tag chips (derived from the caption) to copy a hashtag into the caption.
- Taps Hochladen → returns immediately to the feed (optimistic UX). The slim progress bar above the bottom nav and the red badge on the FAB indicate active uploads.
- The client uploads files one at a time (XHR with progress) from an IndexedDB queue.
- Each upload triggers a server-side compression job; once the preview is ready the feed
updates via
upload-processedSSE — placeholders swap for actual previews.
6. Posting under rate limits
- Hit the per-hour upload limit (default 10 / hour, configurable).
- Server returns HTTP 429 with a
Retry-Afterheader on the next upload attempt. - Client parks pending items in Wartend state and shows an amber banner: "Upload-Limit erreicht. Wird in Xs automatisch fortgesetzt."
- Countdown ticks down. When it reaches 0, the queue resumes automatically.
7. Liking and commenting
- Tap the heart icon on a card or in the lightbox → like is recorded; count increments
optimistically; server returns the canonical count via
like-updateSSE. - Tap the comment icon → opens the lightbox with the comments list.
- Type a comment →
POST /api/v1/upload/{id}/comment. Hashtags inside the comment are parsed and attached. - The user can delete their own comments (trash icon next to them).
8. Filtering the gallery
- Toggle to grid view (icon top-right of the feed header).
- A search bar appears below the header (auto-focused).
- Type a name or
#hashtag— autocomplete suggestions are derived in memory from the loaded uploads. - Tap a suggestion → it becomes an active filter chip and the search bar clears.
- Filter logic:
- Multiple hashtag chips: OR
- Multiple uploader chips: OR
- One uploader + one hashtag: AND
- Open a post → swipe in the lightbox navigates the filtered set, not the full feed.
9. Hosting the event — moderation
- Host opens My Account → taps ⭐ Host-Dashboard.
- Stats section — guest count, upload count, lock status, release status.
- Event settings — toggle to lock new uploads (likes / comments / browsing stay open;
broadcasts
event-closedSSE so all clients show a "uploads are locked" banner). - Galerie freigeben — releases the export. Enqueues two export jobs (ZIP + HTML
viewer). Progress is visible on the Host dashboard and in the Admin dashboard's Export
tab; SSE
export-progresskeeps it live;export-availablenotifies all guests when ready. Once released the button is disabled and reads "Galerie bereits freigegeben" — a second release is a 409. If a keepsake half fails, both dashboards show the reason and offer Erneut versuchen (rebuild); a ready keepsake can also be rebuilt behind a confirm, during which guest downloads are briefly unavailable. - Nutzerverwaltung — search users; per-user controls:
- Sperren opens a confirmation modal. Banning always hides the user's existing
uploads (a banned user's content is "gone" everywhere) — there is no opt-out. Submitting
calls
POST /host/users/{id}/ban(no body). - Entsperren lifts the ban — and does two further things the confirm sheet now spells
out, because both are visible to the whole party: it clears
uploads_hidden, so all of that user's previously hidden uploads reappear in the gallery, the diashow and the export; and it invalidates and rebuilds a released keepsake (invalidate_and_arm), so every guest's download is unavailable for as long as that takes. A ban does the mirror image of both. Same authority boundary as ban: a plain Host may only unban Guests; only an Admin may unban a Host. - Host promotes a guest to host (Hosts and Admins may do this).
- Degradieren — demote a Host back to guest. Only an Admin may change a Host's
role. A plain Host may not demote a peer Host: doing so would let them then ban or
PIN-reset (→
/recoveraccount-takeover) that ex-peer, since those guards key off the target's current role. So the backend rejects it (403) and the button is hidden for non-admin Hosts. Nobody may change their own role (self-lockout / self-escalation guard), and Admins are un-demotable and un-bannable by anyone. (This tightens an earlier "Hosts may demote other Hosts" design — see the F1 security fix.) - Sperren / PIN zurücksetzen — a plain Host may act on Guests only; an Admin may act on Guests + Hosts; nobody may act on an Admin. The buttons are hidden where they'd 403. PIN reset generates a new PIN, shows it once in a modal, and revokes the target's existing sessions (forcing re-auth with the new PIN). See journey §4.
- Sperren opens a confirmation modal. Banning always hides the user's existing
uploads (a banned user's content is "gone" everywhere) — there is no opt-out. Submitting
calls
- Deleting content — Host can delete any upload or comment via the moderation routes
(
DELETE /host/upload/{id},DELETE /host/comment/{id}). On mobile this is also reachable by long-pressing the content (planned, see §15).
10. Banned-guest experience
- The ban takes effect immediately on the banned user's existing session — the auth
layer re-reads the live
is_bannedflag on every request rather than trusting the JWT, so there's no 30-day token-lifetime window. - Their next authenticated write request returns HTTP 403 with a clear message ("Du bist gesperrt."). Ban enforcement lives on the write handlers and the host/admin extractors, not the base auth extractor.
- They can still browse the read-only feed (and download the export once it's released).
Their sessions are not revoked — this is a read-only ban, not a logout. Their own
live SSE stream is dropped by the
is_bannedrevalidation (no live pushes), but plain feed reads still work. - They cannot upload, like, or comment; a banned host also loses all host/admin actions (including unbanning themselves).
- Banning always hides: the user's existing uploads are filtered out of the feed for
everyone (
v_feed,find_visible_media, and the export query all enforceis_banned = FALSE), and a liveuser-hiddenSSE event evicts their cards from every open feed + the diashow without a reload. A client that was offline/disconnected during the live event doesn't miss the eviction:uploads_hidden_atis stamped at ban time (migration 013) and the reconnect delta (GET /feed/delta) returns the banned users inhidden_user_ids, so the feed and diashow replay the eviction on the next reconnect — the projector-missed-the-live-push case is exactly why this exists.
11. Admin — instance configuration
- Admin opens
/admin/login, types the admin password (compared againstADMIN_PASSWORD_HASH). Receives a separate 1-day admin JWT (insessionStorage). - Admin dashboard has four inner tabs:
- Stats: live counts and disk-usage widget (via
sysinfo). - Config: per-file limits (image MB / video MB), rate limits (upload / feed /
export), quota tolerance, estimated guest count, compression-worker concurrency,
plus the colour-theme picker, the Datenschutzhinweis free-text editor and
on/off toggles for the rate limiters and quotas (see §18). Whitelist on the
server side rejects unknown keys. Values are read from the
configtable on each request — no restart needed. - Export: the gallery-release control plus the list of export jobs with status badges (pending / running / done / failed) and progress bars; refresh button re-polls. The release control mirrors the Host dashboard exactly — disabled once released (a second tap would only 409), live keepsake status with progress and the failure reason, and a Neu erstellen / Erneut versuchen rebuild as the recovery path for a failed or stale keepsake.
- Nutzer: same user list as Host, with the additional Demote action (admin-only, see §9) and PIN-reset on host rows.
- Stats: live counts and disk-usage widget (via
12. Releasing the export and downloading
-
Host (or Admin) taps Galerie freigeben in the dashboard.
-
Server sets
event.export_released_at, locks uploads, bumpsevent.export_epoch, and enqueues two background jobs — all in ONE transaction (workers spawn after it commits, so a client disconnecting mid-request can never leave the event released with no export to build).The epoch is the whole generation model (migration 014). It is bumped in the same UPDATE as any change to
export_released_at— release and reopen are its only writers — and eachexport_jobrow carries a copy of the epoch it was enqueued for. An export is downloadable iffreleased AND job.epoch = event.export_epoch AND job.status = 'done'. Readiness is therefore derived, never stored: it cannot drift, and a worker whose epoch has been retired (by a reopen, a re-release, or a takedown) is inert — anything it writes is simply invisible. A reopen retires the current keepsake instantly, which is why a reopened event serves no export until the host releases again. -
ZIP job: streams
Gallery.zip(Photos/+Videos/, full-quality originals) directly to disk viaasync-zip. Progress updates viaexport-progressSSE. -
HTML-viewer job: copies the pre-built viewer assets from backend/static/export-viewer/ (embedded via
include_dir!), generatesdata.jsonfrom the database, processes_thumb/_fullvariants for each upload, and assemblesMemories.zip. -
Both jobs complete → server broadcasts
export-availableSSE. Takedowns: if a host deletes an upload (or a comment) while the gallery is released, the epoch is bumped and the keepsake is REGENERATED without it. Otherwise a photo removed on request would live on forever in the already-generated archive — the one place it most needs to be gone. The download 404s for the few seconds it takes to rebuild, which is the correct answer: serving the old archive would serve the deleted photo. -
Any user opens
/export:- Before release: friendly "Export not yet available" banner.
- During generation: progress bars per artifact.
- After completion: two cards (ZIP-Archiv and HTML-Viewer) with download
buttons. Tapping the HTML download first shows an in-app guide modal explaining:
"Entpacke die ZIP, öffne
index.html". Tapping Herunterladen triggers the browser download.
-
Downloads are rate-limited per user (default 3 / day), keyed on the user id in
enforce_export_rate— deliberately not per IP. At a venue where a hundred guests share one NAT'd wifi, a per-IP budget would be exhausted by the third person to tap Herunterladen and lock everyone else out of their own keepsake.
13. Diashow
See CONCEPT_DIASHOW.md. Summary of the flow:
- User taps a Diashow / Präsentation action (feed header on tablet/desktop, Account on mobile).
- Navigates to
/diashow— fullscreen, bottom nav hidden, screen wake-lock acquired. - Initial pool fetched from
GET /api/v1/feed. Slides crossfade every ~6 s. - New uploads (
upload-processedSSE) push to a live queue; the next slide transition pops from the live queue first, otherwise from a shuffled queue. upload-deletedremoves that ID from both queues; if it's the current slide, advance immediately.- Tap or Escape reveals an overlay (pause, dwell selector, exit).
14. Picking a data mode
- Guest opens My Account → scrolls to Datennutzung.
- Two options: Datensparer (empfohlen) and Original. Saver is the default.
- Selecting Original shows a one-time warning bottom-sheet: "Original-Dateien werden geladen — das kann deine mobile Datennutzung deutlich erhöhen. Trotzdem aktivieren?" with Abbrechen / Aktivieren buttons.
- Choice persists in
localStorage(per-device). The feed, lightbox, and diashow all read this flag and load originals instead of compressed previews when Original is on. - The viewer (offline HTML export) is unaffected — it already ships with its own pre-
bundled
_thumb/_fullvariants.
15. Signing out
The wording matters here: nothing is left and nothing is deleted. The account, the PIN and every uploaded photo survive — the user signs back in with name + PIN whenever they like. ("Event verlassen" read as leaving for good, which the confirmation sheet then contradicted by saying you can come back.)
- User opens My Account → Konto section. Two separate actions:
- Abmelden — this device only (
DELETE /api/v1/session). - Auf allen Geräten abmelden — every session of theirs (
DELETE /api/v1/sessions), for a lost or borrowed phone.
- Abmelden — this device only (
- Bottom-sheet confirmation naming which of the two it is, with Abmelden / Abbrechen.
- Confirming invalidates the session row(s), wipes the local JWT, PIN and the IndexedDB upload queue (so the next guest on a shared phone doesn't inherit pending uploads), and redirects to the join page. The session-delete call is best-effort: the token is gone locally either way, so a network failure never traps the user on the page.
16. Reading the Datenschutzhinweis
- User opens My Account → scrolls to Datenschutzhinweis.
- The note is rendered inside a preformatted block (
<pre>-style: monospace, whitespace and newlines preserved exactly as the Admin typed them). No HTML, no markdown — the admin's plain text is shown verbatim. - The first-visit onboarding overlay carries a one-line reminder of where to find this: "Datenschutzhinweis findest du in deinem Account."
- Admin sets / edits the note in Admin Dashboard → Config → Datenschutzhinweis: a
tall textarea with a save button. Saved to a single
configkey.
17. Mobile-first gestures (partly shipped)
EventSnap's UI is mobile-first; gestures replace explicit buttons where they're more ergonomic. Buttons are always present as fallback for desktop and accessibility.
Shipped today: long-press context sheets on posts and comments, and lightbox navigation (on-screen prev/next controls, ← / → arrow keys, and left/right swipe). The remaining rows below are still planned.
| Gesture | Action |
|---|---|
| Long-press on a post (own) | Bottom sheet → Löschen, Original anzeigen, Teilen |
| Long-press on a post (other) | Bottom sheet → Original anzeigen, Teilen, Melden (planned) |
| Long-press on a comment (own) | Bottom sheet → Löschen |
| Long-press on a comment (other) | Bottom sheet → Kopieren |
| Long-press on a user row (Host) | Bottom sheet → Sperren, Promote/Demote, PIN zurücksetzen |
| Swipe left/right in the lightbox | Navigate the filtered set |
| Swipe down on any bottom sheet | Dismiss |
| Pull-to-refresh on the feed | Force a delta-fetch |
| Double-tap on a post | Like (heart-burst animation) |
On desktop the same actions surface as kebab/⋯ menus, click-able icons in card corners, and keyboard shortcuts in the lightbox (← → for navigate, Esc to close).
Inspiration: Instagram (double-tap heart, swipe stories), WhatsApp (long-press for context), Telegram (swipe-to-reply on messages — could inform comment threads if those land).
18. Admin toggles a rate limit or quota off
- Admin opens Admin Dashboard → Config.
- Rate-Limits section: a master switch and per-endpoint switches (upload / feed / export / join).
- Admin flips, e.g., Upload-Limit aktiv off. The numeric input for "uploads per hour" stays visible but greyed out (still editable for when the toggle goes back on).
- Speichern persists to the
configtable. The next upload request bypasses the limiter entirely. - Quoten section mirrors the pattern: master toggle plus per-area toggles (storage bytes / upload count).
- When the storage-quota toggle is off, the "Speicher: X / Y" widget in My Account and on the upload screen hides itself (no quota → no number to show). That widget is staff-only in any case — guests never see it.
Suggested defaults at deploy time: all toggles on, sensible numeric limits. Toggling off is the explicit escape hatch for testing or trusted internal events.
Edge cases worth knowing
| Case | Behaviour |
|---|---|
| Browser tab backgrounded for > 5 min | SSE closes on visibilitychange: hidden; reopens on visible |
Upload finishes while user is on /account |
Feed updates anyway — the queue + SSE are global stores |
| Event "closed" while files are still in the queue | Server rejects with a friendly error; client surfaces it in the queue UI |
| Network drops mid-upload | Queue retries the file; retry button available on permanent failure |
| New device but the PIN was lost | Either re-join under a new name, or Host manually re-links (no self-service) |
| Two guests pick the same name | Second one is offered the PIN-recovery form (case-insensitive UNIQUE, mig. 007) |
| Compression fails for a file | Server emits upload-error SSE; the upload is still listed but marked degraded |
| User deletes their own post (once UI is shipped) | Soft delete (deleted_at); SSE upload-deleted; vanishes from feed everywhere |