Files
EventSnap/docs/USER_JOURNEYS.md
MechaCat02 641174717c
Some checks failed
E2E / Playwright E2E (chromium-desktop) (push) Failing after 6m52s
E2E / Cross-UA smoke matrix (push) Failing after 3m50s
fix(user-flow): close offline-queue, export, session, ban & realtime gaps
Comprehensive user-flow review across guest/host/admin roles, then fixes with
e2e regression guards. Highlights:

- Offline upload queue auto-resumes on reconnect: network errors keep items
  pending (not error), 4xx are terminal (no infinite retry), quota exhaustion
  returns a distinct 413; queue cap + dedup.
- Export lifecycle: release atomically locks uploads; reopen invalidates and
  re-release regenerates the keepsake; workers claim their job atomically so a
  reopen->re-release can't corrupt the ZIP; startup re-spawns interrupted
  exports.
- Sessions slide on activity (no 30-day cliff); JWT expiry deferred to the
  revocable session row; sign-out-everywhere + revoke-on-PIN-reset.
- Ban always hides content (v_feed / find_visible_media / export filter
  is_banned) but stays a read-only ban per USER_JOURNEYS §10 — sessions are
  not revoked, read access + keepsake download preserved.
- Realtime: server-clock SSE delta cursor; event-closed/opened drive the UI
  live; feed_delta rate-limited; like returns {liked, like_count} to fix
  multi-device drift; lightbox live comments; diashow delta backfill.
- Forgotten-PIN in-app request flow; simultaneous same-name join returns 409;
  quota increment is transactional; operator floor.

Adds e2e/specs/10-flow-review/ (offline resume, export integrity, deterministic
anti-race guard) and updates existing specs for the new contracts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 23:05:37 +02:00

18 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)

  1. Guest scans the QR code / opens the event link.
  2. 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.
  3. Types display name → taps Beitreten.
  4. Server creates the account, generates a 4-digit PIN, stores bcrypt(PIN), signs a 30-day JWT.
  5. 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.
  6. Guest taps Weiter zur Galerie → lands in the feed (/feed).
  7. The first-visit onboarding overlay appears: dismissible steps (welcome, upload, hashtags, PIN, and a brief pointer to the Datenschutzhinweis in My Account). localStorage('eventsnap_guide_seen') = 'true' after dismiss.
  8. Guest sees the bottom nav: 🏠 Feed · [📷+ FAB] · 👤 Account.

2. Returning guest, same device

  1. App finds a valid JWT in localStorage.
  2. Redirected straight to /feed, no input required.

3. Returning guest, new device or cleared storage

  1. Guest opens the event link on the new device → join page.
  2. Types the same name they used before.
  3. 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.
  4. Guest types their PIN → bcrypt.verify succeeds → new JWT issued for the existing user_id. PIN is written to localStorage on this device too.
  5. Wrong PIN: up to 3 attempts. After the third, the account is locked for 15 minutes (pin_locked_until is set; further attempts return HTTP 429 with a localized message).

4. PIN forgotten — Host or Admin resets it (planned)

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.

  1. Guest approaches the Host: "I can't sign in on my new phone."
  2. Host opens the Host Dashboard → Nutzerverwaltung and finds the user.
  3. Host taps PIN zurücksetzen on that row.
  4. A confirmation prompt explains what happens; on confirm the server generates a fresh 4-digit PIN, replaces recovery_pin_hash with the new bcrypt, clears any active pin_locked_until, and returns the new plaintext PIN in the response.
  5. 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.
  6. 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 to localStorage on 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

  1. Guest taps the central 📷+ FAB in the bottom nav.
  2. A bottom sheet slides up offering Kamera (in-app capture) or Galerie (file picker, multi-select). 3a. Camera pathCameraCapture opens the back camera (facingMode: 'environment'), with toggle for front camera, photo button, and a video-record button using MediaRecorder. 3b. Gallery path — native picker, multiple selection.
  3. 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.
  4. 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.
  5. The client uploads files one at a time (XHR with progress) from an IndexedDB queue.
  6. Each upload triggers a server-side compression job; once the preview is ready the feed updates via upload-processed SSE — placeholders swap for actual previews.

6. Posting under rate limits

  1. Hit the per-hour upload limit (default 10 / hour, configurable).
  2. Server returns HTTP 429 with a Retry-After header on the next upload attempt.
  3. Client parks pending items in Wartend state and shows an amber banner: "Upload-Limit erreicht. Wird in Xs automatisch fortgesetzt."
  4. Countdown ticks down. When it reaches 0, the queue resumes automatically.

7. Liking and commenting

  1. Tap the heart icon on a card or in the lightbox → like is recorded; count increments optimistically; server returns the canonical count via like-update SSE.
  2. Tap the comment icon → opens the lightbox with the comments list.
  3. Type a comment → POST /api/v1/upload/{id}/comment. Hashtags inside the comment are parsed and attached.
  4. The user can delete their own comments (trash icon next to them).
  1. Toggle to grid view (icon top-right of the feed header).
  2. A search bar appears below the header (auto-focused).
  3. Type a name or #hashtag — autocomplete suggestions are derived in memory from the loaded uploads.
  4. Tap a suggestion → it becomes an active filter chip and the search bar clears.
  5. Filter logic:
    • Multiple hashtag chips: OR
    • Multiple uploader chips: OR
    • One uploader + one hashtag: AND
  6. Open a post → swipe in the lightbox navigates the filtered set, not the full feed.

9. Hosting the event — moderation

  1. Host opens My Account → taps Host-Dashboard.
  2. Stats section — guest count, upload count, lock status, release status.
  3. Event settings — toggle to lock new uploads (likes / comments / browsing stay open; broadcasts event-closed SSE so all clients show a "uploads are locked" banner).
  4. Galerie freigeben — releases the export. Enqueues two export jobs (ZIP + HTML viewer). Progress is visible in the Admin dashboard's Export tab; SSE export-progress keeps it live; export-available notifies all guests when ready.
  5. 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.
    • Host promotes a guest to host.
    • Degradieren — visible on Host rows. A Host can demote other Hosts back to guest (planned). The button is hidden on the Host's own row to prevent self-lockout; only an Admin can demote themselves out of moderation. Admins see Degradieren on every Host row.
    • PIN zurücksetzen (planned) — generates a new PIN and shows it once in a modal. See journey §4. Hosts see this on Guest rows only; Admins see it on Guest + Host rows.
  6. 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

  1. The ban takes effect immediately on the banned user's existing session — the auth layer re-reads the live is_banned flag on every request rather than trusting the JWT, so there's no 30-day token-lifetime window.
  2. 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.
  3. 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_banned revalidation (no live pushes), but plain feed reads still work.
  4. They cannot upload, like, or comment; a banned host also loses all host/admin actions (including unbanning themselves).
  5. 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 enforce is_banned = FALSE), and a live user-hidden SSE event evicts their cards from every open feed + the diashow without a reload.

11. Admin — instance configuration

  1. Admin opens /admin/login, types the admin password (compared against ADMIN_PASSWORD_HASH). Receives a separate 1-day admin JWT (in sessionStorage).
  2. 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 Datenschutzhinweis free-text editor and on/off toggles for the rate limiters and quotas (planned — see §16). Whitelist on the server side rejects unknown keys. Values are read from the config table on each request — no restart needed.
    • Export: list of past export jobs with status badges (pending / running / done / failed) and progress bars; refresh button re-polls.
    • Nutzer: same user list as Host, with the additional Demote action and (planned) PIN-reset on host rows.

12. Releasing the export and downloading

  1. Host (or Admin) taps Galerie freigeben in the dashboard.
  2. Server sets event.export_released_at and enqueues two background jobs.
  3. ZIP job: streams Gallery.zip (Photos/ + Videos/, full-quality originals) directly to disk via async-zip. Progress updates via export-progress SSE.
  4. HTML-viewer job: copies the pre-built viewer assets from backend/static/export-viewer/ (embedded via include_dir!), generates data.json from the database, processes _thumb/_full variants for each upload, and assembles Memories.zip.
  5. Both jobs complete → server broadcasts export-available SSE.
  6. 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.
  7. Downloads are rate-limited per IP (default 3 / day).

13. Diashow (planned)

See CONCEPT_DIASHOW.md. Summary of the planned flow:

  1. User taps a Diashow / Präsentation action (feed header on tablet/desktop, Account on mobile).
  2. Navigates to /diashow — fullscreen, bottom nav hidden, screen wake-lock acquired.
  3. Initial pool fetched from GET /api/v1/feed. Slides crossfade every ~6 s.
  4. New uploads (upload-processed SSE) push to a live queue; the next slide transition pops from the live queue first, otherwise from a shuffled queue.
  5. upload-deleted removes that ID from both queues; if it's the current slide, advance immediately.
  6. Tap or Escape reveals an overlay (pause, dwell selector, exit).

14. Picking a data mode (planned)

  1. Guest opens My Account → scrolls to Datennutzung.
  2. Two options: Datensparer (empfohlen) and Original. Saver is the default.
  3. 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.
  4. 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.
  5. The viewer (offline HTML export) is unaffected — it already ships with its own pre- bundled _thumb / _full variants.

15. Leaving an event

  1. User opens My Account → taps 🚪 Event verlassen.
  2. Bottom-sheet confirmation: "Event verlassen?" with Abmelden and Bleiben.
  3. Confirming calls DELETE /api/v1/session (invalidates the session row), clears the JWT and PIN from localStorage, and redirects to the join page.

16. Reading the Datenschutzhinweis (planned)

  1. User opens My Account → scrolls to Datenschutzhinweis.
  2. 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.
  3. The first-visit onboarding overlay carries a one-line reminder of where to find this: "Datenschutzhinweis findest du in deinem Account."
  4. Admin sets / edits the note in Admin Dashboard → Config → Datenschutzhinweis: a tall textarea with a save button. Saved to a single config key.

17. Mobile-first gestures (planned)

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.

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 (planned)

  1. Admin opens Admin Dashboard → Config.
  2. Rate-Limits section: a master switch and per-endpoint switches (upload / feed / export / join).
  3. 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).
  4. Speichern persists to the config table. The next upload request bypasses the limiter entirely.
  5. Quoten section mirrors the pattern: master toggle plus per-area toggles (storage bytes / upload count).
  6. When the storage-quota toggle is off, the "X von Y MB genutzt" widget in the guest's My Account and upload screen hides itself (no quota → no number to show).

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