Skip to content

feat(research): POST /api/research/events — live shows by Recoup artist_id - #826

Merged
sweetmantech merged 3 commits into
mainfrom
feat/research-events
Aug 10, 2026
Merged

feat(research): POST /api/research/events — live shows by Recoup artist_id#826
sweetmantech merged 3 commits into
mainfrom
feat/research-events

Conversation

@sweetmantech

@sweetmantech sweetmantech commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Implements POST /api/research/events, returning a Recoup artist's live shows.

Tracking issue: recoupable/chat#1954
Contract: recoupable/docs#297
Merge order: docs#297 first, then this PR.

Contract revised 2026-08-10. This PR originally took a bandsintown_id. It now takes the Recoup artist_id and resolves the provider id server-side. Two reasons: callers shouldn't need a third-party id to ask about their own artist, and publishing a provider id as a request parameter couples the contract to that provider. Concretely, the sandboxed agent that consumes this could not have obtained a bandsintown_id anyway — resolving one needs a credential buildRecoupExecEnv deliberately withholds, so the original design was unusable by its main consumer.

Why id-keyed at all

Looking shows up by artist name can resolve to a different, more search-prominent performer sharing that name. That is not hypothetical — it put the wrong artist's concert into a customer-facing report on 2026-08-10. Resolution now happens through the artist's connected profile, so it cannot drift.

The distinction that matters most

State Response
Artist has no events profile connected 404 + how to connect one
Artist connected, nothing scheduled 200 {"events": []}

Collapsing these would let a missing profile read as "this artist has no shows" — the same silent-wrong-answer failure the endpoint exists to prevent. Both are covered by tests, including one asserting the empty case is not a 404.

The 404 body states the exact URL format and links the field that accepts it:

Error: no bandsintown ID connected to this artist. Please connect the bandsintown ID in this
format: bandsintown.com/a/{id}-{slug} Docs here:
https://docs.recoupable.dev/api-reference/artists/update#body-profile-urls

Verified that anchor resolves: the artist PATCH body param is profileUrls, and Mintlify kebab-cases it to body-profile-urls, which is present on the live page.

Authorization — new, and worth a look

Switching to artist_id introduced a cross-tenant read that the provider-id contract didn't have: selectAccountSocials({ accountId: artistId }) is not scoped to the caller. The handler therefore resolves the caller's roster via getArtists({ accountId, orgId }) and 404s if the artist isn't in it. There is no generic artist-ownership guard in the repo, so this follows the existing convention of scoping the query rather than adding a new guard. Reviewers: this is the piece I'd most like a second opinion on — in particular whether loading the full roster is acceptable for large label accounts, or whether a targeted membership query is worth adding.

Layering

File Role
app/api/research/events/route.ts thin route, maxDuration = 60
lib/research/postResearchEventsHandler.ts roster scope → resolve → fetch
lib/research/validatePostResearchEventsRequest.ts auth + Zod body + credit gate
lib/research/getArtistBandsintownId.ts resolves the provider id from socials
lib/research/ensureEventsResearchCredits.ts 1 credit, priced like web search
lib/apify/bandsintown/fetchBandsintownEvents.ts actor call + normalization

URL parsing is case-insensitive because socials.profile_url is lowercased by a DB trigger, and tolerates a missing www. and trailing query strings.

Sync .call() follows the existing pattern

The repo splits Apify usage by purpose: .start() + webhook for scrapers that persist and fan out, .call() blocking for fetchers whose caller needs the data back (lib/apify/spotify/fetchSpotifyAlbumPlayCounts.ts:39). This is the second shape. Measured p95 5.7s (N=12 real runs, concurrency 6) against maxDuration = 60. Rationale recorded in chat#1954.

Verification

Check Result
vitest run lib/research lib/apify lib/artists 605 passed / 127 files
New tests 36 (5 fetcher, 7 resolver, 11 handler, 13 validator)
eslint on touched files clean
tsc --noEmit 0 errors in these files

Honest notes:

  • The fetcher and resolver were written RED → GREEN. The validator was implemented before its test, so that unit was not strict TDD, though it is now fully covered including the uuid rule and a case asserting a bandsintown_id-only body is rejected.
  • tsc --noEmit reports 201 pre-existing errors on pristine origin/main (all in lib/tasks/ and lib/trigger/ tests). This branch adds none, but the repo typecheck is red independent of this PR.
  • Preview verification is partial. 401/CORS paths confirmed on the preview; the authenticated 200/400/402/404 paths could not be exercised because preview auth uses a different PRIVY_PROJECT_SECRET while sharing the prod database, so minting a preview-valid key means writing to production. Not done unprompted. See the earlier verification comment.

Note on rollout

With artist_id as the only input, this returns 404 for every artist until Bandsintown profiles are backfilled — no artist has one today. That backfill is now a prerequisite rather than a follow-up, and it needs no new code: the existing PATCH /api/artists/{id} profileUrls field accepts these URLs.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added an events research endpoint for retrieving artist event listings.
    • Supports upcoming, past, or all event date filters.
    • Normalizes and chronologically sorts event results.
    • Added CORS preflight support and standardized validation and error responses.
    • Enforces research credits for each request.

…s (chat#1954)

Implements the contract specified in recoupable/docs#297.

Looking shows up by artist *name* can resolve to a different, more
search-prominent performer who shares that name. This endpoint takes a
numeric Bandsintown artist id instead, so the result set cannot drift to the
wrong artist. The validator constrains `bandsintown_id` to digits for the
same reason: accepting free text would quietly reintroduce the ambiguity the
endpoint exists to remove.

- lib/apify/bandsintown/fetchBandsintownEvents.ts — actor call + normalization
- lib/research/validatePostResearchEventsRequest.ts — auth, body, credit gate
- lib/research/ensureEventsResearchCredits.ts — 1 credit, priced like web search
- lib/research/postResearchEventsHandler.ts — handler
- app/api/research/events/route.ts — thin route, maxDuration 60

Calls Apify synchronously via `.call()`, following fetchSpotifyAlbumPlayCounts
rather than the `.start()` + webhook scrapers: this is a read-only fetch whose
caller needs the data in its response and which persists nothing. Measured p95
5.7s against the 60s route budget (N=12, concurrency 6).

An artist with no events returns 200 with an empty array, not a 404 — "not
touring" is a valid answer to the question asked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@cursor

cursor Bot commented Aug 10, 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 Aug 10, 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 Aug 10, 2026 9:16pm

Request Review

@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The PR adds /api/research/events. The route supports CORS preflight and POST requests, validates authentication and input, enforces research credits, resolves Bandsintown profiles, retrieves normalized events through Apify, and returns standardized responses.

Changes

Research events endpoint

Layer / File(s) Summary
Bandsintown event retrieval
lib/apify/bandsintown/fetchBandsintownEvents.ts
The Apify utility fetches events by artist ID and date filter. It normalizes fields, removes entries without dates, and sorts results chronologically.
Request validation and processing
lib/research/validatePostResearchEventsRequest.ts, lib/research/ensureEventsResearchCredits.ts, lib/research/getArtistBandsintownId.ts, lib/research/postResearchEventsHandler.ts
The request flow validates authentication and input, checks one research credit, scopes artist access, resolves the Bandsintown ID, retrieves events, and handles missing profiles and errors.
Route wiring and CORS handling
app/api/research/events/route.ts
The route sets a 60-second maximum duration, returns a CORS-enabled preflight response, and delegates POST requests to the research handler.

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

Possibly related issues

Possibly related PRs

  • recoupable/api#824 — Adds a similar CORS-enabled POST route with request validation and handler delegation.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant ResearchEventsRoute
  participant PostResearchEventsHandler
  participant CreditGate
  participant BandsintownResolver
  participant ApifyBandsintown

  Client->>ResearchEventsRoute: POST artist ID and date
  ResearchEventsRoute->>PostResearchEventsHandler: delegate request
  PostResearchEventsHandler->>CreditGate: validate research credits
  CreditGate-->>PostResearchEventsHandler: allow or short-circuit
  PostResearchEventsHandler->>BandsintownResolver: resolve connected Bandsintown ID
  BandsintownResolver-->>PostResearchEventsHandler: return provider ID
  PostResearchEventsHandler->>ApifyBandsintown: fetch events
  ApifyBandsintown-->>PostResearchEventsHandler: return normalized events
  PostResearchEventsHandler-->>Client: return success or error response
Loading

Poem

An artist ID starts the flow,
Validation checks what requests show.
Bandsintown events arrive in line,
Apify sorts each dated sign.
Credits guard the route as results grow.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Solid & Clean Code ⚠️ Warning postResearchEventsHandler spans 43 lines and combines roster authorization, profile resolution, Apify fetching, billing, and response formatting; the 1-credit cost is also duplicated. Extract focused roster and deduction helpers, and define one shared events credit-cost constant for both the gate and recordCreditDeduction.
✅ Passed checks (2 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ 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/research-events

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 — https://api-l0e17cwkw-recoup.vercel.app

Deployment 5836552759, built from 6f72229 (this PR's head).

# Check Documented Actual
1 No auth header 401 401 {"status":"error","error":"Exactly one of x-api-key or Authorization must be provided"}
2 Invalid x-api-key 401 401 {"status":"error","message":"Unauthorized"}
3 OPTIONS preflight 200 + CORS 200, access-control-allow-origin: *
4 No secret echoed in any error body 0 matches for apify_token/privy/secret/sk_live across both bodies
5 Control: same request against prod route not deployed 404

Check 5 matters: prod 404s while the preview routes, which proves the preview is genuinely serving this branch's new code rather than a stale build.

What is NOT verified here, and why

The 200 happy path, 400 validation, and 402 credit paths are all gated behind auth, and I could not exercise them against this preview without writing to production.

getAccountIdByApiKey resolves a key by looking up hashApiKey(rawKey, PRIVY_PROJECT_SECRET) in account_api_keys. Preview deployments use a different PRIVY_PROJECT_SECRET than prod but share the prod Supabase — confirmed empirically above, where a valid prod key 401s here. Minting a preview-valid key therefore means inserting a row into the production account_api_keys table for test purposes. That is a production write, so I stopped rather than doing it unprompted.

Those three paths are covered at the unit level (24 tests):

  • 200 + events[] and 200 + [] for an artist with no showspostResearchEventsHandler.test.ts
  • 400 for missing / non-numeric bandsintown_id ("Loreen", "micky-dolenz", "", "1590132abc", "a1590132") and unknown datevalidatePostResearchEventsRequest.test.ts
  • 402 credit short-circuit — validatePostResearchEventsRequest.test.ts

Separately, the underlying actor call was exercised against the real Bandsintown actor outside the app on 2026-08-10: 38 artist ids, 38 successes, 0 failures, 111 events, p95 5.7s. So the data path is proven; what remains unproven is specifically this route's authenticated request/response cycle on a deployed preview.

Ask: if a preview-scoped key can be minted (or one already exists), I'll run the remaining three checks and update this table before merge.

@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/research/validatePostResearchEventsRequest.ts (1)

14-18: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Derive the validated type from the schema.

ValidatedPostResearchEventsRequest restates the schema shape by hand. The union "upcoming" | "past" | "all" now exists in three places: this type, the Zod enum on Line 11, and BandsintownDateFilter in lib/apify/bandsintown/fetchBandsintownEvents.ts. Any future filter value must be added in each place, and the compiler will not remind you.

♻️ Suggested change
-export type ValidatedPostResearchEventsRequest = {
-  accountId: string;
-  bandsintown_id: string;
-  date?: "upcoming" | "past" | "all";
-};
+export type ValidatedPostResearchEventsRequest = z.infer<typeof bodySchema> & {
+  accountId: string;
+};

You can also type the enum from the shared source: z.enum(["upcoming", "past", "all"] as const satisfies readonly BandsintownDateFilter[]).

As per path instructions, validation functions must "Export inferred types for validated data".

🤖 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/research/validatePostResearchEventsRequest.ts` around lines 14 - 18,
Update ValidatedPostResearchEventsRequest to be inferred from the Zod validation
schema rather than manually restating its fields, exporting the inferred
validated-data type while preserving the schema’s BandsintownDateFilter-backed
enum.

Source: Path instructions

lib/research/postResearchEventsHandler.ts (1)

35-37: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Log the swallowed credit failure.

The decision not to block the response is correct. The silence is not. When recordCreditDeduction fails, the account receives paid data for free and no signal reaches your logs or metrics. This failure mode is invisible until someone reconciles the ledger by hand.

♻️ Suggested change
-    } catch {
+    } catch (creditError) {
       // Credit deduction failed but data was fetched — don't block the response
+      console.error("recordCreditDeduction failed for research events", {
+        accountId: validated.accountId,
+        error: creditError instanceof Error ? creditError.message : String(creditError),
+      });
     }

Use the project logger if one exists, and consider a counter metric so unbilled successes can trigger an alert.

🤖 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/research/postResearchEventsHandler.ts` around lines 35 - 37, Update the
catch block surrounding recordCreditDeduction to log the deduction failure
through the project logger, while preserving the current behavior of returning
the fetched data without blocking. If an established metrics mechanism is
available nearby, increment a counter for these unbilled successes as well.
lib/apify/bandsintown/fetchBandsintownEvents.ts (1)

54-58: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Bound the actor run to the route budget.

apifyClient.actor().call() waits indefinitely for the actor run by default, but the route has maxDuration = 60. Set a module-level timeout and waitSecs below that budget when calling the actor so slow runs fail before the route is terminated.

🤖 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/apify/bandsintown/fetchBandsintownEvents.ts` around lines 54 - 58, Update
the actor invocation in fetchBandsintownEvents around
apifyClient.actor(BANDSINTOWN_ACTOR).call to use module-level timeout and
waitSecs values both below the route’s 60-second maxDuration, ensuring slow
actor runs fail before route termination.
🤖 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/research/ensureEventsResearchCredits.ts`:
- Line 11: Export EVENTS_RESEARCH_CREDIT_COST from
ensureEventsResearchCredits.ts while preserving its pricing rationale comment.
In postResearchEventsHandler.ts lines 30-34, import and use this constant for
creditsToDeduct instead of the literal 1 so the gate and deduction share one
value.

In `@lib/research/postResearchEventsHandler.ts`:
- Around line 40-45: Update the catch block in the handler around
fetchBandsintownEvents to stop returning error.message to callers; always use a
stable generic failure message in errorResponse, and log the original error with
relevant context server-side before returning the 500 response.

In `@lib/research/validatePostResearchEventsRequest.ts`:
- Around line 31-47: Remove the ensureEventsResearchCredits call from
validatePostResearchEventsRequest so it only authenticates and parses the
request body, returning the validated data or auth/validation errors. Add the
credit check immediately after successful validation in
postResearchEventsHandler, returning its response before processing the request.
Rename the validator to validatePostResearchEventsBody.ts and update all imports
and references.

---

Nitpick comments:
In `@lib/apify/bandsintown/fetchBandsintownEvents.ts`:
- Around line 54-58: Update the actor invocation in fetchBandsintownEvents
around apifyClient.actor(BANDSINTOWN_ACTOR).call to use module-level timeout and
waitSecs values both below the route’s 60-second maxDuration, ensuring slow
actor runs fail before route termination.

In `@lib/research/postResearchEventsHandler.ts`:
- Around line 35-37: Update the catch block surrounding recordCreditDeduction to
log the deduction failure through the project logger, while preserving the
current behavior of returning the fetched data without blocking. If an
established metrics mechanism is available nearby, increment a counter for these
unbilled successes as well.

In `@lib/research/validatePostResearchEventsRequest.ts`:
- Around line 14-18: Update ValidatedPostResearchEventsRequest to be inferred
from the Zod validation schema rather than manually restating its fields,
exporting the inferred validated-data type while preserving the schema’s
BandsintownDateFilter-backed enum.
🪄 Autofix

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 Plus

Run ID: d46efec8-82fd-428b-b3f7-22cc8e2a1315

📥 Commits

Reviewing files that changed from the base of the PR and between e53242d and 6f72229.

⛔ Files ignored due to path filters (3)
  • lib/apify/bandsintown/__tests__/fetchBandsintownEvents.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/research/__tests__/postResearchEventsHandler.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/research/__tests__/validatePostResearchEventsRequest.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
📒 Files selected for processing (5)
  • app/api/research/events/route.ts
  • lib/apify/bandsintown/fetchBandsintownEvents.ts
  • lib/research/ensureEventsResearchCredits.ts
  • lib/research/postResearchEventsHandler.ts
  • lib/research/validatePostResearchEventsRequest.ts

* sweeps viable — a caller fanning out across a label roster makes one call per
* artist (chat#1954).
*/
const EVENTS_RESEARCH_CREDIT_COST = 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.

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

Share the credit cost constant between the gate and the deduction. EVENTS_RESEARCH_CREDIT_COST is module-private, so the deduction site repeats the value as a literal 1. The gate and the charge must always agree; today a price change in one place silently leaves the other behind, and the account is checked for one amount and charged another.

  • lib/research/ensureEventsResearchCredits.ts#L11-L11: export the constant, for example export const EVENTS_RESEARCH_CREDIT_COST = 1;, keeping the pricing rationale comment as its documentation.
  • lib/research/postResearchEventsHandler.ts#L30-L34: import EVENTS_RESEARCH_CREDIT_COST and pass it as creditsToDeduct in place of the literal 1.

As per coding guidelines, "Use constants for repeated values".

📍 Affects 2 files
  • lib/research/ensureEventsResearchCredits.ts#L11-L11 (this comment)
  • lib/research/postResearchEventsHandler.ts#L30-L34
🤖 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/research/ensureEventsResearchCredits.ts` at line 11, Export
EVENTS_RESEARCH_CREDIT_COST from ensureEventsResearchCredits.ts while preserving
its pricing rationale comment. In postResearchEventsHandler.ts lines 30-34,
import and use this constant for creditsToDeduct instead of the literal 1 so the
gate and deduction share one value.

Source: Coding guidelines

Comment on lines +40 to +45
} catch (error) {
return errorResponse(
error instanceof Error ? error.message : "Artist events lookup failed",
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.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Do not forward upstream error text to the caller.

error.message reaches the client verbatim. fetchBandsintownEvents throws a message that names the Apify actor run status, and any client or network error thrown below it is forwarded with the same path. This exposes internal provider detail through a public API, and it gives no server-side record of the failure.

Return a stable message, and log the original error with context.

🛡️ Suggested change
   } catch (error) {
-    return errorResponse(
-      error instanceof Error ? error.message : "Artist events lookup failed",
-      500,
-    );
+    console.error("Artist events lookup failed", {
+      error: error instanceof Error ? error.message : String(error),
+    });
+    return errorResponse("Artist events lookup failed", 500);
   }
📝 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
} catch (error) {
return errorResponse(
error instanceof Error ? error.message : "Artist events lookup failed",
500,
);
}
} catch (error) {
console.error("Artist events lookup failed", {
error: error instanceof Error ? error.message : String(error),
});
return errorResponse("Artist events lookup failed", 500);
}
🤖 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/research/postResearchEventsHandler.ts` around lines 40 - 45, Update the
catch block in the handler around fetchBandsintownEvents to stop returning
error.message to callers; always use a stable generic failure message in
errorResponse, and log the original error with relevant context server-side
before returning the 500 response.

Comment on lines +31 to +47
export async function validatePostResearchEventsRequest(
request: NextRequest,
): Promise<NextResponse | ValidatedPostResearchEventsRequest> {
const authResult = await validateAuthContext(request);
if (authResult instanceof NextResponse) return authResult;

const body = await request.json().catch(() => null);
const parsed = bodySchema.safeParse(body);
if (!parsed.success) {
return errorResponse(parsed.error.issues[0]?.message ?? "Invalid request body", 400);
}

const short = await ensureEventsResearchCredits(authResult.accountId);
if (short) return short;

return { accountId: authResult.accountId, ...parsed.data };
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Move the credit gate out of the validator.

This function does three jobs: authentication, body parsing, and credit enforcement. The credit check on Lines 43-44 is a billing policy decision, not input validation. Two consequences follow. First, postResearchEventsHandler cannot see whether a returned NextResponse is a 400, a 401, or a 402, so it cannot add per-concern telemetry or ordering. Second, a future caller that wants to validate a body without charging an account has no seam to do it.

Keep this file to auth plus body parsing, and call ensureEventsResearchCredits from the handler.

♻️ Suggested change
-  const short = await ensureEventsResearchCredits(authResult.accountId);
-  if (short) return short;
-
   return { accountId: authResult.accountId, ...parsed.data };

Then in lib/research/postResearchEventsHandler.ts, after validation succeeds:

const short = await ensureEventsResearchCredits(validated.accountId);
if (short) return short;

The file name also deviates from the documented convention. Consider validatePostResearchEventsBody.ts once the responsibility is narrowed to the body.

As per path instructions, "Single responsibility per function" and "Follow naming: validateBody.ts or validateQuery.ts".

📝 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
export async function validatePostResearchEventsRequest(
request: NextRequest,
): Promise<NextResponse | ValidatedPostResearchEventsRequest> {
const authResult = await validateAuthContext(request);
if (authResult instanceof NextResponse) return authResult;
const body = await request.json().catch(() => null);
const parsed = bodySchema.safeParse(body);
if (!parsed.success) {
return errorResponse(parsed.error.issues[0]?.message ?? "Invalid request body", 400);
}
const short = await ensureEventsResearchCredits(authResult.accountId);
if (short) return short;
return { accountId: authResult.accountId, ...parsed.data };
}
export async function validatePostResearchEventsRequest(
request: NextRequest,
): Promise<NextResponse | ValidatedPostResearchEventsRequest> {
const authResult = await validateAuthContext(request);
if (authResult instanceof NextResponse) return authResult;
const body = await request.json().catch(() => null);
const parsed = bodySchema.safeParse(body);
if (!parsed.success) {
return errorResponse(parsed.error.issues[0]?.message ?? "Invalid request body", 400);
}
return { accountId: authResult.accountId, ...parsed.data };
}
🤖 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/research/validatePostResearchEventsRequest.ts` around lines 31 - 47,
Remove the ensureEventsResearchCredits call from
validatePostResearchEventsRequest so it only authenticates and parses the
request body, returning the validated data or auth/validation errors. Add the
credit check immediately after successful validation in
postResearchEventsHandler, returning its response before processing the request.
Rename the validator to validatePostResearchEventsBody.ts and update all imports
and references.

Source: Path instructions

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

7 issues found across 8 files

Confidence score: 3/5

  • app/api/research/events/route.ts forwards requests to an external Apify actor without rate limiting, so repeated calls can spike third-party usage, burn credits, and degrade endpoint reliability under abuse — add per-user/IP throttling before invoking the handler.
  • lib/research/postResearchEventsHandler.ts currently returns errorResponse(error.message, 500), which can expose raw upstream/internal exception text to clients; the related 500-path test in lib/research/__tests__/postResearchEventsHandler.test.ts wouldn’t catch that leak — return a sanitized generic 500 payload and assert the error envelope in tests.
  • lib/apify/bandsintown/fetchBandsintownEvents.ts can emit malformed actor timestamps and can drop events with usable starts_at data when datetime is empty, risking missing or incorrect event dates in responses — apply non-empty fallback logic, enforce normalized ISO output, and add the missing branch test in lib/apify/bandsintown/__tests__/fetchBandsintownEvents.test.ts.
  • Credit accounting logic is split between lib/research/ensureEventsResearchCredits.ts and a hardcoded 1 in lib/research/postResearchEventsHandler.ts, which can drift and cause inconsistent charging if costs change; the same handler also has a dead catch {} around recordCreditDeduction — export/import a single cost constant and handle the returned { success: false } path explicitly.
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/research/__tests__/validatePostResearchEventsRequest.test.ts">

<violation number="1" location="lib/research/__tests__/validatePostResearchEventsRequest.test.ts:59">
P3: The 400-path tests in this new validator assert only the HTTP status and never the response envelope. Since every 400 here is built by errorResponse as `{ status: "error", error }`, asserting the body too (e.g. `status: "error"` and a string `error`) would lock in the documented envelope and catch regressions that leave status 400 but break the shape clients parse. Consider asserting the parsed JSON body on the shared 400 cases.</violation>
</file>

<file name="lib/apify/bandsintown/__tests__/fetchBandsintownEvents.test.ts">

<violation number="1" location="lib/apify/bandsintown/__tests__/fetchBandsintownEvents.test.ts:115">
P3: The suite covers fetch/sort/empty/FAILED, but not the documented 'items without usable date are dropped' path, which is a distinct branch in normalizeEvent + filter. Consider adding a case that feeds an item missing both datetime and starts_at (and asserts it is omitted) so the drop behavior is locked in.</violation>
</file>

<file name="lib/research/__tests__/postResearchEventsHandler.test.ts">

<violation number="1" location="lib/research/__tests__/postResearchEventsHandler.test.ts:147">
P2: The 500-path test doesn't verify the error envelope, so a real leak ships: the handler responds `errorResponse(error.message, 500)`, exposing raw exception text (Apify run IDs/URLs, internal SDK messages) to the caller. Per repo convention 500 responses should return a hardcoded message, and the test should assert the exception text never appears in the body. Assert that response JSON is `{ status: "error", error: <fixed string> }` and does not contain the mocked throw text.</violation>
</file>

<file name="lib/apify/bandsintown/fetchBandsintownEvents.ts">

<violation number="1" location="lib/apify/bandsintown/fetchBandsintownEvents.ts:80">
P2: Malformed actor timestamps can be returned as event dates, and an empty `datetime` drops an otherwise dated `starts_at` event. Fall back on nonempty values and require the normalized ISO date shape before returning it.</violation>
</file>

<file name="lib/research/postResearchEventsHandler.ts">

<violation number="1" location="lib/research/postResearchEventsHandler.ts:20">
P3: The try/catch around `recordCreditDeduction` here can never catch anything: `recordCreditDeduction` never rejects — it catches internally and returns `{ success: false }` on failure. The empty `catch {}` is dead code and its return value is discarded, so the comment "Credit deduction failed but data was fetched — don't block the response" describes behavior the handler itself never actually acts on. Trimming the dead try/catch and, optionally, logging the returned `success: false` would keep intent accurate.</violation>
</file>

<file name="lib/research/ensureEventsResearchCredits.ts">

<violation number="1" location="lib/research/ensureEventsResearchCredits.ts:11">
P2: The credit cost is defined as a private constant here but repeated as a literal `1` in postResearchEventsHandler.ts's recordCreditDeduction call. Export this constant and import it at the deduction site so the credit check and the actual charge can't drift out of sync if pricing changes.</violation>
</file>

<file name="app/api/research/events/route.ts">

<violation number="1" location="app/api/research/events/route.ts:23">
P2: Custom agent: **API Design Consistency and Maintainability**

This new scraping endpoint delegates directly to a handler that triggers an external Apify actor, but no rate limiting is in place. The per-request credit gate limits access by balance, not by request rate, so a caller with credits can hammer the endpoint. Consider adding a rate-limiting wrapper or middleware before the request reaches the handler, especially since this is a scraping-backed route where abuse has a direct external cost and latency impact.</violation>
</file>
Architecture diagram
sequenceDiagram
    participant Client as API Client
    participant Route as POST /api/research/events
    participant Handler as postResearchEventsHandler
    participant Validator as validatePostResearchEventsRequest
    participant Auth as validateAuthContext
    participant Credits as ensureEventsResearchCredits
    participant Fetcher as fetchBandsintownEvents
    participant Apify as Apify Actor (bandsintown-scraper)
    participant Ledger as Credit Ledger

    Note over Client,Ledger: NEW: Artist events lookup by numeric Bandsintown ID

    Client->>Route: POST /api/research/events
    Route->>Handler: Forward request

    Handler->>Validator: Validate request

    alt Auth fails (401)
        Validator->>Auth: validateAuthContext(request)
        Auth-->>Validator: NextResponse 401
        Validator-->>Handler: Return Unauthorized response
        Handler-->>Client: 401 Unauthorized
    else Body validation fails (400)
        Validator->>Auth: validateAuthContext(request)
        Auth-->>Validator: { accountId }
        Validator->>Validator: Parse body with Zod (bandsintown_id must be /^\d+$/)
        alt Invalid bandsintown_id or missing fields
            Validator-->>Handler: NextResponse 400
            Handler-->>Client: 400 Invalid body
        else Valid body
            Validator->>Credits: ensureEventsResearchCredits(accountId)
            alt Insufficient credits (402)
                Credits-->>Validator: NextResponse 402
                Validator-->>Handler: Return Payment Required
                Handler-->>Client: 402 Insufficient credits
            else Credits OK
                Credits-->>Validator: null
                Validator-->>Handler: { accountId, bandsintown_id, date? }
            end
        end
    end

    Note over Handler,Fetcher: Credit gate passed — proceed with fetch

    Handler->>Fetcher: fetchBandsintownEvents({ bandsintownId, date })
    Fetcher->>Apify: .call() with artistId and date filter
    alt Actor run SUCCEEDED
        Apify-->>Fetcher: { defaultDatasetId, success }
        Fetcher->>Apify: dataset(defaultDatasetId).listItems()
        Apify-->>Fetcher: Raw event items
        Fetcher->>Fetcher: Normalize events, drop undated rows, sort by date
        Fetcher-->>Handler: Normalized BandsintownEvent[]
    else Actor run FAILED or no datasetId
        Apify-->>Fetcher: { status: "FAILED" }
        Fetcher-->>Handler: Throw Error
        Handler->>Handler: Catch error, return 500
        Handler-->>Client: 500 errorResponse
    end

    Note over Handler,Ledger: Credit deduction (after successful fetch, before response)

    Handler->>Ledger: recordCreditDeduction({ accountId, creditsToDeduct: 1, source: "api" })
    alt Deduction succeeds
        Ledger-->>Handler: OK
    else Deduction fails (e.g. ledger down)
        Ledger-->>Handler: Error thrown
        Handler->>Handler: Silently catch — data still returned
    end

    alt Events found
        Handler-->>Client: 200 { status: "success", events: [...] }
    else No events
        Handler-->>Client: 200 { status: "success", events: [] }
    end

    Note over Client,Apify: Key design decisions: <br/>- Numeric ID lookup prevents artist name ambiguity <br/>- .call() blocking (not .start()+webhook) for read-only fetch <br/>- Empty events = 200, not 404 <br/>- Failed actor = 500 <br/>- Credit deducted after fetch, ledger failure doesn't block response
Loading

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

Re-trigger cubic

const res = await postResearchEventsHandler(req({ bandsintown_id: "1590132" }));

expect(res.status).toBe(500);
expect((await res.json()).status).toBe("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: The 500-path test doesn't verify the error envelope, so a real leak ships: the handler responds errorResponse(error.message, 500), exposing raw exception text (Apify run IDs/URLs, internal SDK messages) to the caller. Per repo convention 500 responses should return a hardcoded message, and the test should assert the exception text never appears in the body. Assert that response JSON is { status: "error", error: <fixed string> } and does not contain the mocked throw text.

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

<comment>The 500-path test doesn't verify the error envelope, so a real leak ships: the handler responds `errorResponse(error.message, 500)`, exposing raw exception text (Apify run IDs/URLs, internal SDK messages) to the caller. Per repo convention 500 responses should return a hardcoded message, and the test should assert the exception text never appears in the body. Assert that response JSON is `{ status: "error", error: <fixed string> }` and does not contain the mocked throw text.</comment>

<file context>
@@ -0,0 +1,162 @@
+    const res = await postResearchEventsHandler(req({ bandsintown_id: "1590132" }));
+
+    expect(res.status).toBe(500);
+    expect((await res.json()).status).toBe("error");
+  });
+
</file context>

* @returns The normalized event, or null when it carries no date
*/
function normalizeEvent(raw: RawEvent): BandsintownEvent | null {
const date = (raw.datetime ?? raw.starts_at ?? "").slice(0, 10);

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: Malformed actor timestamps can be returned as event dates, and an empty datetime drops an otherwise dated starts_at event. Fall back on nonempty values and require the normalized ISO date shape before returning it.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/apify/bandsintown/fetchBandsintownEvents.ts, line 80:

<comment>Malformed actor timestamps can be returned as event dates, and an empty `datetime` drops an otherwise dated `starts_at` event. Fall back on nonempty values and require the normalized ISO date shape before returning it.</comment>

<file context>
@@ -0,0 +1,93 @@
+ * @returns The normalized event, or null when it carries no date
+ */
+function normalizeEvent(raw: RawEvent): BandsintownEvent | null {
+  const date = (raw.datetime ?? raw.starts_at ?? "").slice(0, 10);
+  if (!date) return null;
+
</file context>

* sweeps viable — a caller fanning out across a label roster makes one call per
* artist (chat#1954).
*/
const EVENTS_RESEARCH_CREDIT_COST = 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: The credit cost is defined as a private constant here but repeated as a literal 1 in postResearchEventsHandler.ts's recordCreditDeduction call. Export this constant and import it at the deduction site so the credit check and the actual charge can't drift out of sync if pricing changes.

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

<comment>The credit cost is defined as a private constant here but repeated as a literal `1` in postResearchEventsHandler.ts's recordCreditDeduction call. Export this constant and import it at the deduction site so the credit check and the actual charge can't drift out of sync if pricing changes.</comment>

<file context>
@@ -0,0 +1,22 @@
+ * sweeps viable — a caller fanning out across a label roster makes one call per
+ * artist (chat#1954).
+ */
+const EVENTS_RESEARCH_CREDIT_COST = 1;
+
+/**
</file context>

* @param request - JSON body with `bandsintown_id` string
* @returns JSON `{ status, events }` or error
*/
export async function POST(request: NextRequest) {

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: Custom agent: API Design Consistency and Maintainability

This new scraping endpoint delegates directly to a handler that triggers an external Apify actor, but no rate limiting is in place. The per-request credit gate limits access by balance, not by request rate, so a caller with credits can hammer the endpoint. Consider adding a rate-limiting wrapper or middleware before the request reaches the handler, especially since this is a scraping-backed route where abuse has a direct external cost and latency impact.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/api/research/events/route.ts, line 23:

<comment>This new scraping endpoint delegates directly to a handler that triggers an external Apify actor, but no rate limiting is in place. The per-request credit gate limits access by balance, not by request rate, so a caller with credits can hammer the endpoint. Consider adding a rate-limiting wrapper or middleware before the request reaches the handler, especially since this is a scraping-backed route where abuse has a direct external cost and latency impact.</comment>

<file context>
@@ -0,0 +1,25 @@
+ * @param request - JSON body with `bandsintown_id` string
+ * @returns JSON `{ status, events }` or error
+ */
+export async function POST(request: NextRequest) {
+  return postResearchEventsHandler(request);
+}
</file context>


// The endpoint exists to remove name-based ambiguity; accepting a name here
// would reintroduce exactly the bug it was built to prevent.
it.each(["Loreen", "micky-dolenz", "", "1590132abc", "a1590132"])(

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 400-path tests in this new validator assert only the HTTP status and never the response envelope. Since every 400 here is built by errorResponse as { status: "error", error }, asserting the body too (e.g. status: "error" and a string error) would lock in the documented envelope and catch regressions that leave status 400 but break the shape clients parse. Consider asserting the parsed JSON body on the shared 400 cases.

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

<comment>The 400-path tests in this new validator assert only the HTTP status and never the response envelope. Since every 400 here is built by errorResponse as `{ status: "error", error }`, asserting the body too (e.g. `status: "error"` and a string `error`) would lock in the documented envelope and catch regressions that leave status 400 but break the shape clients parse. Consider asserting the parsed JSON body on the shared 400 cases.</comment>

<file context>
@@ -0,0 +1,91 @@
+
+  // The endpoint exists to remove name-based ambiguity; accepting a name here
+  // would reintroduce exactly the bug it was built to prevent.
+  it.each(["Loreen", "micky-dolenz", "", "1590132abc", "a1590132"])(
+    "rejects non-numeric bandsintown_id %j with a 400",
+    async value => {
</file context>

@@ -0,0 +1,126 @@
import { describe, it, expect, vi, beforeEach } from "vitest";

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 suite covers fetch/sort/empty/FAILED, but not the documented 'items without usable date are dropped' path, which is a distinct branch in normalizeEvent + filter. Consider adding a case that feeds an item missing both datetime and starts_at (and asserts it is omitted) so the drop behavior is locked in.

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

<comment>The suite covers fetch/sort/empty/FAILED, but not the documented 'items without usable date are dropped' path, which is a distinct branch in normalizeEvent + filter. Consider adding a case that feeds an item missing both datetime and starts_at (and asserts it is omitted) so the drop behavior is locked in.</comment>

<file context>
@@ -0,0 +1,126 @@
+    });
+  });
+
+  it("returns an empty array when the artist has no events (not an error)", async () => {
+    mockRun([]);
+
</file context>

* @returns JSON `{ status, events }`, or an error response
*/
export async function postResearchEventsHandler(request: NextRequest): Promise<NextResponse> {
try {

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 try/catch around recordCreditDeduction here can never catch anything: recordCreditDeduction never rejects — it catches internally and returns { success: false } on failure. The empty catch {} is dead code and its return value is discarded, so the comment "Credit deduction failed but data was fetched — don't block the response" describes behavior the handler itself never actually acts on. Trimming the dead try/catch and, optionally, logging the returned success: false would keep intent accurate.

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

<comment>The try/catch around `recordCreditDeduction` here can never catch anything: `recordCreditDeduction` never rejects — it catches internally and returns `{ success: false }` on failure. The empty `catch {}` is dead code and its return value is discarded, so the comment "Credit deduction failed but data was fetched — don't block the response" describes behavior the handler itself never actually acts on. Trimming the dead try/catch and, optionally, logging the returned `success: false` would keep intent accurate.</comment>

<file context>
@@ -0,0 +1,46 @@
+ * @returns JSON `{ status, events }`, or an error response
+ */
+export async function postResearchEventsHandler(request: NextRequest): Promise<NextResponse> {
+  try {
+    const validated = await validatePostResearchEventsRequest(request);
+    if (validated instanceof NextResponse) return validated;
</file context>

…server-side

Rewrites the endpoint to the contract in recoupable/docs#297.

Callers should not have to hold a Bandsintown id to ask Recoup about their own
artist — and the sandboxed agent that consumes this could not obtain one
anyway, since resolving it needs a credential buildRecoupExecEnv deliberately
withholds. The request now takes `artist_id` and the provider id is resolved
from that artist's connected socials.

- add lib/research/getArtistBandsintownId.ts — parses the numeric id out of a
  connected bandsintown profile URL. Case-insensitive because profile_url is
  lowercased by a DB trigger, and tolerant of a missing www. and of trailing
  query strings.
- validator takes `artist_id` (uuid) and carries accountId + orgId through
- handler scopes the roster lookup via getArtists(accountId, orgId) before
  reading the artist's socials. Without this any authenticated account could
  read any artist's connected profile and events.
- 404 when the artist has no bandsintown profile, with the exact URL format
  and a link to the field that accepts it

Two negative cases stay deliberately distinct: no profile connected is a 404,
while connected-but-not-touring is a 200 with an empty array. Collapsing them
would let a missing profile read as "this artist has no shows" — the same
silent-wrong-answer failure this endpoint exists to prevent. Both are tested.

605 tests pass across the research, apify and artists suites.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@sweetmantech sweetmantech changed the title feat(research): POST /api/research/events — id-keyed artist live shows feat(research): POST /api/research/events — live shows by Recoup artist_id Aug 10, 2026

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

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

Confidence score: 2/5

  • In lib/research/postResearchEventsHandler.ts, passing the validator’s always-null orgId makes organization-owned artists look like personal-only lookups, which can force valid org artists into 404 responses for this endpoint — treat missing org context as undefined before calling getArtists so org rosters are included.
  • In lib/research/getArtistBandsintownId.ts, the current pattern can accept non-Bandsintown URLs that merely contain bandsintown.com/a/{id}-, which risks attaching the wrong artist ID and returning another artist’s events — constrain matching to the actual Bandsintown host (with optional scheme/www) before extracting IDs.
  • In lib/research/getArtistBandsintownId.ts, relying on the default helper limit means Bandsintown links beyond the first 100 socials are missed, causing false no-profile 404s for artists with large social lists — add pagination or query the Bandsintown social directly.
  • In lib/research/__tests__/validatePostResearchEventsRequest.test.ts, the tests/docs contract around numeric bandsintown_id validation appears out of sync with the described endpoint behavior, creating regression risk where accepted/rejected requests diverge from contract — align request validation and tests with the documented numeric-ID rules (/^\d+$/).
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/research/__tests__/validatePostResearchEventsRequest.test.ts">

<violation number="1" location="lib/research/__tests__/validatePostResearchEventsRequest.test.ts:74">
P2: Custom agent: **Flag AI Slop and Fabricated Changes**

The PR description and the referenced docs contract (recoupable/docs#297) describe this endpoint as keyed by a numeric `bandsintown_id` validated against `/^\d+$/`, with validation tests covering strings like "Loreen", "1590132abc", and "a1590132". The implemented code and tests tell a different story: `artist_id` is validated as a Recoup UUID (`z.string().uuid()`), and the numeric id `"1590132"` — which the PR description implies is valid — is actually rejected with a 400, while the PR-described test strings never appear. This mismatch means integrators following the documented contract will send numeric Bandsintown ids and get rejected, and reviewers can't trust the stated contract. Recommend updating the PR description and the docs contract (docs#297) to reflect the actual UUID `artist_id` schema, or aligning the implementation with the documented numeric contract.</violation>
</file>

<file name="lib/research/getArtistBandsintownId.ts">

<violation number="1" location="lib/research/getArtistBandsintownId.ts:10">
P2: An unrelated URL containing `bandsintown.com/a/{id}-` is treated as a connected Bandsintown profile, so reports can fetch another artist's events. Anchor the expression to the Bandsintown host (with optional scheme/www) before extracting the ID.</violation>

<violation number="2" location="lib/research/getArtistBandsintownId.ts:24">
P2: Artists with a Bandsintown link after the first 100 social rows receive the no-profile 404. Paginate this lookup or query the Bandsintown social directly rather than relying on the helper's default cap.</violation>
</file>

<file name="lib/research/postResearchEventsHandler.ts">

<violation number="1" location="lib/research/postResearchEventsHandler.ts:42">
P1: Organization-owned artists always return 404 because this call passes the validator’s always-null `orgId`, selecting the personal-only roster. Treat absent organization context as `undefined` so `getArtists` includes organizations the authenticated account belongs to.</violation>
</file>

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

Re-trigger cubic

// authenticated account could read any artist's connected profile.
const artists = await getArtists({
accountId: validated.accountId,
orgId: validated.orgId,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1: Organization-owned artists always return 404 because this call passes the validator’s always-null orgId, selecting the personal-only roster. Treat absent organization context as undefined so getArtists includes organizations the authenticated account belongs to.

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

<comment>Organization-owned artists always return 404 because this call passes the validator’s always-null `orgId`, selecting the personal-only roster. Treat absent organization context as `undefined` so `getArtists` includes organizations the authenticated account belongs to.</comment>

<file context>
@@ -2,27 +2,56 @@ import { type NextRequest, NextResponse } from "next/server";
+    // authenticated account could read any artist's connected profile.
+    const artists = await getArtists({
+      accountId: validated.accountId,
+      orgId: validated.orgId,
+    });
+    if (!artists.some(artist => artist.account_id === validated.artist_id)) {
</file context>
Suggested change
orgId: validated.orgId,
orgId: validated.orgId ?? undefined,

});

// A provider id is no longer part of the contract; only a Recoup uuid is valid.
it.each(["1590132", "Loreen", "", "not-a-uuid", "123694f2-1dab-40b4-8a75"])(

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: Custom agent: Flag AI Slop and Fabricated Changes

The PR description and the referenced docs contract (recoupable/docs#297) describe this endpoint as keyed by a numeric bandsintown_id validated against /^\d+$/, with validation tests covering strings like "Loreen", "1590132abc", and "a1590132". The implemented code and tests tell a different story: artist_id is validated as a Recoup UUID (z.string().uuid()), and the numeric id "1590132" — which the PR description implies is valid — is actually rejected with a 400, while the PR-described test strings never appear. This mismatch means integrators following the documented contract will send numeric Bandsintown ids and get rejected, and reviewers can't trust the stated contract. Recommend updating the PR description and the docs contract (docs#297) to reflect the actual UUID artist_id schema, or aligning the implementation with the documented numeric contract.

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

<comment>The PR description and the referenced docs contract (recoupable/docs#297) describe this endpoint as keyed by a numeric `bandsintown_id` validated against `/^\d+$/`, with validation tests covering strings like "Loreen", "1590132abc", and "a1590132". The implemented code and tests tell a different story: `artist_id` is validated as a Recoup UUID (`z.string().uuid()`), and the numeric id `"1590132"` — which the PR description implies is valid — is actually rejected with a 400, while the PR-described test strings never appear. This mismatch means integrators following the documented contract will send numeric Bandsintown ids and get rejected, and reviewers can't trust the stated contract. Recommend updating the PR description and the docs contract (docs#297) to reflect the actual UUID `artist_id` schema, or aligning the implementation with the documented numeric contract.</comment>

<file context>
@@ -27,54 +29,73 @@ function req(body: unknown) {
-  it.each(["Loreen", "micky-dolenz", "", "1590132abc", "a1590132"])(
-    "rejects non-numeric bandsintown_id %j with a 400",
+  // A provider id is no longer part of the contract; only a Recoup uuid is valid.
+  it.each(["1590132", "Loreen", "", "not-a-uuid", "123694f2-1dab-40b4-8a75"])(
+    "rejects a non-uuid artist_id %j with a 400",
     async value => {
</file context>

* @returns The numeric Bandsintown artist id, or null when none is connected
*/
export async function getArtistBandsintownId(artistId: string): Promise<string | null> {
const socials = await selectAccountSocials({ accountId: artistId });

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: Artists with a Bandsintown link after the first 100 social rows receive the no-profile 404. Paginate this lookup or query the Bandsintown social directly rather than relying on the helper's default cap.

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

<comment>Artists with a Bandsintown link after the first 100 social rows receive the no-profile 404. Paginate this lookup or query the Bandsintown social directly rather than relying on the helper's default cap.</comment>

<file context>
@@ -0,0 +1,32 @@
+ * @returns The numeric Bandsintown artist id, or null when none is connected
+ */
+export async function getArtistBandsintownId(artistId: string): Promise<string | null> {
+  const socials = await selectAccountSocials({ accountId: artistId });
+
+  for (const row of socials) {
</file context>

* trigger, and tolerant of a missing `www.` and of anything trailing the
* slug (query strings such as `?came_from=` are common).
*/
const BANDSINTOWN_ARTIST_URL = /bandsintown\.com\/a\/(\d+)-/i;

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: An unrelated URL containing bandsintown.com/a/{id}- is treated as a connected Bandsintown profile, so reports can fetch another artist's events. Anchor the expression to the Bandsintown host (with optional scheme/www) before extracting the ID.

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

<comment>An unrelated URL containing `bandsintown.com/a/{id}-` is treated as a connected Bandsintown profile, so reports can fetch another artist's events. Anchor the expression to the Bandsintown host (with optional scheme/www) before extracting the ID.</comment>

<file context>
@@ -0,0 +1,32 @@
+ * trigger, and tolerant of a missing `www.` and of anything trailing the
+ * slug (query strings such as `?came_from=` are common).
+ */
+const BANDSINTOWN_ARTIST_URL = /bandsintown\.com\/a\/(\d+)-/i;
+
+/**
</file context>
Suggested change
const BANDSINTOWN_ARTIST_URL = /bandsintown\.com\/a\/(\d+)-/i;
const BANDSINTOWN_ARTIST_URL = /^(?:https?:\/\/)?(?:www\.)?bandsintown\.com\/a\/(\d+)-/i;

Found by testing the preview deployment rather than the unit suite. Omitting
artist_id returned Zod's default "Invalid input: expected string, received
undefined", which never tells the caller which field is missing. Supplying a
non-uuid returned the helpful message, so the gap only showed on the
omitted-field path.

Adds the type-level error message and a test asserting the response names the
field.

Note the same gap exists on validatePostResearchWebRequest (`query`), which is
untouched here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@sweetmantech

Copy link
Copy Markdown
Contributor Author

Preview verification, authenticated paths

Earlier I could only exercise the unauthenticated paths. This run covers every documented status code against the live preview with real data, and it found one defect, now fixed.

Preview: https://api-be9i05ekv-recoup.vercel.app, built from 1c9aea21 (confirmed against the PR head, not a stale build).

🐛 Found and fixed: a missing artist_id did not name the field

Omitting artist_id returned Zod's default:

{"status":"error","error":"Invalid input: expected string, received undefined"}

A caller has no idea which field is missing. The non-uuid path was fine ("artist_id must be a valid UUID"), so the gap only appeared on the omitted-field case, which is why the unit suite missed it: the existing test asserted status 400 and never looked at the message.

Fixed in 1c9aea21 with the type-level message plus a test asserting the response names the field. Now:

{"status":"error","error":"artist_id is required and must be a valid UUID"}

The same gap exists on validatePostResearchWebRequest (query), left untouched here.

Full matrix, all against the preview

# Case Expected Actual
1 Artist with profile + upcoming shows 200 + events 200, 2 real events (Coachman Park 2026-09-27, Pershing Square 2026-10-10), 3.5s
2 Artist with profile, nothing scheduled 200 + [] 200 {"events":[]}
3 Artist in roster, no profile connected 404 + instructions 404, full connection message with the profileUrls deep link
4 Valid uuid not in caller's roster 404 404 "Artist not found"
5 Non-uuid artist_id ("1590132") 400 400 "artist_id must be a valid UUID"
6 Missing artist_id 400 naming the field 400 "artist_id is required and must be a valid UUID" ✅ (was the bug)
7 Legacy {"bandsintown_id":"11732"} body 400 400, provider id is no longer accepted
8 date: "tomorrow" 400 400 expected one of "upcoming"|"past"|"all"
9 No auth header 401 401
10 date: "past" 200 + past events 200, 2013 events returned, ticket_url: null handled

Cases 1, 2 and 3 are the ones that matter most. They prove the three-way distinction holds end to end: has-shows, connected-but-not-touring, and no-source-at-all are three different answers rather than being collapsed into "no shows".

Case 4 confirms the roster scoping works. A valid uuid belonging to no artist in the caller's roster 404s without touching the socials table.

Latency on the 200 path was 2.4s to 5.1s across runs, consistent with the p95 5.7s measured earlier and comfortably inside maxDuration = 60.

How the authenticated calls were made

Preview auth uses a different PRIVY_PROJECT_SECRET than prod while sharing the prod database, which I verified directly by comparing the two values (they differ). A prod key therefore 401s on a preview. I minted a temporary preview-scoped key scoped to my own account, with a 2 hour TTL, and deleted the row immediately after the run. No customer account was involved and no key value appears in this comment or anywhere else.

Test fixtures are two artists on my own roster with their real Bandsintown profiles attached through PATCH /api/artists/{id}: Elvis Crespo (11732, touring) and Ana Bárbara (77465, valid profile, nothing scheduled). Both resolved by exact slug match, not guessed. Those rows are correct data and are staying, as the first slice of the backfill this endpoint needs.

Test suite

606 passing across lib/research, lib/apify, lib/artists (605 before, +1 for the new message assertion). Lint clean.

@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: 2

🤖 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/research/getArtistBandsintownId.ts`:
- Line 10: Update the BANDSINTOWN_ARTIST_URL pattern to require an optional URL
scheme followed by the exact bandsintown.com hostname before the /a/ path,
preventing matches when that text appears in another hostname.

In `@lib/research/validatePostResearchEventsRequest.ts`:
- Around line 17-22: Update ValidatedPostResearchEventsRequest to derive
artist_id and date from z.infer<typeof bodySchema> instead of duplicating those
fields. Export the inferred bodySchema type, then compose it with accountId and
orgId while preserving their existing types.
🪄 Autofix

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 Plus

Run ID: f1d1d49f-cf10-480f-ab30-44bd1305ef0f

📥 Commits

Reviewing files that changed from the base of the PR and between 6f72229 and 1c9aea2.

⛔ Files ignored due to path filters (3)
  • lib/research/__tests__/getArtistBandsintownId.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/research/__tests__/postResearchEventsHandler.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/research/__tests__/validatePostResearchEventsRequest.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
📒 Files selected for processing (4)
  • app/api/research/events/route.ts
  • lib/research/getArtistBandsintownId.ts
  • lib/research/postResearchEventsHandler.ts
  • lib/research/validatePostResearchEventsRequest.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • app/api/research/events/route.ts
  • lib/research/postResearchEventsHandler.ts

* trigger, and tolerant of a missing `www.` and of anything trailing the
* slug (query strings such as `?came_from=` are common).
*/
const BANDSINTOWN_ARTIST_URL = /bandsintown\.com\/a\/(\d+)-/i;

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

Restrict the match to the Bandsintown hostname.

BANDSINTOWN_ARTIST_URL matches bandsintown.com anywhere in profile_url. For example, https://notbandsintown.com/a/123-artist matches and resolves a provider ID from a non-Bandsintown profile.

Anchor the optional scheme and hostname before /a/.

Proposed fix
-const BANDSINTOWN_ARTIST_URL = /bandsintown\.com\/a\/(\d+)-/i;
+const BANDSINTOWN_ARTIST_URL =
+  /^(?:https?:\/\/)?(?:www\.)?bandsintown\.com\/a\/(\d+)-/i;
📝 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
const BANDSINTOWN_ARTIST_URL = /bandsintown\.com\/a\/(\d+)-/i;
const BANDSINTOWN_ARTIST_URL =
/^(?:https?:\/\/)?(?:www\.)?bandsintown\.com\/a\/(\d+)-/i;
🤖 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/research/getArtistBandsintownId.ts` at line 10, Update the
BANDSINTOWN_ARTIST_URL pattern to require an optional URL scheme followed by the
exact bandsintown.com hostname before the /a/ path, preventing matches when that
text appears in another hostname.

Comment on lines +17 to +22
export type ValidatedPostResearchEventsRequest = {
accountId: string;
orgId: string | null;
artist_id: string;
date?: "upcoming" | "past" | "all";
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -eu

target="lib/research/validatePostResearchEventsRequest.ts"

printf '%s\n' '--- target outline ---'
if command -v ast-grep >/dev/null 2>&1; then
  ast-grep outline "$target" || true
fi

printf '%s\n' '--- target source ---'
cat -n "$target"

printf '%s\n' '--- related symbols ---'
rg -n --glob '*.ts' --glob '*.tsx' \
  'bodySchema|ValidatedPostResearchEventsRequest|validatePostResearchEventsRequest|PostResearchEventsBody' .

Repository: recoupable/api

Length of output: 7254


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- handler ---'
cat -n lib/research/postResearchEventsHandler.ts

printf '%s\n' '--- validator test ---'
cat -n lib/research/__tests__/validatePostResearchEventsRequest.test.ts

printf '%s\n' '--- handler test type-shape usages ---'
sed -n '1,180p' lib/research/__tests__/postResearchEventsHandler.test.ts

printf '%s\n' '--- inferred Zod type patterns ---'
rg -n --glob 'lib/**/*.ts' \
  'export (type|interface) .*z\\.infer|z\\.infer<typeof' lib

Repository: recoupable/api

Length of output: 15576


🏁 Script executed:

#!/bin/bash
set -eu

python3 - <<'PY'
from pathlib import Path
import re

source = Path("lib/research/validatePostResearchEventsRequest.ts").read_text()
handler = Path("lib/research/postResearchEventsHandler.ts").read_text()

schema = re.search(r"const bodySchema = z\.object\(\{(.*?)\n\}\);", source, re.S)
manual = re.search(
    r"export type ValidatedPostResearchEventsRequest = \{(.*?)\n\};",
    source,
    re.S,
)
assert schema and manual

schema_fields = set(re.findall(r"^\s{2}([A-Za-z_][A-Za-z0-9_]*)\s*:", schema.group(1), re.M))
manual_fields = set(re.findall(r"^\s{2}([A-Za-z_][A-Za-z0-9_]*)\s*:", manual.group(1), re.M))
returned_auth_fields = {"accountId", "orgId"}
consumer_fields = set(
    re.findall(r"\bvalidated\.([A-Za-z_][A-Za-z0-9_]*)", handler)
)

print("schema body fields:", sorted(schema_fields))
print("manual body fields:", sorted(manual_fields & schema_fields))
print("missing manual body fields:", sorted(schema_fields - manual_fields))
print("extra manual body fields:", sorted(manual_fields - schema_fields))
print("handler validated fields:", sorted(consumer_fields))
print("proposed composed fields:", sorted(returned_auth_fields | schema_fields))

assert schema_fields == {"artist_id", "date"}
assert manual_fields & schema_fields == schema_fields
assert returned_auth_fields | schema_fields >= consumer_fields
PY

Repository: recoupable/api

Length of output: 543


🏁 Script executed:

#!/bin/bash
set -eu

python3 - <<'PY'
from pathlib import Path
import re

source = Path("lib/research/validatePostResearchEventsRequest.ts").read_text()
handler = Path("lib/research/postResearchEventsHandler.ts").read_text()

schema = re.search(r"const bodySchema = z\.object\(\{(.*?)\n\}\);", source, re.S)
manual = re.search(
    r"export type ValidatedPostResearchEventsRequest = \{(.*?)\n\};",
    source,
    re.S,
)
assert schema and manual

field_pattern = r"^\s{2}([A-Za-z_][A-Za-z0-9_]*)(\?)?\s*:"
schema_fields = set(re.findall(field_pattern, schema.group(1), re.M))
manual_matches = re.findall(field_pattern, manual.group(1), re.M)
manual_fields = {name for name, optional in manual_matches}
manual_body_fields = manual_fields & {"artist_id", "date"}
auth_fields = {"accountId", "orgId"}
consumer_fields = set(re.findall(r"\bvalidated\.([A-Za-z_][A-Za-z0-9_]*)", handler))
proposed_fields = auth_fields | {"artist_id", "date"}

print("schema body fields:", sorted(schema_fields))
print("manual body fields:", sorted(manual_body_fields))
print("missing manual body fields:", sorted(schema_fields - manual_body_fields))
print("extra manual body fields:", sorted(manual_body_fields - schema_fields))
print("handler validated fields:", sorted(consumer_fields))
print("proposed composed fields:", sorted(proposed_fields))

assert schema_fields == {"artist_id", "date"}
assert manual_body_fields == schema_fields
assert consumer_fields <= proposed_fields
PY

Repository: recoupable/api

Length of output: 587


🏁 Script executed:

#!/bin/bash
set -eu

python3 - <<'PY'
from pathlib import Path
import re

source = Path("lib/research/validatePostResearchEventsRequest.ts").read_text()
handler = Path("lib/research/postResearchEventsHandler.ts").read_text()

schema = re.search(r"const bodySchema = z\.object\(\{(.*?)\n\}\);", source, re.S)
manual = re.search(
    r"export type ValidatedPostResearchEventsRequest = \{(.*?)\n\};",
    source,
    re.S,
)
assert schema and manual

field_pattern = r"^\s{2}([A-Za-z_][A-Za-z0-9_]*)(?:\?)?\s*:"
schema_fields = {match.group(1) for match in re.finditer(field_pattern, schema.group(1), re.M)}
manual_fields = {match.group(1) for match in re.finditer(field_pattern, manual.group(1), re.M)}
consumer_fields = set(re.findall(r"\bvalidated\.([A-Za-z_][A-Za-z0-9_]*)", handler))
proposed_fields = {"accountId", "orgId"} | schema_fields

print("schema body fields:", sorted(schema_fields))
print("manual body fields:", sorted(manual_fields & schema_fields))
print("missing manual body fields:", sorted(schema_fields - manual_fields))
print("handler validated fields:", sorted(consumer_fields))
print("proposed composed fields:", sorted(proposed_fields))

assert schema_fields == {"artist_id", "date"}
assert (manual_fields & schema_fields) == schema_fields
assert consumer_fields <= proposed_fields
PY

Repository: recoupable/api

Length of output: 407


Derive the request type from bodySchema.

ValidatedPostResearchEventsRequest duplicates artist_id and date from bodySchema. Export a z.infer<typeof bodySchema> type and compose it with accountId and orgId to prevent type drift.

🤖 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/research/validatePostResearchEventsRequest.ts` around lines 17 - 22,
Update ValidatedPostResearchEventsRequest to derive artist_id and date from
z.infer<typeof bodySchema> instead of duplicating those fields. Export the
inferred bodySchema type, then compose it with accountId and orgId while
preserving their existing types.

Source: Path instructions

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

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

Confidence score: 5/5

  • Automated review surfaced no issues in the provided summaries.
  • No files require special attention.

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

Re-trigger cubic

@sweetmantech

Copy link
Copy Markdown
Contributor Author

Addendum: full 200 body re-confirmed on the final build

The complete payload quoted above came from the pre-fix build. Re-ran the 200 path against 1c9aea21, the current head, to confirm the fix did not disturb it:

HTTP 200   time=8.06s   bytes=612
{
  "status": "success",
  "events": [
    {
      "date": "2026-09-27",
      "venue": "Coachman Park",
      "city": "Clearwater",
      "region": "FL",
      "country": "United States",
      "ticket_url": "https://www.bandsintown.com/t/1039854286?app_id=12345&came_from=267&utm_medium=api&utm_source=public_api&utm_campaign=ticket",
      "sold_out": false,
      "lineup": ["Elvis Crespo"]
    },
    {
      "date": "2026-10-10",
      "venue": "Pershing Square",
      "city": "Los Angeles",
      "region": "CA",
      "country": "United States",
      "ticket_url": "https://www.bandsintown.com/t/1039760997?app_id=12345&came_from=267&utm_medium=api&utm_source=public_api&utm_campaign=ticket",
      "sold_out": false,
      "lineup": ["Elvis Crespo"]
    }
  ]
}

Null audit on the response: events is an array of 2, each with all 8 documented fields, no nulls and no empty strings. Every field in the documented schema is populated with real data, including region (FL, CA), which is the field whose example caused the London/"TN" defect on the docs PR.

Two notes:

  • 8.06s on this run, against 2.4s to 5.1s earlier. Still far inside maxDuration = 60, but it is the slowest observed and worth knowing the spread is roughly 2s to 8s rather than a tight band.
  • A Privy JWT authenticates against the preview. I had concluded a preview-scoped API key was required, because a prod recoup_sk_ key 401s there. A bearer JWT works, so future preview verification needs no temporary key row in the database at all. The temporary key used for the earlier matrix was deleted immediately after that run.

@sweetmantech
sweetmantech merged commit 490c7cc into main Aug 10, 2026
6 checks passed
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