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>
360 lines
22 KiB
Markdown
360 lines
22 KiB
Markdown
# 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](FEATURES.md). For manual QA, see
|
|
[TEST_GUIDE.md](../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: 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.
|
|
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
|
|
|
|
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 path** — [CameraCapture](../frontend/src/lib/components/CameraCapture.svelte)
|
|
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.
|
|
4. **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.
|
|
5. 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.
|
|
6. The client uploads files **one at a time** (XHR with progress) from an IndexedDB queue.
|
|
7. 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).
|
|
|
|
## 8. Filtering the gallery
|
|
|
|
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 on the Host dashboard *and* in the Admin dashboard's Export
|
|
tab; SSE `export-progress` keeps it live; `export-available` notifies 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.
|
|
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 — 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 (→ `/recover` account-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.
|
|
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. A client that was offline/disconnected during
|
|
the live event doesn't miss the eviction: `uploads_hidden_at` is stamped at ban time
|
|
(migration 013) and the reconnect delta (`GET /feed/delta`) returns the banned users in
|
|
`hidden_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
|
|
|
|
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 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 `config` table 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.
|
|
|
|
## 12. Releasing the export and downloading
|
|
|
|
1. Host (or Admin) taps **Galerie freigeben** in the dashboard.
|
|
2. Server sets `event.export_released_at`, locks uploads, **bumps `event.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 each
|
|
`export_job` row carries a copy of the epoch it was enqueued for. An export is downloadable
|
|
**iff** `released 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.
|
|
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/](../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.
|
|
**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.
|
|
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 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](CONCEPT_DIASHOW.md). Summary of the 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
|
|
|
|
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. 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.)
|
|
|
|
1. 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.
|
|
2. Bottom-sheet confirmation naming which of the two it is, with **Abmelden** / **Abbrechen**.
|
|
3. 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
|
|
|
|
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 (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
|
|
|
|
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 **"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 |
|