feat(db): idempotency keys, ban-aware counts, and a host audit trail

Four migrations, all additive against a database that already has 001-025 applied.

026 narrows the client-upload idempotency index with `AND deleted_at IS NULL`. The old
index made a soft-deleted row keep its key forever, so a guest who deleted a photo and
re-sent the same one had the retry silently swallowed. The new indexed set is a strict
subset of the old, so it cannot fail on existing rows.

027 adds `client_join_id`, which lets a join retry after a lost response resume the same
account instead of 409ing on a name the caller itself owns. Every existing row gets NULL
and the partial index excludes NULLs, so it indexes nothing at creation.

028 brings the feed view's like/comment counts in line with what the feed actually renders:
a banned guest's rows were still counted, so a card showed "3 comments" above two.
`comment.rs` gets the matching `NOT u.is_banned` on the live read path — the export and
hashtag queries already filtered it, so the two views of one moderation action disagreed.

029 records host moderation actions, which were previously invisible after the fact.

Verified by applying 001-029 to a real Postgres against seeded data, including a
soft-deleted row holding a key and a banned user's like and comment. 026's down-migration
legitimately fails where a deleted and a live row share a key — that is inherent to the
direction, documented in the file, and sqlx never runs downs at boot.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
fabi
2026-08-11 22:43:11 +02:00
parent ef6d3a077a
commit 963f6449a1
15 changed files with 365 additions and 10 deletions

View File

@@ -0,0 +1,13 @@
-- Restore migration 022's wider index (which also covered soft-deleted rows).
--
-- Note this can FAIL where the up-migration succeeded: once retries-after-delete have been
-- allowed, two rows may legitimately share a `client_upload_id` (one deleted, one live), and
-- the wider unique index cannot be rebuilt over them. That is inherent to reverting this
-- direction, not a defect in the down-migration. If it fails, the live-only index is still
-- correct and should simply be kept.
DROP INDEX IF EXISTS upload_client_upload_id_key;
CREATE UNIQUE INDEX upload_client_upload_id_key
ON upload (client_upload_id)
WHERE client_upload_id IS NOT NULL;

View File

@@ -0,0 +1,35 @@
-- Narrow the client-upload idempotency index so it stops covering soft-deleted rows.
--
-- The bug (H9). Migration 022 created the index partial on `client_upload_id IS NOT NULL`
-- only, so a soft-deleted row kept occupying its key. But `find_by_client_upload_id` filters
-- `deleted_at IS NULL` — deliberately, and its doc comment says so: "if the guest deleted the
-- photo and their queue later retries, they should get a fresh upload rather than a
-- resurrection of a deleted one." The index and the lookup therefore disagreed, and the
-- disagreement is reachable by an ordinary guest:
--
-- 1. guest uploads a photo, then deletes it (soft delete — the row stays, `deleted_at` set)
-- 2. their queue retries the same item (reconnect requeue, or they tap "Erneut")
-- 3. the whole body is re-streamed and re-validated, then `ON CONFLICT DO NOTHING` matches
-- the DEAD row and inserts nothing
-- 4. the replay lookup filters that row out and finds nothing, so the handler returns 409
-- 5. the client classifies 409 as terminal and DELETES the blob from IndexedDB
--
-- The photo is now gone from the device with no row in the gallery, and there is no path back.
-- Re-selecting the same file from the camera roll mints a new `client_upload_id`, so that does
-- work — but the guest has no way to know that is what is required.
--
-- Adding `deleted_at IS NULL` makes the index agree with the lookup: a key is claimed only
-- while a LIVE row holds it, so step 3 inserts a fresh row and the retry succeeds.
--
-- Uniqueness among live rows is what the feature actually needs. The property migration 022
-- was protecting — "the same photo must not land in the gallery twice" — is about rows the
-- guest can see, and a soft-deleted row is not one of those.
DROP INDEX IF EXISTS upload_client_upload_id_key;
-- CONCURRENTLY is deliberately NOT used: sqlx runs each migration inside a transaction, and
-- CREATE INDEX CONCURRENTLY cannot run in one. The table is small (one event's uploads) and
-- this runs at boot before the server accepts requests, so the brief lock costs nothing.
CREATE UNIQUE INDEX upload_client_upload_id_key
ON upload (client_upload_id)
WHERE client_upload_id IS NOT NULL AND deleted_at IS NULL;

View File

@@ -0,0 +1,2 @@
DROP INDEX IF EXISTS user_client_join_id_key;
ALTER TABLE "user" DROP COLUMN IF EXISTS client_join_id;

View File

@@ -0,0 +1,34 @@
-- Idempotency key for /join, supplied by the client.
--
-- The failure this closes (H16) is the single most likely failure of the evening, on step one
-- of the product. `/join` commits the user row AND the bcrypt hash of the PIN, but the PLAINTEXT
-- PIN exists nowhere except the HTTP response body. So:
--
-- 1. guest scans the QR in the venue car park, taps "Beitreten"
-- 2. the server creates the account and hashes the PIN
-- 3. the response is lost on the way back — the 5G-to-nothing transition every wedding venue
-- has, or the AP handing off
-- 4. the client retries; the name is now taken, so it 409s
-- 5. the client shows a PIN entry form for a PIN THAT WAS NEVER DISPLAYED
--
-- The guest is locked out of their own brand-new account, and the only recovery is finding a
-- host with a dashboard open. `/upload` already solved exactly this with `client_upload_id`;
-- join never got the same treatment.
--
-- With a key, a retry is recognised as the same join and answered with a usable PIN. We do NOT
-- store the plaintext to replay it — see the handler: a retry ROTATES the PIN. That is sound
-- precisely because the original was never shown to anybody, so there is nothing to preserve,
-- and it keeps this table free of recoverable credentials.
ALTER TABLE "user" ADD COLUMN client_join_id UUID;
-- Partial, for the same reasons as `upload_client_upload_id_key`: index only the rows that
-- carry a key, and state the rule exactly. NULL is allowed and unconstrained, so any client
-- that does not send one (and every row that predates this column) behaves exactly as before.
--
-- Scoped per event as well as per key. The key is a client-generated v4 UUID so a cross-event
-- collision is not realistic, but a reused install genuinely has two events in one table and
-- "this join belongs to that event" is the property we actually mean.
CREATE UNIQUE INDEX user_client_join_id_key
ON "user" (event_id, client_join_id)
WHERE client_join_id IS NOT NULL;

View File

@@ -0,0 +1,22 @@
-- Restore migration 024's counts (which included banned users' likes and comments).
CREATE OR REPLACE VIEW v_feed AS
SELECT
u.id,
u.event_id,
u.user_id,
usr.display_name AS uploader_name,
usr.is_banned,
usr.uploads_hidden,
u.preview_path,
u.thumbnail_path,
u.display_path,
u.mime_type,
u.caption,
u.created_at,
(SELECT count(*) FROM "like" l WHERE l.upload_id = u.id) AS like_count,
(SELECT count(*) FROM comment c WHERE c.upload_id = u.id AND c.deleted_at IS NULL) AS comment_count
FROM upload u
JOIN "user" usr ON u.user_id = usr.id
WHERE u.deleted_at IS NULL
AND usr.uploads_hidden = FALSE
AND usr.is_banned = FALSE;

View File

@@ -0,0 +1,44 @@
-- Exclude banned users' likes and comments from the feed's scalar counts (H11).
--
-- `v_feed` already excludes banned UPLOADERS (`usr.is_banned = FALSE` on the join), but the two
-- correlated subqueries added by migration 024 counted every like and every non-deleted comment
-- regardless of who wrote it. So after a ban:
--
-- * the banned guest's own photos disappear from the feed (correct), but
-- * their likes still inflate the counter on everyone else's photos, and
-- * their comments still contribute to `comment_count` — and, until the change to
-- `Comment::list_for_upload` that ships with this migration, were still RENDERED in the
-- lightbox on the most-viewed photo of the evening.
--
-- The host's mental model of "ban" is "this person's contributions are gone". Photos honoured it;
-- likes and comments did not. Migration 021 already applied exactly this reasoning to hashtag
-- counts, and the export query filters `is_banned` too — this brings the last read path in line.
--
-- Derived at read time, so `unban_user` restores the counts with no extra work, exactly as it
-- already restores the photos.
CREATE OR REPLACE VIEW v_feed AS
SELECT
u.id,
u.event_id,
u.user_id,
usr.display_name AS uploader_name,
usr.is_banned,
usr.uploads_hidden,
u.preview_path,
u.thumbnail_path,
u.display_path,
u.mime_type,
u.caption,
u.created_at,
(SELECT count(*) FROM "like" l
JOIN "user" lu ON lu.id = l.user_id
WHERE l.upload_id = u.id AND NOT lu.is_banned) AS like_count,
(SELECT count(*) FROM comment c
JOIN "user" cu ON cu.id = c.user_id
WHERE c.upload_id = u.id AND c.deleted_at IS NULL AND NOT cu.is_banned) AS comment_count
FROM upload u
JOIN "user" usr ON u.user_id = usr.id
WHERE u.deleted_at IS NULL
AND usr.uploads_hidden = FALSE
AND usr.is_banned = FALSE;

View File

@@ -0,0 +1,2 @@
DROP INDEX IF EXISTS host_action_audit_event_created_idx;
DROP TABLE IF EXISTS host_action_audit;

View File

@@ -0,0 +1,41 @@
-- An audit trail for privileged actions (H17).
--
-- What existed before: nothing. `grep -i audit` across `handlers/host.rs` and `handlers/admin.rs`
-- returned no hits. Individual actions logged a `tracing::info!` line, but config changes, gallery
-- release and event lock/unlock logged nothing at all — and the "audit trail" as a whole was a
-- 30 MB rotating Docker log that the runbook's own retention settings will discard.
--
-- Why it matters here specifically: a host is a promoted GUEST, and `reset_pin` overwrites another
-- guest's credential and returns the new PIN in the clear. So a host can take over any guest's
-- account and post as them, and nothing in the record showed it happened (only /recover FAILURES
-- were logged). At a wedding the people involved know each other; the point is not catching a
-- villain, it is being able to answer "what happened to my photo?" the next morning without
-- guessing.
--
-- Deliberately append-only in practice: no UPDATE or DELETE path is written for it anywhere. Small
-- (a few hundred rows for a real event), so no partitioning or retention job.
CREATE TABLE host_action_audit (
id BIGSERIAL PRIMARY KEY,
event_id UUID NOT NULL REFERENCES event(id) ON DELETE CASCADE,
-- The privileged caller. NOT a FK with ON DELETE CASCADE: the record must survive the actor's
-- account being removed, which is exactly when it is most likely to be wanted.
actor_id UUID,
actor_name TEXT,
actor_role TEXT NOT NULL,
-- Short stable slug: 'ban_user', 'unban_user', 'reset_pin', 'delete_upload',
-- 'delete_comment', 'release_gallery', 'lock_uploads', 'unlock_uploads', 'patch_config',
-- 'promote_user', 'demote_user', 'delete_user'.
action TEXT NOT NULL,
-- The guest or object acted upon, when there is one.
target_id UUID,
target_name TEXT,
-- Free-form context: the config key and its old/new value, the caption that was removed, etc.
-- Never credentials — a reset PIN must not be recoverable from this table.
detail JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- The only query shape this needs: "what happened at this event, newest first".
CREATE INDEX host_action_audit_event_created_idx
ON host_action_audit (event_id, created_at DESC);