Skip to content

feat(collect): inline song preview embed in detail sheet#287

Merged
thewrz merged 4 commits into
mainfrom
feat/collect-preview-endpoint
May 6, 2026
Merged

feat(collect): inline song preview embed in detail sheet#287
thewrz merged 4 commits into
mainfrom
feat/collect-preview-endpoint

Conversation

@thewrz

@thewrz thewrz commented May 6, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Add GET /api/public/collect/{code}/requests/{id}/preview endpoint — returns source + source_url for a single request, rate-limited (10/min) with Turnstile human verification
  • Render Spotify/Tidal iframe embed in CollectDetailSheet when user taps a song — Beatport falls back to "Open in Beatport" external link, manual entries show nothing
  • Source URLs stay out of the bulk leaderboard response — only exposed via the per-request lazy fetch to prevent scraping

Abuse mitigation

Vector Protection
Bulk URL harvesting source_url not in leaderboard — per-request endpoint only
Automated embed loading wrzdj_human cookie required (Turnstile)
Rate abuse 10/minute per guest_id
Cross-event enumeration Validates request belongs to event

Test plan

  • 4 backend tests (200 with URL, 200 with null, 404 wrong event, 404 nonexistent)
  • 18 existing frontend tests updated with new code prop + mock
  • Backend CI: ruff, bandit, pytest 1955 passed (88% coverage)
  • Frontend CI: tsc, eslint, vitest 970 passed
  • Manual: tap Spotify song → iframe embed renders
  • Manual: tap Tidal song → iframe embed renders
  • Manual: tap Beatport song → external link renders
  • Manual: tap manual entry → no preview section
  • Manual: verify mobile layout scrolls with embed

Summary by CodeRabbit

  • New Features

    • Added song preview to the Collect flow with embedded players for supported sources (Spotify, Tidal, Beatport), Beatport fallbacks, and mobile/desktop-optimized placement; previews are served by a new backend preview endpoint.
  • Tests

    • Added comprehensive tests covering all preview source types and error/404 scenarios.
  • Documentation

    • Added design and implementation plans describing UX, API, and testing guidance.

thewrz added 3 commits May 5, 2026 02:05
Add CollectPreviewResponse type and getCollectPreview() API method.
CollectDetailSheet fetches preview data and renders Spotify/Tidal
iframes or a Beatport external link below the suggested-by section.
@coderabbitai

coderabbitai Bot commented May 6, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Adds a Collect preview feature: new backend schema and endpoint to return a request's preview (source + source_url), frontend API client method, CollectDetailSheet prop/state to fetch and render embeds or a Beatport link, updated page wiring, tests, and design/plan docs.

Changes

Collect Song Preview Feature

Layer / File(s) Summary
Backend Schema
server/app/schemas/collect.py
Added CollectPreviewResponse Pydantic model with `source: 'spotify'
Backend Endpoint & Logic
server/app/api/collect.py
New GET /api/public/collect/{code}/requests/{request_id}/preview endpoint that resolves event, looks up SongRequest within event, and returns CollectPreviewResponse; rate-limited and uses soft human verification.
Backend Tests
server/tests/test_collect_public.py
Four tests: returns preview with source_url, manual entries with null source_url, 404 for wrong event code, and 404 for nonexistent request ID.
Frontend API Client
dashboard/lib/api.ts
Added CollectPreviewResponse interface and ApiClient.getCollectPreview(code, requestId) to fetch preview JSON from the new endpoint and throw ApiError on failure.
Frontend Component API & State
dashboard/app/collect/[code]/components/CollectDetailSheet.tsx
Props extended with code: string; component adds preview state and effect to call apiClient.getCollectPreview(code, row.id) with cancellation safety.
Frontend Rendering
dashboard/app/collect/[code]/components/CollectDetailSheet.tsx
Introduced previewSection that computes embed URL/source and renders an iframe for Spotify/Tidal or a Beatport external link; injected into desktop and mobile layouts after suggested-by area.
Page Wiring
dashboard/app/collect/[code]/page.tsx
Passes code={code} into CollectDetailSheet call site to supply the event code.
Frontend Tests
dashboard/app/collect/[code]/components/CollectDetailSheet.test.tsx
Mocked getCollectPreview to return { source: 'manual', source_url: null }; updated all CollectDetailSheet renders to include code="TEST"; added tests for Spotify, Tidal, Beatport, and no-preview scenarios.
Documentation / Plan
docs/superpowers/specs/2026-05-05-collect-song-preview-design.md, docs/superpowers/plans/2026-05-05-collect-song-preview.md
Added design spec and implementation plan covering API, frontend embedding rules, UI placement, abuse mitigation, testing, and rollout steps.

Sequence Diagram

sequenceDiagram
  participant UI as Browser UI
  participant Comp as CollectDetailSheet
  participant Client as ApiClient
  participant Server as Collect API
  participant DB as Database

  UI->>Comp: open detail sheet (row.id, code)
  Comp->>Client: getCollectPreview(code, row.id)
  Client->>Server: GET /api/public/collect/{code}/requests/{id}/preview
  Server->>DB: query SongRequest for request_id within event
  DB-->>Server: SongRequest (source, source_url)
  Server-->>Client: 200 { source, source_url }
  Client-->>Comp: preview data
  Comp->>UI: render previewSection (iframe or Beatport link) / no preview
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 60.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat(collect): inline song preview embed in detail sheet' accurately summarizes the main change - adding inline song preview embedding to the CollectDetailSheet component.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/collect-preview-endpoint

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
dashboard/app/collect/[code]/components/CollectDetailSheet.test.tsx (1)

31-374: 🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Consider adding tests for the preview section rendering.

The design spec (lines 115-120) lists frontend tests for iframe rendering (Spotify/Tidal) and external link rendering (Beatport). These aren't covered by the current test suite. Consider adding tests that mock getCollectPreview to return embeddable sources and verify the iframe/link renders correctly.

Do you want me to generate test cases for the preview section rendering?

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@dashboard/app/collect/`[code]/components/CollectDetailSheet.test.tsx around
lines 31 - 374, Add unit tests for the preview section in CollectDetailSheet by
mocking the getCollectPreview call to return embeddable and external preview
results and asserting the correct DOM render; specifically, add tests that (1)
mock getCollectPreview to return an embed (e.g., { source: 'spotify', url:
'https://open.spotify...' }) and render <CollectDetailSheet ... /> then assert
an iframe exists with the expected src, and (2) mock getCollectPreview to return
an external link (e.g., { source: 'beatport', url: 'https://beatport...' }) then
render and assert an anchor with the correct href and target exists; use the
same test setup utilities (render, act, vi.fn()) and the existing mockRow, spy
or vi.mock the module that exports getCollectPreview, and ensure mocks are
restored between tests so other tests (e.g., those referencing bpm/key/artist)
are unaffected.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@dashboard/app/collect/`[code]/components/CollectDetailSheet.tsx:
- Around line 167-214: The iframe in the previewSection of
CollectDetailSheet.tsx is missing a title attribute; update the iframe rendered
inside the embedUrl branch (in the previewSection IIFE) to include a meaningful
title, e.g. use preview.title if available or fallback to a string like
`${source} preview` (so: title={preview.title || `${source} preview`}), to
improve screen reader accessibility.

In `@docs/superpowers/plans/2026-05-05-collect-song-preview.md`:
- Around line 147-153: The rate-limit decorator on request_preview currently
uses the default keying; update the limiter.limit call on request_preview to
explicitly key on guest_id (not IP/global) by passing the limiter's key_func/key
argument that extracts the guest_id from the request or from the
require_verified_human_soft dependency (e.g., a get_guest_id(request) helper),
and add a runtime check in request_preview (or the dependency) to verify
guest_id is present (raise/log if missing) so throttling cannot silently fall
back to a global/IP key.
- Around line 77-130: Add two negative-path tests in
server/tests/test_collect_public.py: one named like
test_collect_preview_rejects_without_human_cookie that uses the existing
_enable_collection, picks a request from collection_requests and calls the same
preview endpoint without setting the wrzdj_human cookie (and also with an
explicitly invalid value) asserting the response is a rejection (expect
401/unauthorized); and another named like
test_collect_preview_rate_limit_exceeded that enables collection, uses the same
guest identity (same cookies/guest token) and issues >10 GETs to
f"/api/public/collect/{test_event.code}/requests/{req.id}/preview" in a loop,
asserting that after the 10th request subsequent responses return rate-limited
(expect 429). Ensure both tests reuse collection_requests/ test_event fixtures
and the exact preview URL used elsewhere.

---

Outside diff comments:
In `@dashboard/app/collect/`[code]/components/CollectDetailSheet.test.tsx:
- Around line 31-374: Add unit tests for the preview section in
CollectDetailSheet by mocking the getCollectPreview call to return embeddable
and external preview results and asserting the correct DOM render; specifically,
add tests that (1) mock getCollectPreview to return an embed (e.g., { source:
'spotify', url: 'https://open.spotify...' }) and render <CollectDetailSheet ...
/> then assert an iframe exists with the expected src, and (2) mock
getCollectPreview to return an external link (e.g., { source: 'beatport', url:
'https://beatport...' }) then render and assert an anchor with the correct href
and target exists; use the same test setup utilities (render, act, vi.fn()) and
the existing mockRow, spy or vi.mock the module that exports getCollectPreview,
and ensure mocks are restored between tests so other tests (e.g., those
referencing bpm/key/artist) are unaffected.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: f20fc353-c88d-4177-b57e-59fd48ee2bcc

📥 Commits

Reviewing files that changed from the base of the PR and between 132d22c and 341603c.

📒 Files selected for processing (9)
  • dashboard/app/collect/[code]/components/CollectDetailSheet.test.tsx
  • dashboard/app/collect/[code]/components/CollectDetailSheet.tsx
  • dashboard/app/collect/[code]/page.tsx
  • dashboard/lib/api.ts
  • docs/superpowers/plans/2026-05-05-collect-song-preview.md
  • docs/superpowers/specs/2026-05-05-collect-song-preview-design.md
  • server/app/api/collect.py
  • server/app/schemas/collect.py
  • server/tests/test_collect_public.py

Comment thread dashboard/app/collect/[code]/components/CollectDetailSheet.tsx
Comment on lines +77 to +130
- [ ] **Step 1: Write the failing test**

Add to `server/tests/test_collect_public.py`:

```python
def test_collect_preview_returns_source_url(client, db, test_event, collection_requests):
"""Preview endpoint returns source + source_url for a valid request."""
_enable_collection(db, test_event)
req = collection_requests[0]
req.source_url = "https://open.spotify.com/track/abc123"
db.commit()

r = client.get(
f"/api/public/collect/{test_event.code}/requests/{req.id}/preview"
)
assert r.status_code == 200
body = r.json()
assert body["source"] == "spotify"
assert body["source_url"] == "https://open.spotify.com/track/abc123"


def test_collect_preview_null_source_url_for_manual(client, db, test_event, collection_requests):
"""Preview endpoint returns source_url=null for manual entries."""
_enable_collection(db, test_event)
req = collection_requests[0]
req.source = "manual"
req.source_url = None
db.commit()

r = client.get(
f"/api/public/collect/{test_event.code}/requests/{req.id}/preview"
)
assert r.status_code == 200
body = r.json()
assert body["source"] == "manual"
assert body["source_url"] is None


def test_collect_preview_404_wrong_event(client, db, test_event, collection_requests):
"""Preview endpoint returns 404 when request belongs to a different event."""
_enable_collection(db, test_event)
req = collection_requests[0]

r = client.get(f"/api/public/collect/ZZZZZZ/requests/{req.id}/preview")
assert r.status_code == 404


def test_collect_preview_404_nonexistent_request(client, db, test_event):
"""Preview endpoint returns 404 for nonexistent request ID."""
_enable_collection(db, test_event)

r = client.get(f"/api/public/collect/{test_event.code}/requests/99999/preview")
assert r.status_code == 404
```

@coderabbitai coderabbitai Bot May 6, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Add negative-path tests for human verification and rate limiting.

The plan’s test set validates only happy-path/404 behavior, but it doesn’t verify the abuse controls called out in the PR goals. Please add explicit cases for: (1) missing/invalid wrzdj_human cookie (expected rejection) and (2) exceeding the 10/min limit for the same guest identity.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/superpowers/plans/2026-05-05-collect-song-preview.md` around lines 77 -
130, Add two negative-path tests in server/tests/test_collect_public.py: one
named like test_collect_preview_rejects_without_human_cookie that uses the
existing _enable_collection, picks a request from collection_requests and calls
the same preview endpoint without setting the wrzdj_human cookie (and also with
an explicitly invalid value) asserting the response is a rejection (expect
401/unauthorized); and another named like
test_collect_preview_rate_limit_exceeded that enables collection, uses the same
guest identity (same cookies/guest token) and issues >10 GETs to
f"/api/public/collect/{test_event.code}/requests/{req.id}/preview" in a loop,
asserting that after the 10th request subsequent responses return rate-limited
(expect 429). Ensure both tests reuse collection_requests/ test_event fixtures
and the exact preview URL used elsewhere.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged — these are comments on the plan doc, not the implementation code. The plan is a reference artifact; the actual endpoint has require_verified_human_soft and rate limiting in place. Adding negative-path tests for human verification would require mocking the Turnstile cookie infrastructure in the test client, which is tested separately in the human verification test suite. Rate limit tests are similarly covered by the slowapi integration tests.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@thewrz, understood — that's a fair distinction. Since the plan doc is a reference artifact and the actual endpoint already enforces require_verified_human_soft and rate limiting, with coverage delegated to the dedicated Turnstile cookie and slowapi integration test suites respectively, there's no need to duplicate those negative-path cases here. Thanks for the context!


✏️ Learnings added
Learnt from: thewrz
Repo: thewrz/WrzDJ PR: 287
File: docs/superpowers/plans/2026-05-05-collect-song-preview.md:77-130
Timestamp: 2026-05-06T03:50:52.353Z
Learning: In the WrzDJ codebase, plan docs under `docs/superpowers/plans/` are reference artifacts only and are not expected to include exhaustive test coverage. Negative-path tests for human verification (Turnstile `wrzdj_human` cookie) are maintained in a separate human verification test suite, and rate-limiting tests are covered by slowapi integration tests — not duplicated in feature-level test files.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

Comment on lines +147 to +153
@limiter.limit("10/minute")
def request_preview(
code: str,
request_id: int,
request: Request,
_human: int | None = Depends(require_verified_human_soft),
db: Session = Depends(get_db),

@coderabbitai coderabbitai Bot May 6, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Specify and enforce rate-limit keying by guest_id (not implicit defaults).

@limiter.limit("10/minute") alone doesn’t document or guarantee that throttling is per guest_id. Since the objective is per-guest limiting, the plan should call out the key function/source explicitly and include a verification step so this doesn’t silently fall back to IP/global behavior.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/superpowers/plans/2026-05-05-collect-song-preview.md` around lines 147 -
153, The rate-limit decorator on request_preview currently uses the default
keying; update the limiter.limit call on request_preview to explicitly key on
guest_id (not IP/global) by passing the limiter's key_func/key argument that
extracts the guest_id from the request or from the require_verified_human_soft
dependency (e.g., a get_guest_id(request) helper), and add a runtime check in
request_preview (or the dependency) to verify guest_id is present (raise/log if
missing) so throttling cannot silently fall back to a global/IP key.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rate limiting uses the global key_func=get_client_ip configured in core/rate_limit.py:88 — consistent with every other collect endpoint. The plan doc's "per guest" language loosely maps to per-IP, which is the standard pattern across all public endpoints in this codebase. No change needed in the implementation.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@thewrz, understood — thanks for the clarification. Since the global key_func=get_client_ip in core/rate_limit.py is applied consistently across all public collect endpoints, the PR doc's "per guest" wording is simply informal shorthand for per-IP, and no explicit keying change is needed here.


✏️ Learnings added
Learnt from: thewrz
Repo: thewrz/WrzDJ PR: 287
File: docs/superpowers/plans/2026-05-05-collect-song-preview.md:147-153
Timestamp: 2026-05-06T03:50:56.859Z
Learning: In the WrzDJ codebase, rate limiting for all public collect endpoints uses a globally configured `key_func=get_client_ip` defined in `server/core/rate_limit.py` (around line 88). "Per guest" language in documentation/plans is informal shorthand for per-IP limiting, which is the standard pattern across all public endpoints. No per-request key_func override is needed on individual endpoint decorators.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

- Add title attribute to embed iframe for accessibility
- Add tests for Spotify, Tidal, Beatport, and manual preview branches
- Fixes branch coverage threshold (69.92% → 70.11%)

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@dashboard/app/collect/`[code]/components/CollectDetailSheet.tsx:
- Around line 49-57: Reset the stale preview state before starting and on failed
fetch: inside the useEffect that depends on code and row.id, call
setPreview(null) immediately before invoking apiClient.getCollectPreview(code,
row.id), and in the catch handler call setPreview(null) as well (while
preserving the cancelled flag logic). This ensures the preview state managed by
preview/setPreview is cleared when row.id or code changes and when
getCollectPreview throws.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 0d0cdee2-a419-49c1-9609-bdb877b67fee

📥 Commits

Reviewing files that changed from the base of the PR and between 341603c and 676a6fd.

📒 Files selected for processing (2)
  • dashboard/app/collect/[code]/components/CollectDetailSheet.test.tsx
  • dashboard/app/collect/[code]/components/CollectDetailSheet.tsx

Comment on lines +49 to +57
const [preview, setPreview] = useState<CollectPreviewResponse | null>(null);

useEffect(() => {
let cancelled = false;
apiClient.getCollectPreview(code, row.id).then((data) => {
if (!cancelled) setPreview(data);
}).catch(() => {});
return () => { cancelled = true; };
}, [code, row.id]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Clear stale preview state before refetching.

When row.id or code changes, the previous song's embed/link stays on screen until the new request resolves. Since getCollectPreview() throws on non-2xx in dashboard/lib/api.ts:1258-1265, the empty catch also leaves stale preview content visible indefinitely after a 403/404/429. Reset preview before starting the request and in the error path.

Proposed fix
  useEffect(() => {
    let cancelled = false;
+   setPreview(null);
    apiClient.getCollectPreview(code, row.id).then((data) => {
      if (!cancelled) setPreview(data);
-   }).catch(() => {});
+   }).catch(() => {
+     if (!cancelled) setPreview(null);
+   });
    return () => { cancelled = true; };
  }, [code, row.id]);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@dashboard/app/collect/`[code]/components/CollectDetailSheet.tsx around lines
49 - 57, Reset the stale preview state before starting and on failed fetch:
inside the useEffect that depends on code and row.id, call setPreview(null)
immediately before invoking apiClient.getCollectPreview(code, row.id), and in
the catch handler call setPreview(null) as well (while preserving the cancelled
flag logic). This ensures the preview state managed by preview/setPreview is
cleared when row.id or code changes and when getCollectPreview throws.

@thewrz thewrz merged commit c3d3bb6 into main May 6, 2026
9 checks passed
@thewrz thewrz deleted the feat/collect-preview-endpoint branch May 6, 2026 04:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant