A deep investigative review of the export concurrency (four parallel adversarial
reviewers) found that three rounds of race fixes had been chasing the wrong bug, and
that the model itself was the root cause. This replaces the model and fixes the real
data loss.
THE ACTUAL BUG (upload path, not the state machine)
`upload` checked `uploads_locked_at` BEFORE streaming the body — minutes, for a large
video — then committed the row without re-checking. A release landing mid-upload let the
row commit AFTER the export snapshot: the photo appeared in the live feed and was
PERMANENTLY missing from the keepsake, with nothing to regenerate it. release_gallery's
comment claims to prevent exactly this; it never did. Every prior round fixed the DB
state machine, and the leak was upstream of it.
Fix: re-assert the lock under `SELECT ... FOR SHARE` INSIDE the commit tx. That row lock
conflicts with release_gallery's `UPDATE event`, so either the upload commits first (and
the release — hence the snapshot — is ordered after it) or the release wins and the
upload is rejected as `uploads_locked` (reversible; the client keeps the blob).
Regression test verified NON-VACUOUS: with the fix reverted it fails, reporting accepted
uploads missing from the keepsake.
THE MODEL (migration 014)
"Which generation is current?" had no single answer: it was assembled from three
separately-written pieces across two tables (`export_released_at`, a per-job
`release_seq`, and cached `export_{zip,html}_ready` flags). Keeping them in agreement
took ~10 hand-written guards; each review round found one more path that slipped through.
Now: ONE monotonic `event.export_epoch`, bumped in the same UPDATE as any change to
`export_released_at`. Each job carries a copy. Downloadable IFF
`released AND job.epoch = event.export_epoch AND status = 'done'` — readiness DERIVED,
never stored. A retired-epoch worker is INERT BY CONSTRUCTION: losing a race can no
longer corrupt state, only waste work.
Deleted: both ready-flag columns, both ready-flip blocks, `if ready { continue }`, the
reopen seq-bump, open_event's transaction, and the cross-table claim guard.
That last one was also UNSOUND: under READ COMMITTED, a blocked UPDATE re-evaluates its
WHERE against the updated target row but answers subqueries on OTHER tables from the
original snapshot — so the previous `EXISTS (SELECT ... FROM event ...)` claim guard
could admit a claim against an already-reopened event while RETURNING the post-bump seq.
Every guard is now same-row (`epoch = $n`), which EPQ re-evaluates correctly.
OTHER REAL DEFECTS FIXED
- release_gallery committed the release and THEN awaited the enqueue inside the handler
future. Axum drops that future on client disconnect → event released, uploads locked,
ZERO export_job rows, host unable to retry ("bereits freigegeben"), restart-only
recovery. Now one tx; workers spawn (detached) after commit.
- No flush/fsync before the tmp→final rename. async_zip's close() never flushes the inner
file and tokio's File drop DISCARDS write errors — so a full disk silently produced a
truncated archive that was then marked done and published, after which the last good
generation was pruned. Now flush + sync_all, which also surfaces ENOSPC.
- Download read the ready flag and the file path in TWO queries; a reopen between them
served a keepsake that should 404. Now one read through the `export_current` view.
- `if !src.exists() { continue }` then `open()?` — a TOCTOU that turned a tolerated gap
into a hard failure of the ENTIRE keepsake (unretryable while released). Now open-first,
warn, and skip.
- Recovery was flag-driven and never checked the disk: a `done` row whose archive was gone
was a permanent dead end needing manual DB surgery. Now verifies the file and re-arms.
- Temp files/dirs leaked on every error path (the leak is what fills the disk that
corrupts the next archive). Now cleaned up.
- Export archives were not event-scoped (`viewer_tmp_` already was), so a second event
would collide on paths and one event's prune would delete another's live keepsake.
- Host deletes after release never regenerated the keepsake — a photo removed on request
lived on in the archive forever. Now bumps the epoch and rebuilds without it.
- claim_job swallowed DB errors as "someone else won", stranding a job pending at 0%.
- export_status reported retired-epoch rows (a progress bar that never moves).
- The startup sweep's single-instance assumption is now documented, not hidden.
Verified: backend 40 tests, svelte-check 0 errors, e2e typecheck clean,
e2e 160 passed / 1 skipped on chromium-desktop (incl. 2 new regressions; the
upload-acceptance one confirmed to fail without the fix).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
328 lines
20 KiB
Markdown
328 lines
20 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. 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. Same authority boundary as ban (below): 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 **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`, 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 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 |
|