- PROJECT.md, README.md, TEST_GUIDE.md: status line refreshed; rate-limiter doc-vs-code drift fixed; HTML export section rewritten for the SvelteKit- static viewer; SSE event names + new events documented; config seed block extended with planned toggles + privacy_note; decision log entries added. - docs/CONCEPT_HTML_VIEWER.md, docs/CONCEPT_MOBILE_UI.md: banner the design intent as shipped; point at the source-of-truth code paths. - docs/CONCEPT_DIASHOW.md: planned-then-shipped design for the live diashow (two-queue policy, pluggable transitions, data-mode aware). - docs/FEATURES.md: capability matrix by role (Guest / Host / Admin) plus prose per area (auth, posting, feed, moderation, admin, export, gestures, data mode, quotas, privacy note, extensibility). - docs/USER_JOURNEYS.md: step-by-step flows for every supported scenario, including PIN reset by host, data mode, privacy note, gestures, and the admin toggles. - docs/IDEAS.md: speculative extensions (global diashow, reactions, multi-tenancy, animation pack, etc.) — explicitly out of v0.16 scope. - backend/migrations/README.md, frontend/src/lib/README.md: codify the "never edit a shipped migration" rule and the lib/ conventions (one store per concern, gestures via actions, sheets via ContextSheet, transitions as drop-in components). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
293 lines
17 KiB
Markdown
293 lines
17 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: 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 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 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 with a checkbox "Uploads aus der Galerie
|
|
ausblenden" — Host chooses whether to hide the user's existing uploads or leave them
|
|
visible. Submitting calls `POST /host/users/{id}/ban` with `hide_uploads`.
|
|
- **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 banned user's next authenticated request returns HTTP 403 with a clear message
|
|
("Du bist gesperrt.").
|
|
2. They can still browse the read-only feed (and download the export once it's released).
|
|
3. They cannot upload, like, or comment.
|
|
4. If `hide_uploads` was set on the ban, their existing uploads are filtered out of the
|
|
feed for everyone (the `v_feed` view already enforces this).
|
|
|
|
## 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/](../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](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 |
|