fix(feed): filter on the server, exactly, and keep banned uploads out of the chips

Filtering was split across two independent client-side states and applied to whatever
page 1 happened to hold, by caption SUBSTRING. So a tag chip selected in the list view
was silently still applied in the grid without being shown; a filter matched photos
whose caption merely contained the text; and anything past the first page was invisible
to it. Verified against the seeded data: `hashtag=tanz` returned 6 photos by substring,
1 by tag.

`FeedQuery` now carries `hashtag` (single, list view), `hashtags` (CSV, OR'd, grid
chips) and `uploader` (exact, AND'd), normalised through one function that trims,
strips `#`, lowercases and dedupes, and yields None when empty — so an empty filter
means "no filter", never "match nothing". The two SQL branches collapse into one with
`h.tag = ANY($4)`. Tag-OR plus tag+user-AND is a specified feature, not an accident:
`e2e/specs/03-feed/filter-search.spec.ts` and USER_JOURNEYS §8 pin it, which is why the
semantics moved to the server rather than being simplified away.

Tags travel as CSV safely because the backend restricts them to ASCII alphanumerics and
`_`; `uploader` stays a single exact parameter because a display name can contain a
comma.

New `GET /api/v1/uploaders` reads `v_feed`, so banned and hidden uploaders are excluded
for free.

Migration 021 gives `v_hashtag_counts` the same treatment. It counted every upload
regardless of the uploader's ban state, so banning a guest left their tags in the chip
list as ghost filters that lead to an empty feed. Verified: after banning the guest who
owned all six `tanz*` photos, the chips went 6 -> 0.

`?limit=-5` returned a 500 — only the upper bound was clamped, so Postgres was asked
for `LIMIT -4`. Clamped at both ends.

`is_banned` is added to `/me/context` so the client can show a read-only notice instead
of letting a banned guest discover the ban one 403 toast at a time. `add_comment` sorts
and dedupes hashtags on the normalised key, matching the upload path — the two disagreed,
which is a lock-ordering deadlock between concurrent upserts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Fabian Hamm (Privat)
2026-08-03 18:35:23 +02:00
parent 1d0df3ebf6
commit 496dba5a1f
5 changed files with 200 additions and 40 deletions

View File

@@ -0,0 +1,13 @@
-- Restore the pre-021 definition (no ban/hide filtering) exactly as 004 created it.
DROP VIEW IF EXISTS v_hashtag_counts;
CREATE VIEW v_hashtag_counts AS
SELECT
h.event_id,
h.tag,
COUNT(uh.upload_id) AS upload_count
FROM hashtag h
JOIN upload_hashtag uh ON uh.hashtag_id = h.id
JOIN upload u ON u.id = uh.upload_id AND u.deleted_at IS NULL
GROUP BY h.event_id, h.id, h.tag
ORDER BY upload_count DESC;

View File

@@ -0,0 +1,31 @@
-- v_hashtag_counts: apply the same visibility rules as v_feed.
--
-- The chip row and the grid's tag picker are both fed by this view, but it has only ever
-- filtered `u.deleted_at IS NULL`. Migration 011 added ban/hide filtering to the feed and
-- never reached here, so the two disagreed about which uploads exist:
--
-- * A host bans a guest who posted 3 of the 12 `#tanz` photos. The chip keeps reading
-- "#tanz 12"; tapping it returns 9. The count is presented as authoritative and is not.
-- * A tag used ONLY by a banned or hidden guest stays in the chip row and in the tag
-- picker as a selectable option that leads to an empty feed — a ghost filter that
-- cannot be cleared because there is nothing wrong with it to see.
--
-- Bans are exactly the moment a host is watching these numbers to confirm the moderation
-- took effect, so a stale count reads as "the ban didn't work".
--
-- Same predicate as v_feed (see 016_display_derivative.up.sql), joined through `user`.
DROP VIEW IF EXISTS v_hashtag_counts;
CREATE VIEW v_hashtag_counts AS
SELECT
h.event_id,
h.tag,
COUNT(uh.upload_id) AS upload_count
FROM hashtag h
JOIN upload_hashtag uh ON uh.hashtag_id = h.id
JOIN upload u ON u.id = uh.upload_id AND u.deleted_at IS NULL
JOIN "user" usr ON usr.id = u.user_id
WHERE usr.uploads_hidden = FALSE
AND usr.is_banned = FALSE
GROUP BY h.event_id, h.id, h.tag
ORDER BY upload_count DESC;