Skip to content

feat(catalogs): GET /api/catalogs/measurements — per-ISRC latest playcounts + derived valuation band (chat#1850)#757

Merged
sweetmantech merged 2 commits into
mainfrom
feat/catalog-measurements-endpoint
Jul 6, 2026
Merged

feat(catalogs): GET /api/catalogs/measurements — per-ISRC latest playcounts + derived valuation band (chat#1850)#757
sweetmantech merged 2 commits into
mainfrom
feat/catalog-measurements-endpoint

Conversation

@sweetmantech

@sweetmantech sweetmantech commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

What

Implements GET /api/catalogs/measurements?catalogId= per the merged-first docs contract recoupable/docs#265 — the data plumbing for the Catalog HQ homepage hero in recoupable/chat#1850 (deferred from chat#1801 / chat#1803).

Merge order: docs#265 (contract) → this PR. Base is main per api#750 (the test-branch PR flow is retired; repo CLAUDE.md says never target test).

How it works

  • Auth / no IDOR: account resolved via validateAuthContext (Privy bearer or x-api-key); ownership checked through account_catalogs — a catalog that doesn't exist or belongs to another account is an indistinguishable 404.
  • Measurements: catalog_songs → songs (isrc + title) → song_measurements filtered to spotify / platform_displayed_play_count, reduced to the newest capture per ISRC (latestMeasurementsPerIsrc), sorted playcount-desc. Never-measured songs are omitted (per contract).
  • Valuation band { low, mid, high }: derived at read time (not persisted, per the chat#1850 architecture decision) in lib/catalog/computeValuationBand.ts, mirroring the marketing card's exact model (marketing/lib/valuation/computeCatalogValuation.ts + nlsBandFromSpotifyGross.ts): annual run-rate = lifetime streams ÷ catalog age (lifetime-average proxy) → NLS ($0.0035/stream × 1.25/1.4/1.6 gross-up × 0.85 × 0.75) → 10×/13×/16× multiples.
  • Catalog age: release dates aren't persisted anywhere in the DB, so getCatalogEarliestReleaseDate reads album_ids off the catalog's newest playcount_snapshots row and fetches release dates from Spotify (new batched lib/spotify/getAlbums.ts, 20 ids/request, ~2 requests for a typical catalog). Best-effort: any gap falls back to the marketing card's documented default_5y age, surfaced as catalog_age_years in the response. This is the one input that can differ from a given marketing run (e.g. ISRC-scoped snapshots have no album_ids) — documented rather than faked.

Files

Route app/api/catalogs/measurements/route.ts → handler lib/catalog/getCatalogMeasurementsHandler.ts → validate (validateGetCatalogMeasurementsQuery, zod uuid) → data fns (lib/supabase/account_catalogs/selectAccountCatalog.ts, lib/supabase/catalog_songs/selectCatalogSongTitles.ts, existing selectSongMeasurements + selectPlaycountSnapshots extended with a catalog filter) → pure shaping (latestMeasurementsPerIsrc, computeValuationBand). One exported function per file; all files < 100 LOC.

Testing (TDD red → green per unit)

  • 6 new test files + 1 extended, 22 new tests; full suite: 713 files / 3923 tests pass.
  • pnpm exec tsc --noEmit: no new errors (200 pre-existing on main, all in test files excluded from next build; identical count with this branch).
  • eslint --fix clean; prettier --check clean.
  • Preview verification results to follow in a PR comment.

Error paths (as documented in the contract)

400 missing/invalid catalogId · 401 unauthed · 404 unknown/foreign catalog · 500 unexpected.

🤖 Generated with Claude Code


Summary by cubic

Adds GET /api/catalogs/measurements?catalogId= to return latest per-ISRC Spotify playcounts plus a read-time valuation band for the catalog. Secures access by account and matches the marketing card’s valuation model.

  • New Features
    • Latest platform_displayed_play_count per ISRC from song_measurements (sorted desc); unmeasured songs omitted.
    • Auth + ownership enforced via validateAuthContext and account_catalogs; foreign/unknown catalogs return 404.
    • Returns { measurements, valuation: { low, mid, high }, total_streams, catalog_age_years }.
    • Catalog age pulled from Spotify album release dates on the newest snapshot (batched getAlbums, 20 ids/request) with a 5y fallback.
    • CORS preflight supported; errors: 400 invalid catalogId, 401 unauthenticated, 404 not found, 500 on unexpected errors.

Written for commit 621c169. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features

    • Added a catalog measurements API endpoint that returns measurement data and derived valuation insights.
    • Added support for resolving a catalog’s earliest release date and calculating a valuation band from stream data.
    • Improved album and track measurement aggregation, including total streams and sorted latest results.
  • Bug Fixes

    • Added request validation and clearer error handling for catalog measurements requests.
    • Added CORS support for preflight requests on the new measurements endpoint.
    • Expanded catalog filtering so playcount snapshot queries can be narrowed by catalog.

…counts + derived valuation band (chat#1850)

Implements the docs#265 contract: catalog ownership from auth (no IDOR),
catalog_songs → latest spotify platform_displayed_play_count per ISRC,
and a valuation band { low, mid, high } derived at read time with the
marketing card's exact model (lifetime-average run-rate over catalog age
from the source run's Spotify release dates, NLS, 10/13/16x multiples).

TDD red→green per unit; full suite 3923 pass; tsc adds no new errors;
lint + prettier clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@cursor

cursor Bot commented Jul 6, 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

vercel Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

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

Project Deployment Actions Updated (UTC)
api Ready Ready Preview Jul 6, 2026 8:06pm

Request Review

@coderabbitai

coderabbitai Bot commented Jul 6, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Currently processing new changes in this PR. This may take a few minutes, please wait...

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 5003c0d0-28a5-4c14-86fe-cd90fef63e5e

📥 Commits

Reviewing files that changed from the base of the PR and between b91ed26 and fac09c7.

⛔ Files ignored due to path filters (9)
  • lib/catalog/__tests__/computeValuationBand.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/catalog/__tests__/getCatalogEarliestReleaseDate.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • 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/spotify/__tests__/getAlbums.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/supabase/account_catalogs/__tests__/selectAccountCatalog.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/playcount_snapshots/__tests__/selectPlaycountSnapshots.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
📒 Files selected for processing (10)
  • app/api/catalogs/measurements/route.ts
  • lib/catalog/computeValuationBand.ts
  • lib/catalog/getCatalogEarliestReleaseDate.ts
  • lib/catalog/getCatalogMeasurementsHandler.ts
  • lib/catalog/latestMeasurementsPerIsrc.ts
  • lib/catalog/validateGetCatalogMeasurementsQuery.ts
  • lib/spotify/getAlbums.ts
  • lib/supabase/account_catalogs/selectAccountCatalog.ts
  • lib/supabase/catalog_songs/selectCatalogSongTitles.ts
  • lib/supabase/playcount_snapshots/selectPlaycountSnapshots.ts
 ________________________________________________________
< Your error handling is basically thoughts and prayers. >
 --------------------------------------------------------
  \
   \   \
        \ /\
        ( )
      .( o ).
✨ 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-endpoint

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.

@sweetmantech

Copy link
Copy Markdown
Contributor Author

Preview verification — fac09c73

Tested against the Ready Vercel preview for this PR's sha (deployment 5333428180, env Preview, state success): https://api-7qzjc99x6-recoup.vercel.app.

# Case Request Expected Got Body
1 Missing catalogId GET /api/catalogs/measurements (no auth) 400 400 {"status":"error","missing_fields":["catalogId"],"error":"catalogId parameter is required"}
2 Invalid catalogId GET …?catalogId=not-a-uuid (no auth) 400 400 {"status":"error","missing_fields":["catalogId"],"error":"catalogId must be a valid UUID"}
3 Unauthed, real catalog GET …?catalogId=740d5050-40ec-4892-a040-b78bb50fef2f (the Del Water Gap catalog from chat#1850) 401 401 {"status":"error","error":"Exactly one of x-api-key or Authorization must be provided"}
4 Invalid x-api-key, real catalog same URL + bogus key 401 401 {"status":"error","message":"Unauthorized"}
5 CORS preflight OPTIONS /api/catalogs/measurements 200 200

Done-when status

Check Status
400 / 401 paths live on preview ✅ verified above
404 unknown/foreign catalog ✅ unit-tested (handler returns an indistinguishable 404 via the account_catalogs ownership check); ⏳ not exercised on preview — needs any authenticated credential
200 happy path: per-track play counts + band for the Del Water Gap catalog, consistent with the marketing card pending an authenticated test — I have no Privy bearer / API key for the owning account (sweetmantech+jul620261135am@…) in this session and did not fake one. Covered by unit tests (latest-per-ISRC reduction, band math asserted against the marketing constants); needs one authenticated GET …?catalogId=740d5050-40ec-4892-a040-b78bb50fef2f to confirm live numbers

One caveat for the live check: the band matches a marketing run only when the catalog's snapshot has album_ids (album-scoped valuation runs do); otherwise catalog_age_years falls back to the same default_5y the marketing card documents, and the band shifts accordingly.

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Jul 6, 2026

Copy link
Copy Markdown

Caution

Failed to replace (edit) comment. This is likely due to insufficient permissions or the comment being deleted.

Error details
{"name":"HttpError","status":500,"request":{"method":"PATCH","url":"https://api.github.com/repos/recoupable/api/issues/comments/4896496905","headers":{"accept":"application/vnd.github.v3+json","user-agent":"octokit.js/0.0.0-development octokit-core.js/7.0.6 Node.js/24","content-type":"application/json; charset=utf-8"},"body":{"body":"<!-- This is an auto-generated comment: summarize by coderabbit.ai -->\n<!-- review_stack_entry_start -->\n\n[![Review Change Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/recoupable/api/pull/757?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack)\n\n<!-- review_stack_entry_end -->\n<!-- walkthrough_start -->\n\n<details>\n<summary>📝 Walkthrough</summary>\n\n## Walkthrough\n\nThis PR adds a new `GET /api/catalogs/measurements` endpoint that validates a `catalogId` query parameter, verifies account-catalog ownership, retrieves per-ISRC latest Spotify stream measurements, resolves the catalog's earliest release date via Spotify albums, and computes a low/mid/high valuation band from total streams and catalog age.\n\n### Changes\n\n**Catalog Measurements Endpoint**\n\n|Layer / File(s)|Summary|\n|---|---|\n|**Route entry and query validation** <br> `app/api/catalogs/measurements/route.ts`, `lib/catalog/validateGetCatalogMeasurementsQuery.ts`|New route exposes OPTIONS (CORS preflight) and GET handlers; GET delegates to the handler after Zod validates `catalogId` as a UUID, returning a 400 with CORS headers on failure.|\n|**Supabase data access helpers** <br> `lib/supabase/account_catalogs/selectAccountCatalog.ts`, `lib/supabase/catalog_songs/selectCatalogSongTitles.ts`, `lib/supabase/playcount_snapshots/selectPlaycountSnapshots.ts`|New helpers fetch account-catalog ownership records and catalog song ISRC/title mappings; playcount snapshot selection gains an optional `catalog` filter.|\n|**Spotify album fetch and earliest release date resolution** <br> `lib/spotify/getAlbums.ts`, `lib/catalog/getCatalogEarliestReleaseDate.ts`|New batched Spotify album-fetch helper and a function chaining snapshot lookup, token retrieval, and album fetch to derive the catalog's earliest release date, returning `null` when data is missing.|\n|**Measurement aggregation and valuation computation** <br> `lib/catalog/latestMeasurementsPerIsrc.ts`, `lib/catalog/computeValuationBand.ts`|Reduces measurement rows to one latest entry per ISRC with total streams, sorted by playcount; computes low/mid/high valuation bands using catalog age, per-stream pricing, and market multiples.|\n|**Handler orchestration** <br> `lib/catalog/getCatalogMeasurementsHandler.ts`|Ties together validation, auth, ownership checks, song titles, measurements, release date, and valuation computation into a single success/error response.|\n\n**Estimated code review effort:** 3 (Moderate) | ~25 minutes\n\n### Sequence Diagram(s)\n\n```mermaid\nsequenceDiagram\n  participant Client\n  participant Route as route.ts\n  participant Handler as getCatalogMeasurementsHandler\n  participant Supabase\n  participant Spotify\n\n  Client->>Route: GET /api/catalogs/measurements?catalogId=\n  Route->>Handler: getCatalogMeasurementsHandler(request)\n  Handler->>Handler: validateGetCatalogMeasurementsQuery\n  Handler->>Supabase: selectAccountCatalog (ownership check)\n  Handler->>Supabase: selectCatalogSongTitles\n  Handler->>Supabase: query song_measurements by ISRC\n  Handler->>Spotify: getCatalogEarliestReleaseDate (via getAlbums)\n  Handler->>Handler: latestMeasurementsPerIsrc (totalStreams)\n  Handler->>Handler: computeValuationBand\n  Handler-->>Route: successResponse(measurements + valuation)\n  Route-->>Client: JSON response\n```\n\n**Possibly related issues**\n\n- recoupable/chat#1850: Describes the same `GET /api/catalogs/measurements?catalogId=` measurement/valuation pipeline implemented in this PR.\n\n**Poem**\n\n> A rabbit hopped through streams of sound,\n> Counting plays the whole year round,\n> Bands of value, low to high,\n> Spotify albums passing by,\n> One clean endpoint, tidy and bright —\n> Catalogs valued, just right! 🐰🎧\n\n</details>\n\n<!-- walkthrough_end -->\n<!-- pre_merge_checks_walkthrough_start -->\n\n<details>\n<summary>🚥 Pre-merge checks | ✅ 2 | ❌ 1</summary>\n\n### ❌ Failed checks (1 warning)\n\n|     Check name     | Status     | Explanation                                                                                                                                                           | Resolution                                                                                                                                                                              |\n| :----------------: | :--------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| Solid & Clean Code | ⚠️ Warning | route.ts exports 2 handlers; getCatalogMeasurementsHandler mixes validation/auth/data fetch/valuation; selectPlaycountSnapshots was edited in-place, hurting SRP/OCP. | Split the route into a thin adapter and one named service function, extract CORS/OPTIONS reuse, and add a catalog-scoped snapshot helper instead of modifying selectPlaycountSnapshots. |\n\n<details>\n<summary>✅ Passed checks (2 passed)</summary>\n\n|         Check name         | Status   | Explanation                                                              |\n| :------------------------: | :------- | :----------------------------------------------------------------------- |\n|     Linked Issues check    | ✅ Passed | Check skipped because no linked issues were found for this pull request. |\n| Out of Scope Changes check | ✅ Passed | Check skipped because no linked issues were found for this pull request. |\n\n</details>\n\n</details>\n\n<!-- pre_merge_checks_walkthrough_end -->\n<!-- finishing_touch_checkbox_start -->\n\n<details>\n<summary>✨ Finishing Touches</summary>\n\n<details>\n<summary>📝 Generate docstrings</summary>\n\n- [ ] <!-- {\"checkboxId\": \"7962f53c-55bc-4827-bfbf-6a18da830691\"} --> Create stacked PR\n- [ ] <!-- {\"checkboxId\": \"3e1879ae-f29b-4d0d-8e06-d12b7ba33d98\"} --> Commit on current branch\n\n</details>\n<details>\n<summary>🧪 Generate unit tests (beta)</summary>\n\n- [ ] <!-- {\"checkboxId\": \"f47ac10b-58cc-4372-a567-0e02b2c3d479\", \"radioGroupId\": \"utg-output-choice-group-unknown_comment_id\"} -->   Create PR with unit tests\n- [ ] <!-- {\"checkboxId\": \"6ba7b810-9dad-11d1-80b4-00c04fd430c8\", \"radioGroupId\": \"utg-output-choice-group-unknown_comment_id\"} -->   Commit unit tests in branch `feat/catalog-measurements-endpoint`\n\n</details>\n\n</details>\n\n<!-- finishing_touch_checkbox_end -->\n<!-- tips_start -->\n\n---\n\nThanks for using [CodeRabbit](https://coderabbit.ai?utm_source=oss&utm_medium=github&utm_campaign=recoupable/api&utm_content=757)! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.\n\n<details>\n<summary>❤️ Share</summary>\n\n- [X](https://twitter.com/intent/tweet?text=I%20just%20used%20%40coderabbitai%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20the%20proprietary%20code.%20Check%20it%20out%3A&url=https%3A//coderabbit.ai)\n- [Mastodon](https://mastodon.social/share?text=I%20just%20used%20%40coderabbitai%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20the%20proprietary%20code.%20Check%20it%20out%3A%20https%3A%2F%2Fcoderabbit.ai)\n- [Reddit](https://www.reddit.com/submit?title=Great%20tool%20for%20code%20review%20-%20CodeRabbit&text=I%20just%20used%20CodeRabbit%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20proprietary%20code.%20Check%20it%20out%3A%20https%3A//coderabbit.ai)\n- [LinkedIn](https://www.linkedin.com/sharing/share-offsite/?url=https%3A%2F%2Fcoderabbit.ai&mini=true&title=Great%20tool%20for%20code%20review%20-%20CodeRabbit&summary=I%20just%20used%20CodeRabbit%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20proprietary%20code)\n\n</details>\n\n\n<sub>Comment `@coderabbitai help` to get the list of available commands.</sub>\n\n<!-- tips_end -->"},"request":{"retryCount":3,"signal":{},"retries":3,"retryAfter":16}}}

@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

🧹 Nitpick comments (3)
lib/catalog/getCatalogMeasurementsHandler.ts (1)

24-73: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Handler exceeds the 20-line function guideline; consider extracting the fetch/compute stage.

The function is correct and each step delegates to a well-named helper, but it composes five distinct concerns (query validation, auth, ownership check, parallel data fetch, response shaping) in one ~44-line body.

♻️ Optional extraction of the fetch+compute stage
+async function buildMeasurementsPayload(catalogId: string) {
+  const songs = await selectCatalogSongTitles(catalogId);
+  const titles = new Map(songs.map(s => [s.isrc, s.title]));
+  const [rows, earliestReleaseDate] = await Promise.all([
+    songs.length > 0
+      ? selectSongMeasurements({
+          songs: songs.map(s => s.isrc),
+          platform: "spotify",
+          metric: "platform_displayed_play_count",
+        })
+      : Promise.resolve([]),
+    getCatalogEarliestReleaseDate(catalogId),
+  ]);
+  const { measurements, totalStreams } = latestMeasurementsPerIsrc(rows, titles);
+  const { valuation, catalogAgeYears } = computeValuationBand({ totalStreams, earliestReleaseDate });
+  return { measurements, valuation, totalStreams, catalogAgeYears };
+}

As per path instructions, "Keep functions under 50 lines" and "Single responsibility per function." This is a nice-to-have given the current step-by-step readability is still reasonable.

🤖 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 24 - 73, The
getCatalogMeasurementsHandler function is still doing too many steps in one
body, even though it delegates to helpers. Extract the parallel fetch and
valuation/response shaping into a separate helper (for example around
selectCatalogSongTitles, selectSongMeasurements, getCatalogEarliestReleaseDate,
latestMeasurementsPerIsrc, and computeValuationBand) so the handler stays under
the line limit and follows single-responsibility.

Source: Path instructions

lib/catalog/validateGetCatalogMeasurementsQuery.ts (1)

5-9: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Migrate this validator to Zod 4's error API. z.string({ message }) and chained .uuid("...") are deprecated; replace them with z.uuid({ error: ... }) (or an error callback if you need separate required vs format messages).

🤖 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 5 - 9,
Update getCatalogMeasurementsQuerySchema to use Zod 4’s error API instead of the
deprecated z.string({ message }) and chained .uuid("...") calls. In
validateGetCatalogMeasurementsQuery.ts, switch the catalogId validator to z.uuid
with an error handler or callback so it can preserve the missing-value message
and the invalid-UUID message separately, keeping the schema behavior in
getCatalogMeasurementsQuerySchema the same.
lib/spotify/getAlbums.ts (1)

25-43: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Consider parallelizing batch fetches for large catalogs.

Batches are fetched sequentially; for catalogs with many albums this adds latency proportional to batch count. Promise.all across batches would reduce total wait time.

🤖 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/spotify/getAlbums.ts` around lines 25 - 43, The batch fetching in
getAlbums is currently sequential, which increases latency for large id lists.
Refactor the loop in getAlbums to build per-batch fetch promises and resolve
them in parallel with Promise.all, while preserving the existing response
parsing and error handling. Keep the batching logic and the SpotifyAlbum
accumulation intact, but make sure the final albums array still includes all
successful batch results in the same shape.
🤖 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/computeValuationBand.ts`:
- Around line 31-35: `computeValuationBand` currently lets an invalid
`params.earliestReleaseDate` produce `NaN`, which then propagates into the
valuation band. In the date-handling block inside `computeValuationBand`,
validate the parsed date before computing `ageMs` and only overwrite
`catalogAgeYears` when the date is valid; otherwise leave the fallback
`DEFAULT_AGE_YEARS` in place. Use the existing `now`, `YEAR_MS`, and
`catalogAgeYears` logic, but add a guard around the `new
Date(params.earliestReleaseDate)` result so unparseable inputs do not affect the
response.

In `@lib/catalog/validateGetCatalogMeasurementsQuery.ts`:
- Around line 26-36: The error payload in validateGetCatalogMeasurementsQuery
uses missing_fields for every validation failure, which is misleading for
format/invalid UUID cases. Update the response shape in
validateGetCatalogMeasurementsQuery to distinguish absent fields from invalid
values by using a clearer key or conditional mapping based on firstError.path
and firstError.message, so catalogId validation failures are reported as format
errors rather than missing fields.

In `@lib/spotify/getAlbums.ts`:
- Around line 29-39: The Spotify request in getAlbums should be protected by a
timeout so an unresponsive call does not stall the best-effort measurements
path. Update the fetch in getAlbums to use AbortSignal.timeout(...) or an
AbortController-based fallback for older Node versions, and make sure the
timeout is applied to the existing fetch call before handling the response.ok
error path.

---

Nitpick comments:
In `@lib/catalog/getCatalogMeasurementsHandler.ts`:
- Around line 24-73: The getCatalogMeasurementsHandler function is still doing
too many steps in one body, even though it delegates to helpers. Extract the
parallel fetch and valuation/response shaping into a separate helper (for
example around selectCatalogSongTitles, selectSongMeasurements,
getCatalogEarliestReleaseDate, latestMeasurementsPerIsrc, and
computeValuationBand) so the handler stays under the line limit and follows
single-responsibility.

In `@lib/catalog/validateGetCatalogMeasurementsQuery.ts`:
- Around line 5-9: Update getCatalogMeasurementsQuerySchema to use Zod 4’s error
API instead of the deprecated z.string({ message }) and chained .uuid("...")
calls. In validateGetCatalogMeasurementsQuery.ts, switch the catalogId validator
to z.uuid with an error handler or callback so it can preserve the missing-value
message and the invalid-UUID message separately, keeping the schema behavior in
getCatalogMeasurementsQuerySchema the same.

In `@lib/spotify/getAlbums.ts`:
- Around line 25-43: The batch fetching in getAlbums is currently sequential,
which increases latency for large id lists. Refactor the loop in getAlbums to
build per-batch fetch promises and resolve them in parallel with Promise.all,
while preserving the existing response parsing and error handling. Keep the
batching logic and the SpotifyAlbum accumulation intact, but make sure the final
albums array still includes all successful batch results in the same shape.
🪄 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: 5003c0d0-28a5-4c14-86fe-cd90fef63e5e

📥 Commits

Reviewing files that changed from the base of the PR and between b91ed26 and fac09c7.

⛔ Files ignored due to path filters (9)
  • lib/catalog/__tests__/computeValuationBand.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/catalog/__tests__/getCatalogEarliestReleaseDate.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • 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/spotify/__tests__/getAlbums.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/supabase/account_catalogs/__tests__/selectAccountCatalog.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/playcount_snapshots/__tests__/selectPlaycountSnapshots.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
📒 Files selected for processing (10)
  • app/api/catalogs/measurements/route.ts
  • lib/catalog/computeValuationBand.ts
  • lib/catalog/getCatalogEarliestReleaseDate.ts
  • lib/catalog/getCatalogMeasurementsHandler.ts
  • lib/catalog/latestMeasurementsPerIsrc.ts
  • lib/catalog/validateGetCatalogMeasurementsQuery.ts
  • lib/spotify/getAlbums.ts
  • lib/supabase/account_catalogs/selectAccountCatalog.ts
  • lib/supabase/catalog_songs/selectCatalogSongTitles.ts
  • lib/supabase/playcount_snapshots/selectPlaycountSnapshots.ts

Comment on lines +31 to +35
let catalogAgeYears = DEFAULT_AGE_YEARS;
if (params.earliestReleaseDate) {
const ageMs = now.getTime() - new Date(params.earliestReleaseDate).getTime();
catalogAgeYears = Math.max(1, Math.round(ageMs / YEAR_MS));
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Unparseable earliestReleaseDate silently propagates NaN into the valuation band.

Math.max(1, Math.round(ageMs / YEAR_MS)) returns NaN (not 1) if ageMs is NaN — i.e., if earliestReleaseDate fails to parse as a Date. This would silently corrupt the response with NaN valuations instead of falling back to the documented default age.

🛡️ Proposed guard against invalid date strings
   let catalogAgeYears = DEFAULT_AGE_YEARS;
   if (params.earliestReleaseDate) {
-    const ageMs = now.getTime() - new Date(params.earliestReleaseDate).getTime();
-    catalogAgeYears = Math.max(1, Math.round(ageMs / YEAR_MS));
+    const releaseTime = new Date(params.earliestReleaseDate).getTime();
+    if (!Number.isNaN(releaseTime)) {
+      const ageMs = now.getTime() - releaseTime;
+      catalogAgeYears = Math.max(1, Math.round(ageMs / YEAR_MS));
+    }
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let catalogAgeYears = DEFAULT_AGE_YEARS;
if (params.earliestReleaseDate) {
const ageMs = now.getTime() - new Date(params.earliestReleaseDate).getTime();
catalogAgeYears = Math.max(1, Math.round(ageMs / YEAR_MS));
}
let catalogAgeYears = DEFAULT_AGE_YEARS;
if (params.earliestReleaseDate) {
const releaseTime = new Date(params.earliestReleaseDate).getTime();
if (!Number.isNaN(releaseTime)) {
const ageMs = now.getTime() - releaseTime;
catalogAgeYears = Math.max(1, Math.round(ageMs / YEAR_MS));
}
}
🤖 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/computeValuationBand.ts` around lines 31 - 35,
`computeValuationBand` currently lets an invalid `params.earliestReleaseDate`
produce `NaN`, which then propagates into the valuation band. In the
date-handling block inside `computeValuationBand`, validate the parsed date
before computing `ageMs` and only overwrite `catalogAgeYears` when the date is
valid; otherwise leave the fallback `DEFAULT_AGE_YEARS` in place. Use the
existing `now`, `YEAR_MS`, and `catalogAgeYears` logic, but add a guard around
the `new Date(params.earliestReleaseDate)` result so unparseable inputs do not
affect the response.

Comment on lines +26 to +36
if (!result.success) {
const firstError = result.error.issues[0];
return NextResponse.json(
{
status: "error",
missing_fields: firstError.path,
error: firstError.message,
},
{ status: 400, headers: getCorsHeaders() },
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

missing_fields is misleading for format errors.

When catalogId is present but not a valid UUID, the response still reports it under missing_fields, which is inaccurate and could confuse API consumers debugging a format issue vs. an absent parameter.

💡 Proposed rename for clarity
     return NextResponse.json(
       {
         status: "error",
-        missing_fields: firstError.path,
+        invalid_fields: firstError.path,
         error: firstError.message,
       },
       { status: 400, headers: getCorsHeaders() },
     );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (!result.success) {
const firstError = result.error.issues[0];
return NextResponse.json(
{
status: "error",
missing_fields: firstError.path,
error: firstError.message,
},
{ status: 400, headers: getCorsHeaders() },
);
}
if (!result.success) {
const firstError = result.error.issues[0];
return NextResponse.json(
{
status: "error",
invalid_fields: firstError.path,
error: firstError.message,
},
{ status: 400, headers: getCorsHeaders() },
);
}
🤖 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 26 - 36, The
error payload in validateGetCatalogMeasurementsQuery uses missing_fields for
every validation failure, which is misleading for format/invalid UUID cases.
Update the response shape in validateGetCatalogMeasurementsQuery to distinguish
absent fields from invalid values by using a clearer key or conditional mapping
based on firstError.path and firstError.message, so catalogId validation
failures are reported as format errors rather than missing fields.

Comment thread lib/spotify/getAlbums.ts
Comment on lines +29 to +39
const response = await fetch(url, {
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${accessToken}`,
},
});

if (!response.ok) {
return { albums: null, error: new Error("Spotify API request failed") };
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate the relevant files and inspect the surrounding code.
git ls-files 'lib/spotify/getAlbums.ts' 'lib/**/getCatalogEarliestReleaseDate.ts' 'package.json' 'tsconfig.json' 'README.md'

echo
echo "=== lib/spotify/getAlbums.ts ==="
cat -n lib/spotify/getAlbums.ts

echo
echo "=== getCatalogEarliestReleaseDate.ts matches ==="
rg -n "getCatalogEarliestReleaseDate|getAlbums" lib -A 8 -B 8

echo
echo "=== package.json ==="
cat -n package.json

echo
echo "=== tsconfig.json (if present) ==="
if [ -f tsconfig.json ]; then cat -n tsconfig.json; fi

echo
echo "=== Node/runtime hints ==="
rg -n '"node"|engines|AbortSignal\.timeout|fetch\(' -S package.json README.md lib tsconfig.json

Repository: recoupable/api

Length of output: 38319


Add a timeout to the Spotify fetch in lib/spotify/getAlbums.ts:29-35. An unresponsive Spotify request can stall the best-effort measurements endpoint; use AbortSignal.timeout(...), or an AbortController fallback if older Node support is required.

🤖 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/spotify/getAlbums.ts` around lines 29 - 39, The Spotify request in
getAlbums should be protected by a timeout so an unresponsive call does not
stall the best-effort measurements path. Update the fetch in getAlbums to use
AbortSignal.timeout(...) or an AbortController-based fallback for older Node
versions, and make sure the timeout is applied to the existing fetch call before
handling the response.ok error path.

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

10 issues found across 19 files

Confidence score: 3/5

  • The highest-risk item is in lib/catalog/getCatalogEarliestReleaseDate.ts: importing generateAccessToken at module load can throw when Spotify credentials are missing, which can fail the route before the default-age fallback runs and break catalog measurements entirely for those environments — defer token setup/import until runtime inside the Spotify path so fallback logic still executes.
  • lib/supabase/account_catalogs/selectAccountCatalog.ts and lib/catalog/getCatalogMeasurementsHandler.ts currently blur backend failures into normal outcomes (null) and use an unpaginated measurements query, so users may see incorrect 404s or silently incomplete measurements/total_streams on larger catalogs — propagate Supabase errors as 500s and page or aggregate server-side before reducing latest-per-ISRC.
  • lib/catalog/computeValuationBand.ts can produce NaN valuation bands when earliestReleaseDate is a truthy but invalid date string, and lib/catalog/__tests__/getCatalogMeasurementsHandler.test.ts has time-dependent expectations that can hide or intermittently expose this behavior — validate/parsing-guard the date input and freeze/mock time in the valuation-band test before merging.
  • lib/spotify/getAlbums.ts has no request timeout, so an unresponsive Spotify call can hang the measurements flow; related tests in lib/spotify/__tests__/getAlbums.test.ts and lib/supabase/catalog_songs/__tests__/selectCatalogSongTitles.test.ts also miss key assertions (Authorization header, second batch URL, and !inner select semantics), reducing confidence against regressions — add a fetch timeout and tighten these assertions to de-risk integration behavior.
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/spotify/__tests__/getAlbums.test.ts">

<violation number="1" location="lib/spotify/__tests__/getAlbums.test.ts:26">
P2: The request Authorization header is never verified. The test provides `accessToken: "tok"` but only inspects the URL — the request headers (`mock.calls[0][1]`) are never asserted. Since the Spotify API requires Bearer token auth, add an assertion that the first call includes `Authorization: Bearer tok` to catch regressions that drop or malform the header.</violation>

<violation number="2" location="lib/spotify/__tests__/getAlbums.test.ts:31">
P3: Only the first batch URL is verified. The second `fetch` call's URL goes unchecked, so batching errors in subsequent requests (wrong IDs, wrong size) would not be caught. Add an assertion on the second URL for symmetry.</violation>
</file>

<file name="lib/catalog/getCatalogEarliestReleaseDate.ts">

<violation number="1" location="lib/catalog/getCatalogEarliestReleaseDate.ts:2">
P2: Catalog measurements cannot fall back to the default age when Spotify credentials are absent: importing `generateAccessToken` throws during module load, so the route can fail before `getCatalogEarliestReleaseDate` returns `null`. Consider moving Spotify credential failure into the best-effort path, e.g. lazy-load/wrap token generation so missing config returns `null`.</violation>
</file>

<file name="lib/supabase/account_catalogs/selectAccountCatalog.ts">

<violation number="1" location="lib/supabase/account_catalogs/selectAccountCatalog.ts:27">
P2: A backend failure during the ownership check is reported to clients as a missing catalog because `selectAccountCatalog` converts Supabase errors to `null`. Throwing here lets `getCatalogMeasurementsHandler` return the intended 500 while preserving `null` for true no-row results.</violation>
</file>

<file name="lib/supabase/catalog_songs/__tests__/selectCatalogSongTitles.test.ts">

<violation number="1" location="lib/supabase/catalog_songs/__tests__/selectCatalogSongTitles.test.ts:32">
P2: Test omits assertion on `.select()` call parameters. The function uses `select("songs!inner (isrc, name)")` where the `!inner` join modifier is semantically meaningful — dropping it would let orphaned `catalog_songs` rows leak through instead of being filtered. Add an assertion like `expect(builder.select).toHaveBeenCalledWith("songs!inner (isrc, name)")` to pin the join type.</violation>
</file>

<file name="lib/catalog/__tests__/getCatalogMeasurementsHandler.test.ts">

<violation number="1" location="lib/catalog/__tests__/getCatalogMeasurementsHandler.test.ts:99">
P2: The test "returns latest per-ISRC measurements + the derived valuation band" freezes `earliestReleaseDate` to `2016-07-01` but relies on real system time to determine the catalog age — `computeValuationBand` is called without a `now` override, so every day `new Date()` shifts. The expected valuation constants are hardcoded for a 10-year age (2016 → 2026), meaning the test will produce wrong assertions starting ~mid-2027 when the age naturally ticks to 11 years.

`computeValuationBand` explicitly exposes a `now?: Date` parameter documented as "Clock override for tests" — the test should freeze time with `vi.setSystemTime` so the valuation output is deterministic regardless of when the suite runs.</violation>

<violation number="2" location="lib/catalog/__tests__/getCatalogMeasurementsHandler.test.ts:151">
P2: The 500-error test verifies status but not the response body content. Per team convention, 500 responses should return a hardcoded "Internal server error" without leaking the exception message. The handler is implemented correctly (errorResponse("Internal server error", 500)), so the risk is in regression — a future change to the catch block could accidentally include the error message without a body assertion catching it. Add a check like `const body = await res.json(); expect(body.error).toBe("Internal server error");` to lock in the expected safe envelope.</violation>
</file>

<file name="lib/catalog/getCatalogMeasurementsHandler.ts">

<violation number="1" location="lib/catalog/getCatalogMeasurementsHandler.ts:48">
P2: Large catalogs or catalogs with measurement history can return incomplete `measurements`/`total_streams` because this fetch relies on an unpaginated Supabase select before reducing latest per ISRC. Consider moving the latest-per-song selection into a paginated/SQL-side query scoped to the catalog instead of fetching an implicitly capped series.

(Based on your team's feedback about preferring DB-side pagination over raising Supabase query limits.) [FEEDBACK_USED]</violation>
</file>

<file name="lib/catalog/computeValuationBand.ts">

<violation number="1" location="lib/catalog/computeValuationBand.ts:33">
P2: If `earliestReleaseDate` is a truthy but unparseable date string (e.g. `"unknown"` or a malformed ISO value), `new Date(params.earliestReleaseDate).getTime()` returns `NaN`, which propagates through the subtraction, `Math.round`, and `Math.max(1, NaN)` — all yielding `NaN` rather than the intended `DEFAULT_AGE_YEARS` fallback. Consider validating the parsed timestamp before using it:

```ts
const releaseTime = new Date(params.earliestReleaseDate).getTime();
if (!Number.isNaN(releaseTime)) {
  const ageMs = now.getTime() - releaseTime;
  catalogAgeYears = Math.max(1, Math.round(ageMs / YEAR_MS));
}
```</violation>
</file>

<file name="lib/spotify/getAlbums.ts">

<violation number="1" location="lib/spotify/getAlbums.ts:29">
P2: This `fetch` call has no timeout. If Spotify is unresponsive, the request will hang indefinitely, blocking the measurements endpoint response. Consider adding `signal: AbortSignal.timeout(10_000)` (or similar) to bound the wait time and allow the best-effort fallback (null release date → default age) to kick in.</violation>
</file>
Architecture diagram
sequenceDiagram
    participant Client as External Client (Chat Frontend)
    participant Next as Next.js API Route
    participant Handler as getCatalogMeasurementsHandler
    participant Auth as validateAuthContext
    participant Validator as validateGetCatalogMeasurementsQuery
    participant DB as Supabase Database
    participant Spotify as Spotify API
    participant Valuation as computeValuationBand

    Client->>Next: GET /api/catalogs/measurements?catalogId={uuid}
    
    Next->>Handler: Forward request
    
    Handler->>Validator: validate query params
    alt 400: Invalid/missing catalogId
        Validator-->>Handler: Return NextResponse 400
        Handler-->>Next: Forward 400 response
        Next-->>Client: 400 Invalid catalogId
    else Valid query
        Validator-->>Handler: { catalogId }
    end
    
    Handler->>Auth: Validate authentication (Privy bearer or x-api-key)
    alt 401: Unauthenticated
        Auth-->>Handler: Return NextResponse 401
        Handler-->>Next: Forward 401 response
        Next-->>Client: 401 Unauthorized
    else Authenticated
        Auth-->>Handler: { accountId, orgId }
    end
    
    Handler->>DB: selectAccountCatalog({ accountId, catalogId })
    alt 404: Not found or not owned
        DB-->>Handler: null
        Handler-->>Next: Return 404
        Next-->>Client: 404 Catalog not found
    else Owned catalog found
        DB-->>Handler: { account, catalog } link row
    end
    
    Handler->>DB: selectCatalogSongTitles(catalogId)
    DB-->>Handler: [{ isrc, title }, ...]
    
    Note over Handler: Parallel fetch: measurements + earliest release date
    
    alt Songs exist in catalog
        Handler->>DB: selectSongMeasurements({ songs, platform: "spotify", metric: "platform_displayed_play_count" })
        DB-->>Handler: [{ song, value, captured_at }, ...] (newest-first)
    else Empty catalog
        Handler-->>Handler: Use empty array
    end
    
    Handler->>DB: getCatalogEarliestReleaseDate(catalogId)
    
    DB->>DB: selectPlaycountSnapshots({ catalog: catalogId })
    DB-->>Handler: Snapshot with album_ids
    
    opt Album IDs found on snapshot
        Handler->>Spotify: generateAccessToken()
        Spotify-->>Handler: { access_token }
        
        Handler->>Spotify: getAlbums({ ids, accessToken })
        loop Batch per 20 album IDs
            Spotify-->>Handler: Album metadata with release dates
        end
        
        alt Spotify fetch succeeds
            Handler-->>Handler: Find earliest release date
        else Fetch fails
            Handler-->>Handler: Use null (fallback)
        end
    end
    
    Handler->>Handler: latestMeasurementsPerIsrc(rows, titles)
    Note over Handler: Reduce to newest capture per ISRC, sort by playcount desc
    
    Handler->>Valuation: computeValuationBand({ totalStreams, earliestReleaseDate })
    alt Age known from release date
        Valuation-->>Handler: { valuation: { low, mid, high }, catalogAgeYears }
    else No release date (fallback)
        Valuation-->>Handler: Default 5 years age
    else Zero streams
        Valuation-->>Handler: { low: 0, mid: 0, high: 0 }
    end
    
    Handler->>Next: successResponse({ measurements, valuation, total_streams, catalog_age_years })
    Next-->>Client: 200 JSON response
    
    Note over Handler: Error path (any async failure)
    Handler->>Handler: Catch block → console.error
    Handler-->>Next: 500 Internal server error
    Next-->>Client: 500 Error response
Loading

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

Re-trigger cubic

new Response(JSON.stringify(albumsPage(ids.slice(20))), { status: 200 }),
);

const { albums, error } = await getAlbums({ ids, accessToken: "tok" });

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: The request Authorization header is never verified. The test provides accessToken: "tok" but only inspects the URL — the request headers (mock.calls[0][1]) are never asserted. Since the Spotify API requires Bearer token auth, add an assertion that the first call includes Authorization: Bearer tok to catch regressions that drop or malform the header.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/spotify/__tests__/getAlbums.test.ts, line 26:

<comment>The request Authorization header is never verified. The test provides `accessToken: "tok"` but only inspects the URL — the request headers (`mock.calls[0][1]`) are never asserted. Since the Spotify API requires Bearer token auth, add an assertion that the first call includes `Authorization: Bearer tok` to catch regressions that drop or malform the header.</comment>

<file context>
@@ -0,0 +1,52 @@
+        new Response(JSON.stringify(albumsPage(ids.slice(20))), { status: 200 }),
+      );
+
+    const { albums, error } = await getAlbums({ ids, accessToken: "tok" });
+
+    expect(error).toBeNull();
</file context>

@@ -0,0 +1,31 @@
import { selectPlaycountSnapshots } from "@/lib/supabase/playcount_snapshots/selectPlaycountSnapshots";
import generateAccessToken from "@/lib/spotify/generateAccessToken";

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: Catalog measurements cannot fall back to the default age when Spotify credentials are absent: importing generateAccessToken throws during module load, so the route can fail before getCatalogEarliestReleaseDate returns null. Consider moving Spotify credential failure into the best-effort path, e.g. lazy-load/wrap token generation so missing config returns null.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/catalog/getCatalogEarliestReleaseDate.ts, line 2:

<comment>Catalog measurements cannot fall back to the default age when Spotify credentials are absent: importing `generateAccessToken` throws during module load, so the route can fail before `getCatalogEarliestReleaseDate` returns `null`. Consider moving Spotify credential failure into the best-effort path, e.g. lazy-load/wrap token generation so missing config returns `null`.</comment>

<file context>
@@ -0,0 +1,31 @@
+import { selectPlaycountSnapshots } from "@/lib/supabase/playcount_snapshots/selectPlaycountSnapshots";
+import generateAccessToken from "@/lib/spotify/generateAccessToken";
+import getAlbums from "@/lib/spotify/getAlbums";
+
</file context>

.eq("catalog", catalogId)
.maybeSingle();

if (error) {

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: A backend failure during the ownership check is reported to clients as a missing catalog because selectAccountCatalog converts Supabase errors to null. Throwing here lets getCatalogMeasurementsHandler return the intended 500 while preserving null for true no-row results.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/supabase/account_catalogs/selectAccountCatalog.ts, line 27:

<comment>A backend failure during the ownership check is reported to clients as a missing catalog because `selectAccountCatalog` converts Supabase errors to `null`. Throwing here lets `getCatalogMeasurementsHandler` return the intended 500 while preserving `null` for true no-row results.</comment>

<file context>
@@ -0,0 +1,33 @@
+    .eq("catalog", catalogId)
+    .maybeSingle();
+
+  if (error) {
+    console.error("Error fetching account_catalogs:", error);
+    return null;
</file context>

error: null,
});

const result = await selectCatalogSongTitles("cat_1");

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: Test omits assertion on .select() call parameters. The function uses select("songs!inner (isrc, name)") where the !inner join modifier is semantically meaningful — dropping it would let orphaned catalog_songs rows leak through instead of being filtered. Add an assertion like expect(builder.select).toHaveBeenCalledWith("songs!inner (isrc, name)") to pin the join type.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/supabase/catalog_songs/__tests__/selectCatalogSongTitles.test.ts, line 32:

<comment>Test omits assertion on `.select()` call parameters. The function uses `select("songs!inner (isrc, name)")` where the `!inner` join modifier is semantically meaningful — dropping it would let orphaned `catalog_songs` rows leak through instead of being filtered. Add an assertion like `expect(builder.select).toHaveBeenCalledWith("songs!inner (isrc, name)")` to pin the join type.</comment>

<file context>
@@ -0,0 +1,49 @@
+      error: null,
+    });
+
+    const result = await selectCatalogSongTitles("cat_1");
+
+    expect(supabase.from).toHaveBeenCalledWith("catalog_songs");
</file context>


const res = await getCatalogMeasurementsHandler(makeRequest());

expect(res.status).toBe(500);

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: The 500-error test verifies status but not the response body content. Per team convention, 500 responses should return a hardcoded "Internal server error" without leaking the exception message. The handler is implemented correctly (errorResponse("Internal server error", 500)), so the risk is in regression — a future change to the catch block could accidentally include the error message without a body assertion catching it. Add a check like const body = await res.json(); expect(body.error).toBe("Internal server error"); to lock in the expected safe envelope.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/catalog/__tests__/getCatalogMeasurementsHandler.test.ts, line 151:

<comment>The 500-error test verifies status but not the response body content. Per team convention, 500 responses should return a hardcoded "Internal server error" without leaking the exception message. The handler is implemented correctly (errorResponse("Internal server error", 500)), so the risk is in regression — a future change to the catch block could accidentally include the error message without a body assertion catching it. Add a check like `const body = await res.json(); expect(body.error).toBe("Internal server error");` to lock in the expected safe envelope.</comment>

<file context>
@@ -0,0 +1,154 @@
+
+    const res = await getCatalogMeasurementsHandler(makeRequest());
+
+    expect(res.status).toBe(500);
+    consoleError.mockRestore();
+  });
</file context>

const titles = new Map(songs.map(s => [s.isrc, s.title]));
const [rows, earliestReleaseDate] = await Promise.all([
songs.length > 0
? selectSongMeasurements({

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: Large catalogs or catalogs with measurement history can return incomplete measurements/total_streams because this fetch relies on an unpaginated Supabase select before reducing latest per ISRC. Consider moving the latest-per-song selection into a paginated/SQL-side query scoped to the catalog instead of fetching an implicitly capped series.

(Based on your team's feedback about preferring DB-side pagination over raising Supabase query limits.)

View Feedback

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>Large catalogs or catalogs with measurement history can return incomplete `measurements`/`total_streams` because this fetch relies on an unpaginated Supabase select before reducing latest per ISRC. Consider moving the latest-per-song selection into a paginated/SQL-side query scoped to the catalog instead of fetching an implicitly capped series.

(Based on your team's feedback about preferring DB-side pagination over raising Supabase query limits.) </comment>

<file context>
@@ -0,0 +1,73 @@
+    const titles = new Map(songs.map(s => [s.isrc, s.title]));
+    const [rows, earliestReleaseDate] = await Promise.all([
+      songs.length > 0
+        ? selectSongMeasurements({
+            songs: songs.map(s => s.isrc),
+            platform: "spotify",
</file context>

{ song: "ISRC1", value: 100, captured_at: "2026-06-01T00:00:00Z" },
] as never);
// 10 years of catalog age
vi.mocked(getCatalogEarliestReleaseDate).mockResolvedValue("2016-07-01");

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: The test "returns latest per-ISRC measurements + the derived valuation band" freezes earliestReleaseDate to 2016-07-01 but relies on real system time to determine the catalog age — computeValuationBand is called without a now override, so every day new Date() shifts. The expected valuation constants are hardcoded for a 10-year age (2016 → 2026), meaning the test will produce wrong assertions starting ~mid-2027 when the age naturally ticks to 11 years.

computeValuationBand explicitly exposes a now?: Date parameter documented as "Clock override for tests" — the test should freeze time with vi.setSystemTime so the valuation output is deterministic regardless of when the suite runs.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/catalog/__tests__/getCatalogMeasurementsHandler.test.ts, line 99:

<comment>The test "returns latest per-ISRC measurements + the derived valuation band" freezes `earliestReleaseDate` to `2016-07-01` but relies on real system time to determine the catalog age — `computeValuationBand` is called without a `now` override, so every day `new Date()` shifts. The expected valuation constants are hardcoded for a 10-year age (2016 → 2026), meaning the test will produce wrong assertions starting ~mid-2027 when the age naturally ticks to 11 years.

`computeValuationBand` explicitly exposes a `now?: Date` parameter documented as "Clock override for tests" — the test should freeze time with `vi.setSystemTime` so the valuation output is deterministic regardless of when the suite runs.</comment>

<file context>
@@ -0,0 +1,154 @@
+      { song: "ISRC1", value: 100, captured_at: "2026-06-01T00:00:00Z" },
+    ] as never);
+    // 10 years of catalog age
+    vi.mocked(getCatalogEarliestReleaseDate).mockResolvedValue("2016-07-01");
+
+    const res = await getCatalogMeasurementsHandler(makeRequest());
</file context>


let catalogAgeYears = DEFAULT_AGE_YEARS;
if (params.earliestReleaseDate) {
const ageMs = now.getTime() - new Date(params.earliestReleaseDate).getTime();

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: If earliestReleaseDate is a truthy but unparseable date string (e.g. "unknown" or a malformed ISO value), new Date(params.earliestReleaseDate).getTime() returns NaN, which propagates through the subtraction, Math.round, and Math.max(1, NaN) — all yielding NaN rather than the intended DEFAULT_AGE_YEARS fallback. Consider validating the parsed timestamp before using it:

const releaseTime = new Date(params.earliestReleaseDate).getTime();
if (!Number.isNaN(releaseTime)) {
  const ageMs = now.getTime() - releaseTime;
  catalogAgeYears = Math.max(1, Math.round(ageMs / YEAR_MS));
}
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/catalog/computeValuationBand.ts, line 33:

<comment>If `earliestReleaseDate` is a truthy but unparseable date string (e.g. `"unknown"` or a malformed ISO value), `new Date(params.earliestReleaseDate).getTime()` returns `NaN`, which propagates through the subtraction, `Math.round`, and `Math.max(1, NaN)` — all yielding `NaN` rather than the intended `DEFAULT_AGE_YEARS` fallback. Consider validating the parsed timestamp before using it:

```ts
const releaseTime = new Date(params.earliestReleaseDate).getTime();
if (!Number.isNaN(releaseTime)) {
  const ageMs = now.getTime() - releaseTime;
  catalogAgeYears = Math.max(1, Math.round(ageMs / YEAR_MS));
}
```</comment>

<file context>
@@ -0,0 +1,48 @@
+
+  let catalogAgeYears = DEFAULT_AGE_YEARS;
+  if (params.earliestReleaseDate) {
+    const ageMs = now.getTime() - new Date(params.earliestReleaseDate).getTime();
+    catalogAgeYears = Math.max(1, Math.round(ageMs / YEAR_MS));
+  }
</file context>

Comment thread lib/spotify/getAlbums.ts
for (let i = 0; i < ids.length; i += BATCH_SIZE) {
const batch = ids.slice(i, i + BATCH_SIZE);
const url = `https://api.spotify.com/v1/albums?ids=${encodeURIComponent(batch.join(","))}`;
const response = await fetch(url, {

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: This fetch call has no timeout. If Spotify is unresponsive, the request will hang indefinitely, blocking the measurements endpoint response. Consider adding signal: AbortSignal.timeout(10_000) (or similar) to bound the wait time and allow the best-effort fallback (null release date → default age) to kick in.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/spotify/getAlbums.ts, line 29:

<comment>This `fetch` call has no timeout. If Spotify is unresponsive, the request will hang indefinitely, blocking the measurements endpoint response. Consider adding `signal: AbortSignal.timeout(10_000)` (or similar) to bound the wait time and allow the best-effort fallback (null release date → default age) to kick in.</comment>

<file context>
@@ -0,0 +1,54 @@
+    for (let i = 0; i < ids.length; i += BATCH_SIZE) {
+      const batch = ids.slice(i, i + BATCH_SIZE);
+      const url = `https://api.spotify.com/v1/albums?ids=${encodeURIComponent(batch.join(","))}`;
+      const response = await fetch(url, {
+        method: "GET",
+        headers: {
</file context>

expect(error).toBeNull();
expect(albums).toHaveLength(25);
expect(fetch).toHaveBeenCalledTimes(2);
const firstUrl = vi.mocked(fetch).mock.calls[0][0] as string;

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: Only the first batch URL is verified. The second fetch call's URL goes unchecked, so batching errors in subsequent requests (wrong IDs, wrong size) would not be caught. Add an assertion on the second URL for symmetry.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/spotify/__tests__/getAlbums.test.ts, line 31:

<comment>Only the first batch URL is verified. The second `fetch` call's URL goes unchecked, so batching errors in subsequent requests (wrong IDs, wrong size) would not be caught. Add an assertion on the second URL for symmetry.</comment>

<file context>
@@ -0,0 +1,52 @@
+    expect(error).toBeNull();
+    expect(albums).toHaveLength(25);
+    expect(fetch).toHaveBeenCalledTimes(2);
+    const firstUrl = vi.mocked(fetch).mock.calls[0][0] as string;
+    expect(firstUrl).toContain("https://api.spotify.com/v1/albums?ids=");
+    expect(decodeURIComponent(firstUrl)).toContain(ids.slice(0, 20).join(","));
</file context>

@cursor

cursor Bot commented Jul 6, 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
Contributor Author

Preview verification — 2026-07-06 (head 621c1691, deployment 5335218754)

Tested against https://api-i9kguminn-recoup.vercel.app with an admin test bearer (value not recorded). This completes the authenticated checks the earlier pass couldn't run.

Check Documented Actual Result
GET /api/catalogs/measurements (no param) 400 400 catalogId parameter is required
?catalogId=not-a-uuid 400 400 catalogId must be a valid UUID
No auth 401 401 Exactly one of x-api-key or Authorization must be provided
Unknown catalog (authed, random v4 uuid) 404 404 Catalog not found
Foreign catalog (authed, another account's 740d5050…) 404 404 Catalog not found — no IDOR
OPTIONS 200 200
Happy path (authed, own catalog) 200 envelope 200 — see below

Happy path detail — created [TEST] chat#1850 api#757 measurements verification (catalog 7d3e5c35-1fac-4b88-aa90-e2e4a52dfe78) under the test account and seeded 5 Del Water Gap songs by ISRC:

  • measurements: 5 rows, per-ISRC latest, sorted by playcount desc — e.g. To Philly 5,953,607, measured_at 2026-07-06T16:40:34Z (today's snapshot run — confirms global song_measurements reuse, no new measurement needed).
  • valuation: { low: 103227.64, mid: 150299.44, high: 211410.20 }
  • total_streams: 18,505,795 · catalog_age_years: 5
  • Formula spot-check: 18,505,795 ÷ 5y × $0.0035 × 1.25 (low gross-up) × 0.6375 (NLS 0.85×0.75) × 10 (low multiple) = $103,230 ≈ returned low $103,227 — matches marketing/lib/valuation/computeCatalogValuation.ts exactly. ✅

Response ↔ docs reconciliation: top-level keys status, measurements, valuation, total_streams, catalog_age_years — matches the merged docs#265 contract field-for-field; no undocumented fields observed, no documented-but-absent fields.

Two observations for reviewers:

  1. Ownership is stricter than the sibling. This endpoint 404s a foreign catalog even for the admin bearer, while sibling GET /api/catalogs/songs serves foreign catalogs to the same token. Strict-by-default seems right for a new endpoint, but if internal admin tooling (account-health reports) is expected to read any account's measurements, an explicit admin path will be needed later. Flagging intentionality, not requesting a change.
  2. The [TEST]-prefixed catalog above was left in the shared DB per the test-data convention — safe to delete anytime.

🤖 Generated with Claude Code

@sweetmantech

Copy link
Copy Markdown
Contributor Author

Addendum — docs ↔ implementation reconciliation (2026-07-06)

Field-by-field diff of the merged docs#265 contract vs the live preview responses. Correction to my earlier comment: "no undocumented fields observed" was true of the 200 body but not of the 400s.

Exact matches:

  • 200 body: status, measurements[], valuation{low,mid,high}, total_streams, catalog_age_years — field-for-field, types as documented.
  • Measurement rows: isrc, title, playcount, measured_at (ISO 8601).
  • Valuation formula text matches the arithmetic exactly (low band reproduced to rounding).
  • "Songs never measured are omitted" — now behaviorally verified: added a 6th, never-measured song (QZTST2699001) to the [TEST] catalog → still 5 measurements, total_streams unchanged (18,505,795).
  • 401/404 match CatalogSongsErrorResponse; the 404 description explicitly covers foreign catalogs, matching the strict ownership observed.

Differences found:

  1. Undocumented field on 400s: both 400 responses include "missing_fields": ["catalogId"], which CatalogSongsErrorResponse doesn't declare. Likely inherited from the shared catalogs validator (siblings may emit it too, equally undocumented).
  2. Misleading semantics on invalid input: ?catalogId=not-a-uuid also returns missing_fields: ["catalogId"] — the field is present, just invalid. Cosmetic, but "missing" is the wrong word for that case.
  3. Undocumented ordering: measurements return sorted by playcount desc. Docs are silent on order (fine, but worth a line if clients will rely on it).

None of these block merge (the error schema doesn't forbid extra properties). Suggested follow-up: a one-line docs patch adding optional missing_fields to the shared error schema — or an api-side tweak emitting it only for genuinely missing params. Happy to open either on request.

🤖 Generated with Claude Code

@sweetmantech sweetmantech merged commit 7ed9338 into main Jul 6, 2026
6 checks passed
@sweetmantech

Copy link
Copy Markdown
Contributor Author

🐛 P1 found post-merge: results silently capped at 1,000 rows — valuation undercounts large catalogs

Found 2026-07-06 while building a real 15-artist roster catalog (2,679 measured songs) for chat#1850 homepage testing.

Endpoint returned Ground truth (SQL over song_measurements × catalog_songs)
measurements exactly 1,000 2,679 distinct songs
total_streams 4,834,388,730 16,308,837,441
valuation (mid) $39.3M $132.5M at the same formula

The 1,000 is the Supabase/PostgREST default row limit — the latest-measurements read doesn't paginate, and total_streams + the band are computed over the truncated set, so large catalogs undervalue by whatever falls off the end (3.4× here) with no error or flag. Live repro: GET /api/catalogs/measurements?catalogId=7d3e5c35-1fac-4b88-aa90-e2e4a52dfe78 (the [TEST] roster catalog, account-gated) → measurements.length === 1000. The chat#1852 hero renders the bug directly: "…· 1000 tracks measured".

Suggested fix: page through the read (.range() loop or a batched RPC) until exhaustion — RED test first with a >1,000-song catalog fixture; docs follow-up only if we instead choose to document a cap (not recommended: a silent cap on a valuation endpoint is the marketing-card partial-value bug all over again).

Tracked as an Open item on recoupable/chat#1850.

🤖 Generated with Claude Code

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