docs(specs): address Codex adversarial review for search request section
Splits discovery eligibility from submission eligibility so blocked and quota-exhausted viewers still see the requestable section with disabled per-row CTAs, matching the documented behavior. Documents the required extensions to useRequestSearch — signal forwarding, viewer-identity-keyed cache, and invalidation on auth/profile/ settings/limit changes — so the planned 5-minute staleTime is safe and cancellation works as described. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
c205da8b2b
commit
9fc70631a8
@@ -25,31 +25,54 @@ Surface TMDB-backed "requestable" results inside the main catalog search so user
|
||||
|
||||
### Quota / blocked viewers
|
||||
|
||||
- If the viewer is quota-exhausted or individually blocked, the section still renders, but each row's request affordance is disabled with a tooltip explaining the reason. Rows remain clickable and still navigate to the detail page.
|
||||
Discovery eligibility (whether the TMDB query fires) is separate from submission eligibility (whether the row's request CTA is active):
|
||||
|
||||
- **Discovery eligibility** is gated only by global/identity preconditions: admin `RequestsEnabled = true`, the viewer is authenticated, and has a profile. If any of these is false, the TMDB query does not fire and the section is not rendered.
|
||||
- **Submission eligibility** is per-viewer policy: quota-exhausted, individually blocked (`UserLimit.LimitMode = "blocked"`), or otherwise restricted. When discovery is allowed but submission is not, the section still renders, each row's request affordance is disabled, and a tooltip surfaces the reason. Rows remain clickable and still navigate to the detail page, which is responsible for displaying the full policy state.
|
||||
|
||||
This keeps search-side UX consistent with what the detail page would show for the same viewer.
|
||||
|
||||
### Performance
|
||||
|
||||
- Library results never wait on TMDB. The two queries fire concurrently from the client; the library section paints as soon as FTS returns.
|
||||
- TMDB query is debounced at 400ms; library query stays at the current 200ms.
|
||||
- TMDB query is cancelled in-flight when the query string changes, using the existing react-query `{ signal }` pattern.
|
||||
- TMDB query is cancelled in-flight when the query string changes. This requires extending `useRequestSearch` to accept and forward `{ signal }` to `api` (it does not today); see Architecture.
|
||||
- TMDB error or timeout silently omits the section; no error banner.
|
||||
- React-query `staleTime`: 5 minutes for TMDB results (reduces external calls and respects TMDB rate limits), 60 seconds for library results (matches the existing `GlobalSearch` preview).
|
||||
- React-query `staleTime`: 5 minutes for TMDB results (reduces external calls and respects TMDB rate limits), 60 seconds for library results (matches the existing `GlobalSearch` preview). The 5-minute window is only safe because the cache key includes viewer identity (see Architecture); cross-viewer reuse is impossible.
|
||||
|
||||
## Architecture
|
||||
|
||||
- No backend changes to existing endpoints. The frontend coordinates two parallel queries.
|
||||
- Library on the results page: existing `useCatalogWindow` against `/api/v1/catalog?source=query`.
|
||||
- Library in the Cmd+K dialog: existing `previewQuery` pattern using `fetchCatalogPage` against the same endpoint.
|
||||
- TMDB: existing `useRequestSearch` hook against `/api/v1/requests/search`, used by both surfaces.
|
||||
- TMDB: existing `useRequestSearch` hook against `/api/v1/requests/search`, used by both surfaces — see required extensions below.
|
||||
- Deduplication is handled server-side by the existing `enrichPage()` → `presence.Lookup()` flow on `/requests/search`. Client filters TMDB results where `availability == "available"` so they don't shadow library rows.
|
||||
- A new `useCanRequest()` hook centralizes the gating logic. It reads admin settings (`RequestsEnabled`) and viewer policy (`EffectivePolicy.LimitMode`, quota state) and returns `{ enabled, disabledReason }`. When `enabled === false`, the TMDB query is not fired.
|
||||
|
||||
### Gating hook (`useCanRequest`)
|
||||
|
||||
The new hook splits its return into two independent signals:
|
||||
|
||||
- `discoveryEnabled: boolean` — true when admin `RequestsEnabled = true` AND the viewer is authenticated with a profile. This is the only signal that controls whether the TMDB query fires.
|
||||
- `submitDisabledReason: string | null` — null when the viewer can submit; otherwise one of `"blocked"`, `"quota_exhausted"`, or a future reason key. Passed through `RequestToAddSection` to per-row UI to disable the request CTA and populate its tooltip.
|
||||
|
||||
Per-viewer policy state (`EffectivePolicy.LimitMode`, quota counters) feeds `submitDisabledReason` and is never used to suppress the query.
|
||||
|
||||
### `useRequestSearch` extensions
|
||||
|
||||
The existing hook is reused but must be extended before it can back this feature safely:
|
||||
|
||||
- **Pass through `{ signal }`**: the query function currently does not accept the react-query `signal`. Update it to accept the signal and forward it to `api` so in-flight TMDB requests are cancelled on query change, unmount, or viewer change.
|
||||
- **Key by viewer identity**: extend `requestKeys.search(...)` to include the active `profile_id` (and `user_id` if profile alone is insufficient to identify the policy holder). This prevents cached results from being served across viewer changes and makes the 5-minute `staleTime` safe.
|
||||
- **Invalidate on policy or identity change**: invalidate `requestKeys.search()` queries when any of the following occurs in the SPA: login/logout, profile switch, admin `RequestsEnabled` toggle, `UserLimit` mutation affecting the current viewer, or quota reset/refresh. The invalidation hooks live alongside the existing auth/profile/settings stores.
|
||||
|
||||
## Components
|
||||
|
||||
- `web/src/hooks/useCanRequest.ts` (new): exposes `{ enabled, disabledReason }` derived from settings + viewer policy.
|
||||
- `web/src/hooks/useCanRequest.ts` (new): exposes `{ discoveryEnabled, submitDisabledReason }` derived from settings + viewer identity + policy as described in Architecture.
|
||||
- `web/src/hooks/queries/useRequests.ts` (modified): extend `useRequestSearch` and `requestKeys.search(...)` to accept/forward `{ signal }`, include viewer identity in the query key, and expose invalidation helpers used by the auth/profile/settings stores.
|
||||
- `web/src/components/RequestToAddSection.tsx` (new): renders the section in two variants:
|
||||
- `variant="dialog"` — compact row layout for `GlobalSearch`.
|
||||
- `variant="grid"` — poster grid using existing `RequestPosterCard` for `Catalog`.
|
||||
Accepts `submitDisabledReason` and propagates it to per-row CTAs.
|
||||
- `web/src/components/GlobalSearch.tsx` (modified): wires the second query, passes results into `RequestToAddSection` with `variant="dialog"`.
|
||||
- `web/src/pages/Catalog.tsx` (modified): renders `RequestToAddSection` with `variant="grid"` below the existing `ItemGrid` when the source is `query`.
|
||||
|
||||
@@ -58,8 +81,9 @@ Surface TMDB-backed "requestable" results inside the main catalog search so user
|
||||
- TMDB returns only items already available in the library: section is omitted (after client filter).
|
||||
- Library has hits but TMDB is still loading: library renders immediately; section shows a compact skeleton in its slot, then either renders or vanishes.
|
||||
- Library has 0 hits and TMDB is still pending: the page suppresses the "No matches" empty state and shows a single loading indicator until TMDB resolves. Only after TMDB returns 0 (or errors) does the empty state render.
|
||||
- TMDB query never fires (gated off): library follows its existing behavior including the standard empty state.
|
||||
- Viewer logs out / profile changes mid-query: `useCanRequest()` re-evaluates and cancels the TMDB query if it becomes ineligible.
|
||||
- TMDB query never fires (discovery gated off): library follows its existing behavior including the standard empty state.
|
||||
- Viewer logs out, switches profile, or admin disables `RequestsEnabled` mid-query: `useCanRequest()` re-evaluates and `discoveryEnabled` flips to false; the in-flight TMDB request is cancelled via its forwarded `signal`, and cached entries under the previous viewer identity are invalidated so they cannot be re-served.
|
||||
- Admin updates `UserLimit` for the current viewer while results are cached: the settings/limit mutation triggers a `requestKeys.search()` invalidation; the next paint re-fetches with the new `submitDisabledReason`.
|
||||
- Source is not `query` (e.g., `favorites`, `watchlist`, `history`, `section`): section never renders.
|
||||
|
||||
## Out of scope
|
||||
@@ -75,5 +99,7 @@ Commands assume the repository root is the cwd.
|
||||
|
||||
- `cd web && pnpm run lint`
|
||||
- `cd web && pnpm run format:check`
|
||||
- Frontend component tests for `GlobalSearch`, `Catalog`, and `RequestToAddSection` covering: library-only results, library + TMDB, TMDB-only (library empty), both-empty, TMDB error, quota-disabled viewer, requests-globally-off.
|
||||
- Frontend component tests for `GlobalSearch`, `Catalog`, and `RequestToAddSection` covering: library-only results, library + TMDB, TMDB-only (library empty), both-empty, TMDB error, blocked viewer (section renders, CTAs disabled), quota-exhausted viewer (section renders, CTAs disabled), requests-globally-off (no TMDB query fired, no section).
|
||||
- Hook tests for `useCanRequest` across the matrix of `RequestsEnabled`, auth state, profile presence, and policy states, asserting that `discoveryEnabled` and `submitDisabledReason` are independent.
|
||||
- Hook/integration tests for the extended `useRequestSearch`: confirm `signal` forwarding cancels in-flight requests on query change, confirm cache entries are not shared across `profile_id` keys, and confirm the relevant store mutations invalidate `requestKeys.search()`.
|
||||
- Manual smoke in the dev frontend: confirm library results are not delayed when TMDB is slow or errors; confirm the dialog and full-page surfaces both show the section under matching conditions.
|
||||
|
||||
Reference in New Issue
Block a user