feat(research): POST /api/research/events — live shows by Recoup artist_id - #826
Conversation
…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>
|
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. |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
📝 WalkthroughWalkthroughThe PR adds ChangesResearch events endpoint
Estimated code review effort: 3 (Moderate) | ~20 minutes Possibly related issues
Possibly related PRs
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
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
Preview verification — https://api-l0e17cwkw-recoup.vercel.appDeployment
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 whyThe 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.
Those three paths are covered at the unit level (24 tests):
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. |
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (3)
lib/research/validatePostResearchEventsRequest.ts (1)
14-18: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winDerive the validated type from the schema.
ValidatedPostResearchEventsRequestrestates the schema shape by hand. The union"upcoming" | "past" | "all"now exists in three places: this type, the Zod enum on Line 11, andBandsintownDateFilterinlib/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 winLog the swallowed credit failure.
The decision not to block the response is correct. The silence is not. When
recordCreditDeductionfails, 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 winBound the actor run to the route budget.
apifyClient.actor().call()waits indefinitely for the actor run by default, but the route hasmaxDuration = 60. Set a module-leveltimeoutandwaitSecsbelow 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
⛔ Files ignored due to path filters (3)
lib/apify/bandsintown/__tests__/fetchBandsintownEvents.test.tsis excluded by!**/*.test.*,!**/__tests__/**and included bylib/**lib/research/__tests__/postResearchEventsHandler.test.tsis excluded by!**/*.test.*,!**/__tests__/**and included bylib/**lib/research/__tests__/validatePostResearchEventsRequest.test.tsis excluded by!**/*.test.*,!**/__tests__/**and included bylib/**
📒 Files selected for processing (5)
app/api/research/events/route.tslib/apify/bandsintown/fetchBandsintownEvents.tslib/research/ensureEventsResearchCredits.tslib/research/postResearchEventsHandler.tslib/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; |
There was a problem hiding this comment.
🗄️ 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 exampleexport const EVENTS_RESEARCH_CREDIT_COST = 1;, keeping the pricing rationale comment as its documentation.lib/research/postResearchEventsHandler.ts#L30-L34: importEVENTS_RESEARCH_CREDIT_COSTand pass it ascreditsToDeductin place of the literal1.
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
| } catch (error) { | ||
| return errorResponse( | ||
| error instanceof Error ? error.message : "Artist events lookup failed", | ||
| 500, | ||
| ); | ||
| } |
There was a problem hiding this comment.
🔒 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.
| } 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.
| 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 }; | ||
| } |
There was a problem hiding this comment.
📐 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.
| 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
There was a problem hiding this comment.
7 issues found across 8 files
Confidence score: 3/5
app/api/research/events/route.tsforwards 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.tscurrently returnserrorResponse(error.message, 500), which can expose raw upstream/internal exception text to clients; the related 500-path test inlib/research/__tests__/postResearchEventsHandler.test.tswouldn’t catch that leak — return a sanitized generic 500 payload and assert the error envelope in tests.lib/apify/bandsintown/fetchBandsintownEvents.tscan emit malformed actor timestamps and can drop events with usablestarts_atdata whendatetimeis empty, risking missing or incorrect event dates in responses — apply non-empty fallback logic, enforce normalized ISO output, and add the missing branch test inlib/apify/bandsintown/__tests__/fetchBandsintownEvents.test.ts.- Credit accounting logic is split between
lib/research/ensureEventsResearchCredits.tsand a hardcoded1inlib/research/postResearchEventsHandler.ts, which can drift and cause inconsistent charging if costs change; the same handler also has a deadcatch {}aroundrecordCreditDeduction— 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
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"); |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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"])( |
There was a problem hiding this comment.
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"; | |||
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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>
There was a problem hiding this comment.
4 issues found across 7 files (changes from recent commits).
Confidence score: 2/5
- In
lib/research/postResearchEventsHandler.ts, passing the validator’s always-nullorgIdmakes organization-owned artists look like personal-only lookups, which can force valid org artists into 404 responses for this endpoint — treat missing org context asundefinedbefore callinggetArtistsso org rosters are included. - In
lib/research/getArtistBandsintownId.ts, the current pattern can accept non-Bandsintown URLs that merely containbandsintown.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 numericbandsintown_idvalidation 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, |
There was a problem hiding this comment.
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>
| 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"])( |
There was a problem hiding this comment.
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 }); |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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>
| 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>
Preview verification, authenticated pathsEarlier 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: 🐛 Found and fixed: a missing
|
| # | 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.
There was a problem hiding this comment.
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
⛔ Files ignored due to path filters (3)
lib/research/__tests__/getArtistBandsintownId.test.tsis excluded by!**/*.test.*,!**/__tests__/**and included bylib/**lib/research/__tests__/postResearchEventsHandler.test.tsis excluded by!**/*.test.*,!**/__tests__/**and included bylib/**lib/research/__tests__/validatePostResearchEventsRequest.test.tsis excluded by!**/*.test.*,!**/__tests__/**and included bylib/**
📒 Files selected for processing (4)
app/api/research/events/route.tslib/research/getArtistBandsintownId.tslib/research/postResearchEventsHandler.tslib/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; |
There was a problem hiding this comment.
🎯 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.
| 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.
| export type ValidatedPostResearchEventsRequest = { | ||
| accountId: string; | ||
| orgId: string | null; | ||
| artist_id: string; | ||
| date?: "upcoming" | "past" | "all"; | ||
| }; |
There was a problem hiding this comment.
📐 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' libRepository: 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
PYRepository: 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
PYRepository: 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
PYRepository: 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
There was a problem hiding this comment.
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
Addendum: full 200 body re-confirmed on the final buildThe complete payload quoted above came from the pre-fix build. Re-ran the 200 path against {
"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: Two notes:
|
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.
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
{"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:
Verified that anchor resolves: the artist PATCH body param is
profileUrls, and Mintlify kebab-cases it tobody-profile-urls, which is present on the live page.Authorization — new, and worth a look
Switching to
artist_idintroduced 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 viagetArtists({ 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
app/api/research/events/route.tsmaxDuration = 60lib/research/postResearchEventsHandler.tslib/research/validatePostResearchEventsRequest.tslib/research/getArtistBandsintownId.tslib/research/ensureEventsResearchCredits.tslib/apify/bandsintown/fetchBandsintownEvents.tsURL parsing is case-insensitive because
socials.profile_urlis lowercased by a DB trigger, and tolerates a missingwww.and trailing query strings.Sync
.call()follows the existing patternThe 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) againstmaxDuration = 60. Rationale recorded in chat#1954.Verification
vitest run lib/research lib/apify lib/artistseslinton touched filestsc --noEmitHonest notes:
bandsintown_id-only body is rejected.tsc --noEmitreports 201 pre-existing errors on pristineorigin/main(all inlib/tasks/andlib/trigger/tests). This branch adds none, but the repo typecheck is red independent of this PR.PRIVY_PROJECT_SECRETwhile 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_idas 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 existingPATCH /api/artists/{id}profileUrlsfield accepts these URLs.🤖 Generated with Claude Code
Summary by CodeRabbit