Skip to content

feat(catalogs): artist_account_id filter + no-cap pagination on GET /api/catalogs/measurements - #763

Merged
sweetmantech merged 4 commits into
mainfrom
feat/catalog-measurements-artist-filter
Jul 7, 2026
Merged

feat(catalogs): artist_account_id filter + no-cap pagination on GET /api/catalogs/measurements#763
sweetmantech merged 4 commits into
mainfrom
feat/catalog-measurements-artist-filter

Conversation

@sweetmantech

@sweetmantechsweetmantech commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Implements the data plumbing v2: artist-scoped valuation api item of recoupable/app#1850 — the contract is recoupable/docs#267 (merge that first).

What changed (same read path as api#757, two behaviors)

1. Fix: the silent 1,000-row cap (P1)

The endpoint computed total_streams + the valuation band over at most 1,000 rows — the Supabase default limit — with no error. Live repro on a 2,679-song catalog: exactly 1,000 measurements / 4.83B streams / $39.3M mid vs SQL ground truth 2,679 / 16.31B / ≈$132.5M (3.4× undervalued): #757 (comment)

Now every read on the path paginates to exhaustion before dedupe/derivation:

  • lib/supabase/catalog_songs/selectCatalogSongTitles.ts.range() loop (the tracklist itself capped at 1,000)
  • lib/supabase/song_measurements/selectAllSongMeasurements.ts (new) — ISRC list chunked at 500 per query (bounded IN clause / URL length) and each chunk .range()-paginated to exhaustion, with a stable secondary order for deterministic pages
  • lib/supabase/song_artists/selectSongArtistIsrcs.ts (new) — .range() loop

2. Feature: optional artist_account_id query param

  • Validated as uuid (400 on malformed) in validateGetCatalogMeasurementsQuery
  • When present, the read covers only the catalog's songs linked to that artist account via song_artists (catalog_songs ∩ song_artists, computed in the new lib/catalog/resolveCatalogSongsInScope.ts); absent → whole catalog (unchanged behavior)
  • Response echoes the applied filter: new artist_account_id field (uuid when scoped, null when whole-catalog) so clients can verify the response scope before rendering artist-labeled numbers — pre-v2 deployments ignore the unknown param, and without the echo a client would show whole-catalog money under an artist label (the trust bug chat#1850 exists to kill)

Verification

  • TDD throughout — RED confirmed before GREEN for every unit (pagination via >1,000-row fixtures, chunking, intersection, validator, handler echo)
  • Full suite: 716 files / 3,945 tests passing
  • pnpm exec tsc --noEmit: 200 errors, all pre-existing in test files (0 new; 0 in touched files)
  • pnpm lint clean
  • Preview verification against the live [TEST] Full Roster Catalog (aggregate) fixture posted as a PR comment once the preview deploy is Ready

Merge order

recoupable/docs#267 (contract) → this PR → chat#1852 rework (hero consumes the filter + echo). Refs recoupable/app#1850.

🤖 Generated with Claude Code


Summary by cubic

Reworks GET /api/catalogs/{catalogId}/measurements to return a paginated page of latest-per-ISRC Spotify playcounts with whole-scope totals and a SQL-derived valuation (no row cap). Adds optional artist scoping and moves catalogId to the path.

  • New Features

    • Add artist_account_id (UUID; 400 on malformed) to scope results; echoed as UUID or null.
    • Add page and limit (default 1/50, max 100; 400 on invalid). Response includes measurements, pagination, measured_song_count, total_streams, valuation, artist_account_id, and catalog_age_years.
  • Refactors

    • Endpoint now uses a path param: GET /api/catalogs/{catalogId}/measurements. Validator takes the path id (UUID) and ignores any catalogId in the query.
    • Auth moved into the validator; unauthenticated requests now 401 before param validation errors.
    • Replace looped reads with SQL RPCs: get_catalog_measurements_aggregate for whole-scope totals and get_catalog_measurements_page for the page (sorted by playcount desc). Aggregates cover the entire scope regardless of page size.

Written for commit cfed6de. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features

    • Added a catalog measurements endpoint that supports CORS preflight and returns measurement data by catalog.
    • Added pagination and optional artist filtering for measurements results.
    • Included summary metrics such as total streams, measured song count, valuation, and artist account details in the response.
  • Bug Fixes

    • Improved validation and error handling for invalid requests.
    • Added safer handling when measurement data is unavailable.

…api/catalogs/measurements (chat#1850)
Two fixes on the same read path (v2 of api#757):
- FIX the silent 1,000-row cap: the catalog tracklist, the measurement
series, and the artist-link read all paginate to exhaustion with
.range() loops (and the measurements IN clause is chunked at 500 ISRCs
to stay under URL limits) BEFORE dedupe/derivation, so total_streams
and the valuation band cover every measured song. Live repro was a
2,679-song catalog valued 3.4x low off exactly 1,000 rows.
- ADD optional artist_account_id (uuid, 400 on malformed): scopes
measurements + valuation to the catalog's songs linked to that artist
account via song_artists (catalog_songs ∩ song_artists). The response
echoes the applied filter as artist_account_id (null when unfiltered)
so clients can verify the scope before rendering artist-labeled money.
Per the docs contract in recoupable/docs#267. TDD: RED confirmed before
GREEN for every unit; full suite 3,945 tests, tsc 0 new errors, lint clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@cursor

cursorBot commented Jul 7, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@vercel

vercelBot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentActionsUpdated (UTC)
apiReadyReadyPreviewJul 7, 2026 1:17pm

Request Review

@coderabbitai

coderabbitaiBot commented Jul 7, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The catalog measurements API moves from a flat query-parameter route to a catalog-scoped dynamic route. The handler and validator now accept a catalogId path parameter plus optional pagination and artist filtering, and data retrieval switches from in-process ISRC aggregation to two new Supabase RPC-backed selectors returning aggregate and paged results.

Changes

Catalog Measurements Refactor

Layer / File(s)Summary
New RPC-backed measurement selectors
lib/supabase/song_measurements/selectCatalogMeasurementsAggregate.ts, lib/supabase/song_measurements/selectCatalogMeasurementsPage.ts
New selectors call Supabase RPCs (get_catalog_measurements_aggregate, get_catalog_measurements_page) to fetch aggregate stream/song counts and paged measurement rows, optionally scoped by artist account.
Query validation with auth and path catalogId
lib/catalog/validateGetCatalogMeasurementsQuery.ts
Validation now requires validateAuthContext, accepts a NextRequest plus path catalogId, adds optional artist_account_id/page/limit schema fields, and returns accountId in the result.
Handler rewired to aggregate/page selectors and pagination response
lib/catalog/getCatalogMeasurementsHandler.ts
Handler accepts catalogIdParam, fetches aggregate, page, and earliest release date concurrently, returns 500 on missing data, and computes a response with pagination, measured_song_count, total_streams, valuation, and artist_account_id.
Catalog-scoped route and removal of flat route
app/api/catalogs/[catalogId]/measurements/route.ts, app/api/catalogs/measurements/route.ts (removed), lib/catalog/latestMeasurementsPerIsrc.ts (removed), lib/supabase/catalog_songs/selectCatalogSongTitles.ts (removed)
New route resolves catalogId from path params and delegates to the handler; the old flat route and in-process ISRC/title helpers are deleted.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
participant Client
participant Route as /api/catalogs/[catalogId]/measurements
participant Validator as validateGetCatalogMeasurementsQuery
participant Handler as getCatalogMeasurementsHandler
participant Supabase as Supabase RPC
Client->>Route: GET with catalogId, page, limit, artist_account_id
Route->>Handler: getCatalogMeasurementsHandler(request, catalogId)
Handler->>Validator: validateGetCatalogMeasurementsQuery(request, catalogId)
Validator->>Validator: validateAuthContext + schema parse
Validator-->>Handler: accountId, catalogId, page, limit, artist_account_id
Handler->>Supabase: selectCatalogMeasurementsAggregate
Handler->>Supabase: selectCatalogMeasurementsPage
Supabase-->>Handler: aggregate + page rows
Handler-->>Client: pagination, measured_song_count, total_streams, valuation
Loading

Estimated code review effort: 3 (Moderate) | ~25 minutes

Poem

A rabbit hopped through catalog land,
Swapped ISRC maps for RPCs planned,
Pages now come with limits and skip,
Aggregates counted in one clean trip,
New paths, new params — a tidier grip! 🐇📊

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Solid & Clean Code⚠️ Warningroute.ts exports GET+OPTIONS instead of one named primary function, and getCatalogMeasurementsHandler is a 78-line multi-responsibility handler.Split auth/lookup/query/response work into smaller helpers, and either move route methods into named modules or explicitly exempt Next route files from the naming rule.
✅ Passed checks (2 passed)
Check nameStatusExplanation
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ 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/catalog-measurements-artist-filter

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@cubic-dev-aicubic-dev-aiBot 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.

6 issues found across 12 files

Confidence score: 2/5

  • In lib/supabase/catalog_songs/selectCatalogSongTitles.ts and lib/supabase/song_artists/selectSongArtistIsrcs.ts, pagination errors on later pages currently drop already-fetched results and return [], which is indistinguishable from a truly empty catalog and can silently undercount downstream data — preserve partial results or propagate an explicit error state before merging.
  • In lib/supabase/song_measurements/selectAllSongMeasurements.ts, any page/chunk failure can cause GET /api/catalogs/measurements to look like zero data/zero valuation, creating a concrete user-facing misreporting risk — return a typed failure (or throw) so callers can surface an error instead of treating it as empty data.
  • In lib/catalog/getCatalogMeasurementsHandler.ts, artist-scoped valuation mixes artist-filtered stream totals with catalog-wide age, which can skew outputs for scoped views — derive catalog_age_years from the same scoped song set before merge to keep calculations consistent.
  • Tests in lib/supabase/song_measurements/__tests__/selectAllSongMeasurements.test.ts miss coverage for the secondary id sort assertion and for verifying console.error emission, so regressions in deterministic pagination/error observability may slip through — tighten these assertions to de-risk future changes.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="lib/catalog/getCatalogMeasurementsHandler.ts">
<violation number="1" location="lib/catalog/getCatalogMeasurementsHandler.ts:48">
P2: Artist-scoped valuation can be skewed because the stream total is filtered to the artist while `catalog_age_years` still comes from the whole catalog. Consider resolving the earliest release date from the scoped song set (or a scoped album/source lookup) when `artist_account_id` is provided.</violation>
</file>
Architecture diagram
sequenceDiagram
participant Client as API Client
participant Handler as getCatalogMeasurementsHandler
participant Validator as validateGetCatalogMeasurementsQuery
participant Auth as validateAuthContext
participant DBCatalog as selectAccountCatalog
participant Resolver as resolveCatalogSongsInScope
participant DBSongs as selectCatalogSongTitles
participant DBArtists as selectSongArtistIsrcs
participant DBMeas as selectAllSongMeasurements
Note over Handler: NEW: pagination + artist filter + response echo
Client->>Handler: GET /api/catalogs/measurements?catalogId=&artist_account_id=
Handler->>Validator: validate query params
Validator-->>Handler: {catalogId, artist_account_id} or 400
alt malformed artist_account_id (not uuid)
Handler-->>Client: 400 error
end
Handler->>Auth: validateAuthContext(request)
Auth-->>Handler: {accountId}
Handler->>DBCatalog: selectAccountCatalog({accountId, catalogId})
DBCatalog-->>Handler: catalog link or null
alt catalog not found or forbidden
Handler-->>Client: 404
end
Note over Handler: NEW: resolve scope (whole catalog vs artist filtered)
Handler->>Resolver: resolveCatalogSongsInScope({catalogId, artistAccountId})
Resolver->>DBSongs: CHANGED: selectCatalogSongTitles(catalogId)
Note over DBSongs: Paginates past 1,000-row default. Loops .range() until <1000 rows.
DBSongs-->>Resolver: all song {isrc, title} pairs
alt artistAccountId provided
Resolver->>DBArtists: NEW: selectSongArtistIsrcs(artistAccountId)
Note over DBArtists: Also paginates with .range() to exhaustion.
DBArtists-->>Resolver: ISRCs linked to artist
Note over Resolver: Intersect catalog songs with artist ISRCs
end
Resolver-->>Handler: songs in scope (possibly empty)
Note over Handler: CHANGED: empty scope skips measurement fetch
alt songs empty
Handler->>Handler: derive zero totals, zero valuation band
else songs non-empty
Handler->>DBMeas: NEW: selectAllSongMeasurements(songs, platform, metric)
Note over DBMeas: Chunks ISRC list (500 per chunk).<br/>Each chunk paginated with .range() to exhaustion.<br/>Order: captured_at descending, id descending.
DBMeas-->>Handler: all measurement rows
Handler->>Handler: latest per ISRC, total_streams, catalog_age_years, valuation band
end
Note over Handler: NEW: echo applied filter in response
Handler-->>Client: {measurements, total_streams, valuation, artist_account_id, catalog_age_years}
Loading

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment threadlib/supabase/catalog_songs/selectCatalogSongTitles.ts Outdated
Comment threadlib/supabase/song_artists/selectSongArtistIsrcs.ts Outdated
Comment threadlib/supabase/song_measurements/selectAllSongMeasurements.ts Outdated
Comment threadlib/supabase/song_measurements/__tests__/selectAllSongMeasurements.test.ts Outdated
}

const songs = await selectCatalogSongTitles(catalogId);
const songs = await resolveCatalogSongsInScope({ catalogId, artistAccountId });

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: Artist-scoped valuation can be skewed because the stream total is filtered to the artist while catalog_age_years still comes from the whole catalog. Consider resolving the earliest release date from the scoped song set (or a scoped album/source lookup) when artist_account_id is provided.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/catalog/getCatalogMeasurementsHandler.ts, line 48:
<comment>Artist-scoped valuation can be skewed because the stream total is filtered to the artist while `catalog_age_years` still comes from the whole catalog. Consider resolving the earliest release date from the scoped song set (or a scoped album/source lookup) when `artist_account_id` is provided.</comment>
<file context>
@@ -34,18 +38,18 @@ export async function getCatalogMeasurementsHandler(request: NextRequest): Promi
}
- const songs = await selectCatalogSongTitles(catalogId);
+ const songs = await resolveCatalogSongsInScope({ catalogId, artistAccountId });
const titles = new Map(songs.map(s => [s.isrc, s.title]));
const [rows, earliestReleaseDate] = await Promise.all([
</file context>

Comment threadlib/supabase/song_measurements/__tests__/selectAllSongMeasurements.test.ts Outdated
@sweetmantech

Copy link
Copy Markdown
ContributorAuthor

Preview verification — full authed pass ✅

Preview deployment for 637d8b8a (Ready): https://api-kwowjfmvg-recoup.vercel.app

Ground truth computed with read-only SQL against the shared prod DB (latest capture per ISRC over catalog_songs ∩ song_artists, platform=spotify, metric=platform_displayed_play_count) for the live fixture catalog 7d3e5c35-1fac-4b88-aa90-e2e4a52dfe78 "[TEST] Full Roster Catalog (aggregate)". Note the exact SQL numbers vs the issue's approximations: J Alvarez is 608 songs / 6,717,937,120 streams (issue said ≈610 / 6.81B).

Authed checks (bearer for the owning test account)

ScopeSQL ground truth (measured / total_streams)Endpoint actual (measurements / total_streams)Echo artist_account_idvaluation.midMatch
Whole catalog (no filter)2,679 / 16,308,837,4412,679 / 16,308,837,441null$132,456,300✅ exact
Elvis Crespo b1814076-8e19-4a77-9dea-2ec150e26aaa322 / 1,799,402,184322 / 1,799,402,184b1814076…$14,614,295✅ exact
September Mourning d85f9606-c574-4cf3-bfd2-0b82c02b0a7840 / 21,325,66340 / 21,325,663d85f9606…$173,202✅ exact
Apache ebae4bb9-e38f-4763-b3c8-ff30e99f5d01208 / 742,453,110208 / 742,453,110ebae4bb9…$6,030,019✅ exact
J Alvarez 6f1d797f-5d2f-4754-8c92-d0f491c7bc2f608 / 6,717,937,120608 / 6,717,937,1206f1d797f…$54,561,406✅ exact

catalog_age_years: 5 in all responses (default fallback — the fixture's songs weren't materialized from a source run with resolvable album release dates).

  • Cap fix confirmed: the unfiltered read returns all 2,679 measurements / 16.31B streams — vs 1,000 / 4.83B / $39.3M mid on the pre-fix endpoint (repro), a 3.4× undervaluation now gone.
  • Filter + echo confirmed: per-artist scopes return exactly the catalog_songs ∩ song_artists intersection and echo the applied filter; unfiltered echoes null.

Unauthed checks

CheckExpectedActual
GET missing catalogId400✅ 400 catalogId parameter is required
GET catalogId=not-a-uuid400✅ 400 catalogId must be a valid UUID
GET catalogId=<valid>&artist_account_id=not-a-uuid400✅ 400 artist_account_id must be a valid UUID
GET valid params, no credentials401✅ 401
OPTIONS200✅ 200

Nothing pending: the test-account bearer authenticated against the preview (prod rejected it — previews use a different key salt — so the earlier prod 401 was salt, not expiry), so the full authed Done-when pass ran.

🤖 Generated with Claude Code

…aller-paginated page (chat#1850)
Amendment per the 2026-07-07 decision on chat#1850: the app-code
.range() loop-to-exhaustion is rejected. The read path becomes:
- aggregates (measured_song_count, total_streams -> valuation band) from
the get_catalog_measurements_aggregate RPC: one SQL aggregate over the
latest-per-ISRC subquery, whole scope, no row cap
- the measurements array from the get_catalog_measurements_page RPC:
page/limit params (default 1/50, limit max 100, 400 on invalid), rows
sorted by playcount desc, same pagination envelope as
GET /api/catalogs/songs
- artist_account_id filter + response echo unchanged
Both RPCs ship in recoupable/database#42 (applied to the shared project).
Deletes the loop-based helpers (selectAllSongMeasurements,
selectSongArtistIsrcs, selectCatalogSongTitles, resolveCatalogSongsInScope,
latestMeasurementsPerIsrc) whose only consumer was this handler.
TDD: RED confirmed before GREEN per unit; full suite 3,940 tests,
tsc 200 pre-existing errors (0 new), lint clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@cursor

cursorBot commented Jul 7, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@cubic-dev-aicubic-dev-aiBot 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.

2 issues found across 19 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="lib/catalog/getCatalogMeasurementsHandler.ts">
<violation number="1" location="lib/catalog/getCatalogMeasurementsHandler.ts:65">
P3: This handler now exceeds the repository's `lib/` 50-line function limit after adding pagination/aggregate response assembly. Consider extracting the measurement response-building or read orchestration into a small catalog-domain helper so the API handler stays within the documented boundary.</violation>
</file>
<file name="lib/supabase/song_measurements/__tests__/selectCatalogMeasurementsPage.test.ts">
<violation number="1" location="lib/supabase/song_measurements/__tests__/selectCatalogMeasurementsPage.test.ts:62">
P3: The `consoleError.mockRestore()` call in the body of the 'returns null on error' test runs only when the assertion passes. If it fails, the `console.error` spy leaks — other tests run with a silenced error logger. Since `restoreMocks` is not enabled in the vitest config and `clearAllMocks` doesn't restore spies, use `afterEach(() => consoleError.mockRestore())` or wrap the test body in `try/finally` for leak-proof cleanup.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

@@ -3,23 +3,28 @@ import { errorResponse } from "@/lib/networking/errorResponse";
import { successResponse } from "@/lib/networking/successResponse";

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: This handler now exceeds the repository's lib/ 50-line function limit after adding pagination/aggregate response assembly. Consider extracting the measurement response-building or read orchestration into a small catalog-domain helper so the API handler stays within the documented boundary.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/catalog/getCatalogMeasurementsHandler.ts, line 65:
<comment>This handler now exceeds the repository's `lib/` 50-line function limit after adding pagination/aggregate response assembly. Consider extracting the measurement response-building or read orchestration into a small catalog-domain helper so the API handler stays within the documented boundary.</comment>
<file context>
@@ -38,36 +39,38 @@ export async function getCatalogMeasurementsHandler(request: NextRequest): Promi
return successResponse({
measurements,
+ pagination: {
+ total_count: aggregate.measuredSongCount,
+ page,
</file context>

} as never);

expect(await selectCatalogMeasurementsPage({ catalogId, page: 1, limit: 50 })).toBeNull();
consoleError.mockRestore();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: The consoleError.mockRestore() call in the body of the 'returns null on error' test runs only when the assertion passes. If it fails, the console.error spy leaks — other tests run with a silenced error logger. Since restoreMocks is not enabled in the vitest config and clearAllMocks doesn't restore spies, use afterEach(() => consoleError.mockRestore()) or wrap the test body in try/finally for leak-proof cleanup.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/supabase/song_measurements/__tests__/selectCatalogMeasurementsPage.test.ts, line 62:
<comment>The `consoleError.mockRestore()` call in the body of the 'returns null on error' test runs only when the assertion passes. If it fails, the `console.error` spy leaks — other tests run with a silenced error logger. Since `restoreMocks` is not enabled in the vitest config and `clearAllMocks` doesn't restore spies, use `afterEach(() => consoleError.mockRestore())` or wrap the test body in `try/finally` for leak-proof cleanup.</comment>
<file context>
@@ -0,0 +1,64 @@
+ } as never);
+
+ expect(await selectCatalogMeasurementsPage({ catalogId, page: 1, limit: 50 })).toBeNull();
+ consoleError.mockRestore();
+ });
+});
</file context>

@sweetmantech

Copy link
Copy Markdown
ContributorAuthor

Preview verification — amended shape (03ed53c2) — supersedes the earlier results table

The previous comment verified the loop-based implementation (since replaced per the 2026-07-07 chat#1850 decision). Its per-scope numbers remain valid as ground truth; the response shape changed to whole-scope SQL aggregates + a caller-paginated measurements array.

Preview deployment for 03ed53c2 (Ready): https://api-prrt752vv-recoup.vercel.app

Unauthed checks (run against the preview) — all pass

CheckExpectedActual
missing catalogId400✅ 400 catalogId parameter is required
catalogId=not-a-uuid400✅ 400 catalogId must be a valid UUID
artist_account_id=not-a-uuid400✅ 400 artist_account_id must be a valid UUID
page=0400✅ 400 page must be positive
limit=101400✅ 400 limit must be at most 100
limit=abc400✅ 400
valid params, no credentials401✅ 401
OPTIONS200✅ 200

Aggregate correctness (verified at the SQL layer — the exact functions the handler calls)

The RPCs from recoupable/database#42 (applied to the shared project) were executed directly against the live fixture catalog 7d3e5c35-1fac-4b88-aa90-e2e4a52dfe78; every scope matches the independent read-only SQL ground truth exactly:

Scopemeasured_song_counttotal_streamsMatch
whole catalog2,67916,308,837,441✅ exact
Elvis Crespo b1814076…3221,799,402,184✅ exact
September Mourning d85f9606…4021,325,663✅ exact
Apache ebae4bb9…208742,453,110✅ exact
J Alvarez 6f1d797f…6086,717,937,120✅ exact

get_catalog_measurements_page(catalog, NULL, 3, 0) returns playcount-desc rows (top: USUYG1075006 717,012,221). The handler layer over these RPCs (pagination envelope math, echo field, 500s on RPC failure) is unit-tested: full suite 3,940 passing.

Pending an authed pass

The /tmp/test_bearer token for the owning test account has now expired (401 on the preview; it authenticated yesterday's loop-based pass). Pending until a fresh bearer is available: end-to-end authed 200s on this preview confirming the HTTP-level envelope (measurements page + pagination + measured_song_count + echo) against the numbers above. Given the RPCs are verified exact at the SQL layer and the handler is fully unit-tested, the remaining risk is wiring-level only.

🤖 Generated with Claude Code

…catalogId]/measurements (chat#1850)
REST amendment on chat#1850: path for identity, query for modifiers —
mirrors the research measurements family
(app/api/research/albums/[id]/measurements). The route becomes a dynamic
segment; the validator takes the path id (uuid, 400 on malformed; a
catalogId smuggled into the query string is ignored) and the query
carries only artist_account_id/page/limit. A missing id no longer
routes (404 from Next), so the 'catalogId is required' 400 is gone.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@cursor

cursorBot commented Jul 7, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@sweetmantech

Copy link
Copy Markdown
ContributorAuthor

Follow-up: endpoint renamed to GET /api/catalogs/{catalogId}/measurements (0e358f04)

Path for identity, query for modifiers (REST decision on recoupable/app#1850) — mirrors the repo's own measurements family (/api/research/albums/{id}/measurements). catalogId is now the dynamic path segment; the query carries only artist_account_id / page / limit. A missing id no longer routes; a malformed uuid in the path is a 400.

Re-verified on the preview for 0e358f04 (https://api-dvt4a7vey-recoup.vercel.app):

CheckExpectedActual
old flat path /api/catalogs/measurements?catalogId=…404 (unrouted)✅ 404
/api/catalogs/not-a-uuid/measurements400✅ 400 catalogId must be a valid UUID
artist_account_id=not-a-uuid400✅ 400
page=0400✅ 400
limit=101400✅ 400
valid path, no credentials401✅ 401
OPTIONS200✅ 200

Suite 3,940 passing / tsc 0 new / lint clean. The authed end-to-end pass remains pending a fresh bearer (see the previous comment); aggregate correctness is already verified exact at the RPC/SQL layer.

🤖 Generated with Claude Code

@cubic-dev-aicubic-dev-aiBot 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.

0 issues found across 6 files (changes from recent commits).

Requires human review: Auto-approval blocked by 3 unresolved issues from previous reviews.

Re-trigger cubic

Comment on lines 35 to 44
@@ -34,36 +43,39 @@ export async function getCatalogMeasurementsHandler(request: NextRequest): Promi
return authResult;
}

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

SRP

  • actual: auth validation is in the handler
  • required: auth validation is in validateGetCatalogMeasurementsQuery

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Fixed in cfed6de4validateGetCatalogMeasurementsQuery now owns auth (first) + input validation and returns { accountId, ...params }, matching the measurements-family validator convention (validateGetResearchTrackHistoricStatsRequest et al); the handler just destructures. One behavior note: an unauthenticated request with malformed params now returns 401 before the 400 param check (auth-first, same as the research siblings — the earlier verification tables showed 400 for those unauthed cases). Suite 3,940 passing, tsc 0 new, prettier clean.

…y (SRP)
Review feedback on #763: the handler was doing auth validation itself.
The validator now owns both auth and input validation (auth first),
matching the measurements-family validator convention
(validateGetResearchTrackHistoricStatsRequest et al) and returns
{ accountId, ...params }. Behavior note: an unauthenticated request with
malformed params now 401s before the 400 param check, same as the
research siblings.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@cursor

cursorBot commented Jul 7, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@coderabbitaicoderabbitaiBot 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

🧹 Nitpick comments (1)
lib/catalog/validateGetCatalogMeasurementsQuery.ts (1)

6-29: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Prefer z.uuid() herez.string({ message: ... }).uuid(...) is deprecated in Zod v4; switch to the top-level UUID helper (or z.guid() if you need the older permissive matcher).

🤖 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 `@lib/catalog/validateGetCatalogMeasurementsQuery.ts` around lines 6 - 29, The
UUID validation in getCatalogMeasurementsQuerySchema still uses the deprecated
z.string(...).uuid() pattern for catalogId and artist_account_id. Update those
fields to use the top-level UUID helper in the schema while keeping the same
required/custom error messaging behavior, and leave the page and limit
validation logic unchanged.
🤖 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 `@lib/catalog/getCatalogMeasurementsHandler.ts`:
- Around line 46-58: The release-date lookup in getCatalogMeasurementsHandler is
not scoped the same way as the aggregate and page queries, so valuation band
calculations can mix artist-filtered streams with a catalog-wide age. Update the
Promise.all call to use an artist-aware release-date lookup (or pass
artistAccountId through to getCatalogEarliestReleaseDate) so
computeValuationBand receives earliestReleaseDate that matches the same artist
filter used by selectCatalogMeasurementsAggregate and
selectCatalogMeasurementsPage.
---
Nitpick comments:
In `@lib/catalog/validateGetCatalogMeasurementsQuery.ts`:
- Around line 6-29: The UUID validation in getCatalogMeasurementsQuerySchema
still uses the deprecated z.string(...).uuid() pattern for catalogId and
artist_account_id. Update those fields to use the top-level UUID helper in the
schema while keeping the same required/custom error messaging behavior, and
leave the page and limit validation logic unchanged.
🪄 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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 2b9d774f-efe3-4f85-aac4-630c4b0fb732

📥 Commits

Reviewing files that changed from the base of the PR and between 7ed9338 and cfed6de.

⛔ Files ignored due to path filters (7)
  • lib/catalog/__tests__/getCatalogMeasurementsHandler.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/catalog/__tests__/latestMeasurementsPerIsrc.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/catalog/__tests__/validateGetCatalogMeasurementsQuery.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/supabase/catalog_songs/__tests__/selectCatalogSongTitles.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/supabase/song_measurements/__tests__/selectCatalogMeasurementsAggregate.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/supabase/song_measurements/__tests__/selectCatalogMeasurementsPage.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • types/database.types.ts is excluded by none and included by none
📒 Files selected for processing (8)
  • app/api/catalogs/[catalogId]/measurements/route.ts
  • app/api/catalogs/measurements/route.ts
  • lib/catalog/getCatalogMeasurementsHandler.ts
  • lib/catalog/latestMeasurementsPerIsrc.ts
  • lib/catalog/validateGetCatalogMeasurementsQuery.ts
  • lib/supabase/catalog_songs/selectCatalogSongTitles.ts
  • lib/supabase/song_measurements/selectCatalogMeasurementsAggregate.ts
  • lib/supabase/song_measurements/selectCatalogMeasurementsPage.ts
💤 Files with no reviewable changes (3)
  • lib/supabase/catalog_songs/selectCatalogSongTitles.ts
  • app/api/catalogs/measurements/route.ts
  • lib/catalog/latestMeasurementsPerIsrc.ts

Comment on lines +46 to 58
const [aggregate, measurements, earliestReleaseDate] = await Promise.all([
selectCatalogMeasurementsAggregate({ catalogId, artistAccountId }),
selectCatalogMeasurementsPage({ catalogId, artistAccountId, page, limit }),
getCatalogEarliestReleaseDate(catalogId),
]);
if (!aggregate || !measurements) {
return errorResponse("Internal server error", 500);
}

const { measurements, totalStreams } = latestMeasurementsPerIsrc(rows, titles);
const { valuation, catalogAgeYears } = computeValuationBand({
totalStreams,
totalStreams: aggregate.totalStreams,
earliestReleaseDate,
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash# Description: Check whether getCatalogEarliestReleaseDate supports artist scoping.
ast-grep run --pattern 'export async function getCatalogEarliestReleaseDate($$$) { $$$ }' --lang typescript lib
rg -n -A5 'function getCatalogEarliestReleaseDate' lib

Repository: recoupable/api

Length of output: 790


🏁 Script executed:

#!/bin/bashset -euo pipefail
sed -n '1,220p' lib/catalog/getCatalogMeasurementsHandler.ts
printf'\n---\n'
sed -n '1,220p' lib/catalog/getCatalogEarliestReleaseDate.ts
printf'\n---\n'
rg -n "getCatalogEarliestReleaseDate|earliestReleaseDate|catalog_age_years|valuation" lib/catalog lib -g '*.ts'

Repository: recoupable/api

Length of output: 20186


🏁 Script executed:

#!/bin/bashset -euo pipefail
sed -n '1,220p' lib/supabase/song_measurements/selectCatalogMeasurementsAggregate.ts
printf'\n---\n'
sed -n '1,220p' lib/supabase/song_measurements/selectCatalogMeasurementsPage.ts
printf'\n---\n'
sed -n '1,220p' lib/catalog/__tests__/getCatalogMeasurementsHandler.test.ts
printf'\n---\n'
sed -n '1,220p' lib/catalog/computeValuationBand.ts

Repository: recoupable/api

Length of output: 13429


🏁 Script executed:

#!/bin/bashset -euo pipefail
rg -n "artist_account_id|artistAccountId" lib/catalog lib/supabase marketing -g '*.ts'

Repository: recoupable/api

Length of output: 8485


Scope the release-date lookup to the same artist filter as the measurement totals.selectCatalogMeasurementsAggregate/selectCatalogMeasurementsPage honor artist_account_id, but getCatalogEarliestReleaseDate(catalogId) does not. That mixes artist-scoped streams with a catalog-wide age when computing valuation and catalog_age_years, so filtered responses can show a misleading band. Pass artistAccountId through here or use an artist-scoped release-date helper.

🤖 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 `@lib/catalog/getCatalogMeasurementsHandler.ts` around lines 46 - 58, The
release-date lookup in getCatalogMeasurementsHandler is not scoped the same way
as the aggregate and page queries, so valuation band calculations can mix
artist-filtered streams with a catalog-wide age. Update the Promise.all call to
use an artist-aware release-date lookup (or pass artistAccountId through to
getCatalogEarliestReleaseDate) so computeValuationBand receives
earliestReleaseDate that matches the same artist filter used by
selectCatalogMeasurementsAggregate and selectCatalogMeasurementsPage.

@cubic-dev-aicubic-dev-aiBot 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.

0 issues found across 4 files (changes from recent commits).

Requires human review: Auto-approval blocked by 3 unresolved issues from previous reviews.

Re-trigger cubic

@sweetmantech

Copy link
Copy Markdown
ContributorAuthor

Final preview verification — cfed6de4 (SRP refactor) — full authed pass ✅

Preview: https://api-4qdotxcu3-recoup.vercel.app (deployment for this head, Ready). Fresh bearer for the owning test account (value not recorded). This supersedes the 0e358f04 table and completes the previously-pending authed HTTP pass.

Authed happy paths — every value exact vs SQL ground truth

Scopemeasured_song_counttotal_streamsvaluation.midechopagination
whole catalog2,67916,308,837,441$132,456,300null50 rows, total_pages: 54
Elvis Crespo b1814076…3221,799,402,184$14,614,295b1814076…
September Mourning d85f9606… (limit=1)4021,325,663d85f9606…1 row, total_pages: 40
whole, page=2&limit=102,679 ✅10 rows, total_pages: 268

Top row playcount-desc: USUYG1075006 717,012,221 — matches the RPC-layer check. The 1,000-row-cap regression is definitively gone at the HTTP layer (2,679 > 1,000 through the full route wiring).

Error semantics after the auth-first SRP refactor (review discussion_r3536632018)

CheckExpected (new ordering)Actual
bad path uuid, no auth401 (was 400 pre-refactor)✅ 401
valid path, no auth401✅ 401
old flat route404✅ 404
OPTIONS200✅ 200
bad path uuid, authed400 catalogId must be a valid UUID✅ 400
bad artist_account_id, authed400✅ 400
page=0 / limit=101, authed400✅ 400
unknown catalog, authed404✅ 404

Docs ↔ API ↔ live reconciled: response fields match docs#267 (merged) field-for-field; no undocumented fields observed. Nothing pending — ready to merge.

🤖 Generated with Claude Code

Sign up for freeto 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

@sweetmantech