From a887b80ee710ef639e2f3bc0e44687fb9adab19d Mon Sep 17 00:00:00 2001 From: Recoup Agent Date: Tue, 24 Mar 2026 02:23:13 +0000 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20add=20POST=20/api/launch=20?= =?UTF-8?q?=E2=80=94=20Release=20Autopilot=20streaming=20endpoint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Streams a complete AI-generated music release campaign given artist and song details. Returns 6 sections in real-time (press release, Spotify pitch, Instagram captions, TikTok hooks, fan newsletter, curator email) using XML-style section markers parsed by the frontend. - lib/launch/validateLaunchBody.ts — Zod validation for launch body - lib/launch/buildCampaignPrompt.ts — system + user prompt builders - lib/launch/generateCampaignHandler.ts — streamText handler with auth - lib/launch/__tests__/validateLaunchBody.test.ts — 8 passing tests - app/api/launch/route.ts — POST /api/launch route Co-Authored-By: Claude Sonnet 4.6 --- app/api/launch/route.ts | 51 ++++++++++ .../__tests__/validateLaunchBody.test.ts | 98 +++++++++++++++++++ lib/launch/buildCampaignPrompt.ts | 61 ++++++++++++ lib/launch/generateCampaignHandler.ts | 35 +++++++ lib/launch/validateLaunchBody.ts | 44 +++++++++ 5 files changed, 289 insertions(+) create mode 100644 app/api/launch/route.ts create mode 100644 lib/launch/__tests__/validateLaunchBody.test.ts create mode 100644 lib/launch/buildCampaignPrompt.ts create mode 100644 lib/launch/generateCampaignHandler.ts create mode 100644 lib/launch/validateLaunchBody.ts diff --git a/app/api/launch/route.ts b/app/api/launch/route.ts new file mode 100644 index 00000000..5c6ec554 --- /dev/null +++ b/app/api/launch/route.ts @@ -0,0 +1,51 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { generateCampaignHandler } from "@/lib/launch/generateCampaignHandler"; + +/** + * OPTIONS handler for CORS preflight requests. + * + * @returns Empty 200 response with CORS headers. + */ +export async function OPTIONS() { + return new NextResponse(null, { + status: 200, + headers: getCorsHeaders(), + }); +} + +/** + * POST /api/launch + * + * Streams an AI-generated music release campaign given artist and song details. + * Returns a text/plain stream with XML-style section markers that the client + * parses to render each campaign section in real-time. + * + * Authentication: x-api-key header OR Authorization: Bearer token required. + * + * Request body: + * - artist_name: string (required) — the artist's name + * - song_name: string (required) — the song or album name + * - genre: string (required) — musical genre + * - release_date: string (required) — release date (any format) + * - description: string (optional) — additional context for the AI + * + * Response: streaming text with section markers: + * [SECTION:press_release]...[/SECTION:press_release] + * [SECTION:spotify_pitch]...[/SECTION:spotify_pitch] + * [SECTION:instagram_captions]...[/SECTION:instagram_captions] + * [SECTION:tiktok_hooks]...[/SECTION:tiktok_hooks] + * [SECTION:fan_newsletter]...[/SECTION:fan_newsletter] + * [SECTION:curator_email]...[/SECTION:curator_email] + * + * @param request - The incoming request + * @returns Streaming text response or error + */ +export async function POST(request: NextRequest): Promise { + return generateCampaignHandler(request); +} + +export const dynamic = "force-dynamic"; +export const fetchCache = "force-no-store"; +export const revalidate = 0; diff --git a/lib/launch/__tests__/validateLaunchBody.test.ts b/lib/launch/__tests__/validateLaunchBody.test.ts new file mode 100644 index 00000000..378ea301 --- /dev/null +++ b/lib/launch/__tests__/validateLaunchBody.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect } from "vitest"; +import { NextResponse } from "next/server"; +import { validateLaunchBody } from "../validateLaunchBody"; + +/** + * Remove a key from an object — test utility. + * + * @param obj - Source object + * @param key - Key to remove + * @returns Object without the specified key + */ +function omit(obj: T, key: K): Omit { + const copy = { ...obj }; + delete copy[key]; + return copy; +} + +describe("validateLaunchBody", () => { + const validBody = { + artist_name: "Gliiico", + song_name: "Midnight Drive", + genre: "Indie Pop", + release_date: "2026-04-01", + description: "A song about late night drives and nostalgia", + }; + + describe("successful cases", () => { + it("returns parsed body when all required fields are present", () => { + const result = validateLaunchBody(validBody); + expect(result).not.toBeInstanceOf(NextResponse); + if (!(result instanceof NextResponse)) { + expect(result.artist_name).toBe("Gliiico"); + expect(result.song_name).toBe("Midnight Drive"); + expect(result.genre).toBe("Indie Pop"); + expect(result.release_date).toBe("2026-04-01"); + expect(result.description).toBe("A song about late night drives and nostalgia"); + } + }); + + it("accepts body without optional description", () => { + const result = validateLaunchBody(omit(validBody, "description")); + expect(result).not.toBeInstanceOf(NextResponse); + if (!(result instanceof NextResponse)) { + expect(result.description).toBeUndefined(); + } + }); + }); + + describe("error cases", () => { + it("returns 400 when artist_name is missing", () => { + const result = validateLaunchBody(omit(validBody, "artist_name")); + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) { + expect(result.status).toBe(400); + } + }); + + it("returns 400 when song_name is missing", () => { + const result = validateLaunchBody(omit(validBody, "song_name")); + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) { + expect(result.status).toBe(400); + } + }); + + it("returns 400 when genre is missing", () => { + const result = validateLaunchBody(omit(validBody, "genre")); + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) { + expect(result.status).toBe(400); + } + }); + + it("returns 400 when release_date is missing", () => { + const result = validateLaunchBody(omit(validBody, "release_date")); + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) { + expect(result.status).toBe(400); + } + }); + + it("returns 400 when artist_name is empty string", () => { + const result = validateLaunchBody({ ...validBody, artist_name: "" }); + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) { + expect(result.status).toBe(400); + } + }); + + it("returns 400 when body is null", () => { + const result = validateLaunchBody(null); + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) { + expect(result.status).toBe(400); + } + }); + }); +}); diff --git a/lib/launch/buildCampaignPrompt.ts b/lib/launch/buildCampaignPrompt.ts new file mode 100644 index 00000000..f14e35ce --- /dev/null +++ b/lib/launch/buildCampaignPrompt.ts @@ -0,0 +1,61 @@ +import type { LaunchBody } from "./validateLaunchBody"; + +/** + * Builds the system prompt for the release campaign generator. + * + * @returns The system prompt string + */ +export function buildCampaignSystemPrompt(): string { + return `You are an expert music industry publicist and marketing strategist with 15+ years of experience +launching indie and major label artists. You write compelling, professional, and authentic music PR content +that sounds human — never generic. Your press releases get picked up by music blogs. Your Spotify pitches +get playlisted. Your emails get replies. + +Always write as if this artist and song are genuinely exciting. Use vivid, specific language. +Avoid clichés like "sonic journey" or "genre-defying." +Output each section EXACTLY as instructed with the section markers provided — no extra text outside the markers.`; +} + +/** + * Builds the user prompt for the release campaign generator. + * + * @param body - Validated launch request body + * @returns The formatted user prompt + */ +export function buildCampaignUserPrompt(body: LaunchBody): string { + const { artist_name, song_name, genre, release_date, description } = body; + + const context = description ? `\nAdditional context: ${description}` : ""; + + return `Generate a complete music release campaign for: +Artist: ${artist_name} +Song/Release: ${song_name} +Genre: ${genre} +Release Date: ${release_date}${context} + +Generate each section IN ORDER using EXACTLY these markers (do not skip or reorder): + +[SECTION:press_release] +Write a professional 2-3 paragraph press release announcing the release. Include a punchy headline, vivid description of the song's sound, the artist's backstory in 1 sentence, and a quote from the artist. End with release date and where to stream. +[/SECTION:press_release] + +[SECTION:spotify_pitch] +Write a compelling Spotify editorial pitch (200-250 words). Describe the song's sonic DNA, emotional core, lyrical themes, production style, and which playlists it fits. Include 3 specific Spotify playlist names this would fit. +[/SECTION:spotify_pitch] + +[SECTION:instagram_captions] +Write 5 different Instagram captions for the release announcement post. Vary the tone: one hype, one personal/vulnerable, one cryptic/teaser, one funny, one simple & clean. Include 5-8 relevant hashtags for each. +[/SECTION:instagram_captions] + +[SECTION:tiktok_hooks] +Write 5 different TikTok video hook scripts. Each hook is the first 3 seconds of a video — punchy, scroll-stopping. Format as: "Hook [N]: [script]". Make them specific to the song's vibe. +[/SECTION:tiktok_hooks] + +[SECTION:fan_newsletter] +Write an email newsletter to fans announcing the release. Start with a compelling subject line on its own line (format: "Subject: [subject]"), then the email body. Make it personal, like the artist is writing directly to fans. +[/SECTION:fan_newsletter] + +[SECTION:curator_email] +Write a cold email to a playlist curator pitching the song for placement. Keep it under 150 words. Subject line first (format: "Subject: [subject]"), then body. Be specific, not salesy. Include the Spotify link placeholder [SPOTIFY_LINK]. +[/SECTION:curator_email]`; +} diff --git a/lib/launch/generateCampaignHandler.ts b/lib/launch/generateCampaignHandler.ts new file mode 100644 index 00000000..eef2f922 --- /dev/null +++ b/lib/launch/generateCampaignHandler.ts @@ -0,0 +1,35 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { streamText } from "ai"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateLaunchBody } from "./validateLaunchBody"; +import { buildCampaignSystemPrompt, buildCampaignUserPrompt } from "./buildCampaignPrompt"; +import { DEFAULT_MODEL } from "@/lib/const"; + +/** + * Handles POST /api/launch — streams an AI-generated release campaign. + * + * @param request - The incoming request + * @returns A streaming text response containing the campaign sections + */ +export async function generateCampaignHandler(request: NextRequest): Promise { + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) { + return authResult; + } + + const json = await request.json(); + const validated = validateLaunchBody(json); + if (validated instanceof NextResponse) { + return validated; + } + + const result = streamText({ + model: DEFAULT_MODEL, + system: buildCampaignSystemPrompt(), + prompt: buildCampaignUserPrompt(validated), + }); + + return result.toTextStreamResponse({ headers: getCorsHeaders() }); +} diff --git a/lib/launch/validateLaunchBody.ts b/lib/launch/validateLaunchBody.ts new file mode 100644 index 00000000..ea67ab8a --- /dev/null +++ b/lib/launch/validateLaunchBody.ts @@ -0,0 +1,44 @@ +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { z } from "zod"; + +export const launchBodySchema = z.object({ + artist_name: z + .string({ message: "artist_name is required" }) + .min(1, "artist_name cannot be empty"), + song_name: z.string({ message: "song_name is required" }).min(1, "song_name cannot be empty"), + genre: z.string({ message: "genre is required" }).min(1, "genre cannot be empty"), + release_date: z + .string({ message: "release_date is required" }) + .min(1, "release_date cannot be empty"), + description: z.string().optional(), +}); + +export type LaunchBody = z.infer; + +/** + * Validates request body for POST /api/launch. + * + * @param body - The request body + * @returns A NextResponse with an error if validation fails, or the validated body if validation passes. + */ +export function validateLaunchBody(body: unknown): NextResponse | LaunchBody { + const result = launchBodySchema.safeParse(body); + + if (!result.success) { + const firstError = result.error.issues[0]; + return NextResponse.json( + { + status: "error", + missing_fields: firstError.path, + error: firstError.message, + }, + { + status: 400, + headers: getCorsHeaders(), + }, + ); + } + + return result.data; +} From b1b3c1ee81d2bdef31b80589edd67fa178daf29d Mon Sep 17 00:00:00 2001 From: Recoup Agent Date: Tue, 24 Mar 2026 02:26:00 +0000 Subject: [PATCH 2/3] agent: @U0AJM7X8FBR build a feature that would go viral if demoed on TikTok. Th --- app/api/accounts/[id]/route.ts | 1 + app/api/admins/coding/slack/route.ts | 2 ++ app/api/admins/privy/route.ts | 5 +++++ app/api/coding-agent/[platform]/route.ts | 2 ++ app/api/songs/analyze/presets/route.ts | 1 + app/api/transcribe/route.ts | 4 ++++ .../emails/__tests__/validateGetAdminEmailsQuery.test.ts | 4 ++++ lib/admins/pr/__tests__/getPrMergedStatusHandler.test.ts | 4 ++++ lib/admins/pr/getPrStatusHandler.ts | 2 ++ lib/admins/privy/countNewAccounts.ts | 3 +++ lib/admins/privy/fetchPrivyLogins.ts | 4 ++++ lib/admins/privy/getCutoffMs.ts | 2 ++ lib/admins/privy/getLatestVerifiedAt.ts | 2 ++ lib/admins/privy/toMs.ts | 2 ++ lib/admins/slack/__tests__/getSlackTagsHandler.test.ts | 4 ++++ .../slack/__tests__/validateGetSlackTagsQuery.test.ts | 4 ++++ lib/admins/slack/extractGithubPrUrls.ts | 4 ++++ lib/admins/slack/fetchThreadPullRequests.ts | 4 ++++ lib/ai/getModel.ts | 1 + lib/ai/isEmbedModel.ts | 2 ++ lib/artists/__tests__/createArtistPostHandler.test.ts | 5 +++++ lib/artists/__tests__/validateCreateArtistBody.test.ts | 5 +++++ lib/auth/__tests__/validateAuthContext.test.ts | 4 ++++ lib/catalog/formatCatalogSongsAsCSV.ts | 2 ++ lib/catalog/getCatalogDataAsCSV.ts | 2 ++ lib/catalog/getCatalogSongs.ts | 7 +++++++ lib/catalog/getCatalogs.ts | 4 ++++ lib/chat/__tests__/integration/chatEndToEnd.test.ts | 5 +++++ lib/chat/toolChains/getPrepareStepResult.ts | 2 ++ lib/chats/processCompactChatRequest.ts | 3 +++ lib/coding-agent/__tests__/handleGitHubWebhook.test.ts | 6 ++++++ .../__tests__/onMergeTestToMainAction.test.ts | 3 +++ lib/coding-agent/encodeGitHubThreadId.ts | 2 ++ lib/coding-agent/handleMergeSuccess.ts | 2 ++ lib/coding-agent/parseMergeActionId.ts | 2 ++ lib/coding-agent/parseMergeTestToMainActionId.ts | 2 ++ lib/composio/getCallbackUrl.ts | 1 + lib/content/__tests__/validateCreateContentBody.test.ts | 4 ++++ lib/content/contentTemplates.ts | 4 ++++ lib/content/createContentHandler.ts | 2 ++ lib/content/getArtistContentReadiness.ts | 5 +++++ lib/content/getArtistFileTree.ts | 3 +++ lib/content/getArtistRootPrefix.ts | 5 +++++ lib/content/getContentValidateHandler.ts | 2 ++ lib/content/isCompletedRun.ts | 4 ++++ lib/content/persistCreateContentRunVideo.ts | 2 ++ lib/content/validateCreateContentBody.ts | 2 ++ lib/content/validateGetContentEstimateQuery.ts | 2 ++ lib/content/validateGetContentValidateQuery.ts | 2 ++ lib/credits/getCreditUsage.ts | 1 + lib/credits/handleChatCredits.ts | 4 ++++ lib/emails/processAndSendEmail.ts | 2 ++ lib/evals/callChatFunctions.ts | 1 + lib/evals/callChatFunctionsWithResult.ts | 2 ++ lib/evals/createToolsCalledScorer.ts | 3 +++ lib/evals/extractTextFromResult.ts | 2 ++ lib/evals/extractTextResultFromSteps.ts | 2 ++ lib/evals/getCatalogSongsCountExpected.ts | 3 +++ lib/evals/getSpotifyFollowersExpected.ts | 4 ++++ lib/evals/scorers/CatalogAvailability.ts | 5 +++++ lib/evals/scorers/QuestionAnswered.ts | 5 +++++ lib/evals/scorers/ToolsCalled.ts | 8 ++++++++ lib/flamingo/__tests__/getFlamingoPresetsHandler.test.ts | 3 +++ lib/flamingo/getFlamingoPresetsHandler.ts | 1 + lib/github/expandSubmoduleEntries.ts | 6 ++++++ lib/github/getRepoGitModules.ts | 3 +++ lib/github/resolveSubmodulePath.ts | 2 ++ lib/mcp/resolveAccountId.ts | 2 ++ lib/mcp/tools/transcribe/registerTranscribeAudioTool.ts | 4 ++++ .../__tests__/createNotificationHandler.test.ts | 4 ++++ .../__tests__/validateCreateNotificationBody.test.ts | 5 +++++ lib/prompts/getSystemPrompt.ts | 1 + lib/slack/getBotChannels.ts | 2 ++ lib/slack/getBotUserId.ts | 2 ++ lib/slack/getSlackUserInfo.ts | 3 +++ lib/spotify/getSpotifyFollowers.ts | 1 + lib/supabase/account_artist_ids/getAccountArtistIds.ts | 4 +++- .../account_workspace_ids/getAccountWorkspaceIds.ts | 2 +- lib/supabase/files/createFileRecord.ts | 2 ++ lib/supabase/song_artists/insertSongArtists.ts | 2 ++ lib/supabase/storage/uploadFileByKey.ts | 6 ++++++ lib/tasks/__tests__/enrichTaskWithTriggerInfo.test.ts | 6 +++--- lib/tasks/__tests__/getTaskRunHandler.test.ts | 3 +++ lib/tasks/__tests__/validateGetTaskRunQuery.test.ts | 2 ++ lib/transcribe/processAudioTranscription.ts | 6 ++++++ lib/transcribe/saveAudioToFiles.ts | 4 ++++ lib/transcribe/saveTranscriptToFiles.ts | 4 ++++ lib/transcribe/types.ts | 2 ++ lib/trigger/triggerCreateContent.ts | 2 ++ 89 files changed, 272 insertions(+), 5 deletions(-) diff --git a/app/api/accounts/[id]/route.ts b/app/api/accounts/[id]/route.ts index 3ed40db1..42b388bf 100644 --- a/app/api/accounts/[id]/route.ts +++ b/app/api/accounts/[id]/route.ts @@ -23,6 +23,7 @@ export async function OPTIONS() { * - id (required): The unique identifier of the account (UUID) * * @param request - The request object + * @param params.params * @param params - Route params containing the account ID * @returns A NextResponse with account data */ diff --git a/app/api/admins/coding/slack/route.ts b/app/api/admins/coding/slack/route.ts index ea880d30..956d7b4e 100644 --- a/app/api/admins/coding/slack/route.ts +++ b/app/api/admins/coding/slack/route.ts @@ -9,6 +9,8 @@ import { getSlackTagsHandler } from "@/lib/admins/slack/getSlackTagsHandler"; * Pulls directly from the Slack API as the source of truth. * Supports period filtering: all (default), daily, weekly, monthly. * Requires admin authentication. + * + * @param request */ export async function GET(request: NextRequest): Promise { return getSlackTagsHandler(request); diff --git a/app/api/admins/privy/route.ts b/app/api/admins/privy/route.ts index 073bac60..d22ec616 100644 --- a/app/api/admins/privy/route.ts +++ b/app/api/admins/privy/route.ts @@ -8,11 +8,16 @@ import { getPrivyLoginsHandler } from "@/lib/admins/privy/getPrivyLoginsHandler" * Returns Privy login statistics for the requested time period. * Supports daily (last 24h), weekly (last 7 days), and monthly (last 30 days) periods. * Requires admin authentication. + * + * @param request */ export async function GET(request: NextRequest): Promise { return getPrivyLoginsHandler(request); } +/** + * + */ export async function OPTIONS(): Promise { return new NextResponse(null, { status: 204, headers: getCorsHeaders() }); } diff --git a/app/api/coding-agent/[platform]/route.ts b/app/api/coding-agent/[platform]/route.ts index a51a2104..6fa942ac 100644 --- a/app/api/coding-agent/[platform]/route.ts +++ b/app/api/coding-agent/[platform]/route.ts @@ -10,6 +10,7 @@ import "@/lib/coding-agent/handlers/registerHandlers"; * Handles webhook verification handshakes (e.g. WhatsApp hub.challenge). * * @param request - The incoming verification request + * @param params.params * @param params - Route params containing the platform name */ export async function GET( @@ -34,6 +35,7 @@ export async function GET( * Handles Slack and WhatsApp webhooks via dynamic [platform] segment. * * @param request - The incoming webhook request + * @param params.params * @param params - Route params containing the platform name */ export async function POST( diff --git a/app/api/songs/analyze/presets/route.ts b/app/api/songs/analyze/presets/route.ts index 8baccd38..b809394c 100644 --- a/app/api/songs/analyze/presets/route.ts +++ b/app/api/songs/analyze/presets/route.ts @@ -28,6 +28,7 @@ export async function OPTIONS() { * - status: "success" * - presets: Array of { name, label, description, requiresAudio, responseFormat } * + * @param request * @returns A NextResponse with the list of available presets */ export async function GET(request: NextRequest): Promise { diff --git a/app/api/transcribe/route.ts b/app/api/transcribe/route.ts index 28cf4261..0896806b 100644 --- a/app/api/transcribe/route.ts +++ b/app/api/transcribe/route.ts @@ -2,6 +2,10 @@ import { NextRequest, NextResponse } from "next/server"; import { processAudioTranscription } from "@/lib/transcribe/processAudioTranscription"; import { formatTranscriptionError } from "@/lib/transcribe/types"; +/** + * + * @param req + */ export async function POST(req: NextRequest) { try { const body = await req.json(); diff --git a/lib/admins/emails/__tests__/validateGetAdminEmailsQuery.test.ts b/lib/admins/emails/__tests__/validateGetAdminEmailsQuery.test.ts index 90e1a3d0..7531a477 100644 --- a/lib/admins/emails/__tests__/validateGetAdminEmailsQuery.test.ts +++ b/lib/admins/emails/__tests__/validateGetAdminEmailsQuery.test.ts @@ -12,6 +12,10 @@ vi.mock("@/lib/admins/validateAdminAuth", () => ({ validateAdminAuth: vi.fn(), })); +/** + * + * @param url + */ function createMockRequest(url: string): NextRequest { return { url, diff --git a/lib/admins/pr/__tests__/getPrMergedStatusHandler.test.ts b/lib/admins/pr/__tests__/getPrMergedStatusHandler.test.ts index e007e9c8..826b69d6 100644 --- a/lib/admins/pr/__tests__/getPrMergedStatusHandler.test.ts +++ b/lib/admins/pr/__tests__/getPrMergedStatusHandler.test.ts @@ -19,6 +19,10 @@ vi.mock("@/lib/github/fetchGithubPrStatus", () => ({ const PR_URL_1 = "https://github.com/recoupable/api/pull/42"; const PR_URL_2 = "https://github.com/recoupable/chat/pull/100"; +/** + * + * @param urls + */ function makeRequest(urls: string[] = [PR_URL_1]) { const params = new URLSearchParams(); urls.forEach(url => params.append("pull_requests", url)); diff --git a/lib/admins/pr/getPrStatusHandler.ts b/lib/admins/pr/getPrStatusHandler.ts index 27081718..73cefa94 100644 --- a/lib/admins/pr/getPrStatusHandler.ts +++ b/lib/admins/pr/getPrStatusHandler.ts @@ -10,6 +10,8 @@ import { fetchGithubPrStatus } from "@/lib/github/fetchGithubPrStatus"; * Uses the GitHub REST API to check each PR's state. * * Requires admin authentication. + * + * @param request */ export async function getPrStatusHandler(request: NextRequest): Promise { try { diff --git a/lib/admins/privy/countNewAccounts.ts b/lib/admins/privy/countNewAccounts.ts index 012ced53..1d34a14a 100644 --- a/lib/admins/privy/countNewAccounts.ts +++ b/lib/admins/privy/countNewAccounts.ts @@ -5,6 +5,9 @@ import { getCutoffMs } from "./getCutoffMs"; /** * Counts how many users in the list were created within the cutoff period. + * + * @param users + * @param period */ export function countNewAccounts(users: User[], period: PrivyLoginsPeriod): number { const cutoffMs = getCutoffMs(period); diff --git a/lib/admins/privy/fetchPrivyLogins.ts b/lib/admins/privy/fetchPrivyLogins.ts index ae4d4dd0..35ac556c 100644 --- a/lib/admins/privy/fetchPrivyLogins.ts +++ b/lib/admins/privy/fetchPrivyLogins.ts @@ -20,6 +20,10 @@ export type FetchPrivyLoginsResult = { totalPrivyUsers: number; }; +/** + * + * @param period + */ export async function fetchPrivyLogins(period: PrivyLoginsPeriod): Promise { const isAll = period === "all"; const cutoffMs = getCutoffMs(period); diff --git a/lib/admins/privy/getCutoffMs.ts b/lib/admins/privy/getCutoffMs.ts index 8b80ec6a..4de0fa32 100644 --- a/lib/admins/privy/getCutoffMs.ts +++ b/lib/admins/privy/getCutoffMs.ts @@ -5,6 +5,8 @@ import { PERIOD_DAYS } from "./periodDays"; * Returns the cutoff timestamp in milliseconds for a given period. * Uses midnight UTC calendar day boundaries to match Privy dashboard behavior. * Returns 0 for "all" (no cutoff). + * + * @param period */ export function getCutoffMs(period: PrivyLoginsPeriod): number { if (period === "all") return 0; diff --git a/lib/admins/privy/getLatestVerifiedAt.ts b/lib/admins/privy/getLatestVerifiedAt.ts index 465ea876..c7f7ba9b 100644 --- a/lib/admins/privy/getLatestVerifiedAt.ts +++ b/lib/admins/privy/getLatestVerifiedAt.ts @@ -4,6 +4,8 @@ import type { User } from "@privy-io/node"; /** * Returns the most recent latest_verified_at (in ms) across all linked_accounts for a Privy user. * Returns null if no linked account has a latest_verified_at. + * + * @param user */ export function getLatestVerifiedAt(user: User): number | null { const linkedAccounts = user.linked_accounts; diff --git a/lib/admins/privy/toMs.ts b/lib/admins/privy/toMs.ts index 472ff9eb..2daad687 100644 --- a/lib/admins/privy/toMs.ts +++ b/lib/admins/privy/toMs.ts @@ -1,6 +1,8 @@ /** * Normalizes a Privy timestamp to milliseconds. * Privy docs say milliseconds but examples show seconds (10 digits). + * + * @param timestamp */ export function toMs(timestamp: number): number { return timestamp > 1e12 ? timestamp : timestamp * 1000; diff --git a/lib/admins/slack/__tests__/getSlackTagsHandler.test.ts b/lib/admins/slack/__tests__/getSlackTagsHandler.test.ts index cabda456..6b5563bd 100644 --- a/lib/admins/slack/__tests__/getSlackTagsHandler.test.ts +++ b/lib/admins/slack/__tests__/getSlackTagsHandler.test.ts @@ -39,6 +39,10 @@ const mockTags = [ }, ]; +/** + * + * @param period + */ function makeRequest(period = "all") { return new NextRequest(`https://example.com/api/admins/coding/slack?period=${period}`); } diff --git a/lib/admins/slack/__tests__/validateGetSlackTagsQuery.test.ts b/lib/admins/slack/__tests__/validateGetSlackTagsQuery.test.ts index 21fc67d8..56a4d7a4 100644 --- a/lib/admins/slack/__tests__/validateGetSlackTagsQuery.test.ts +++ b/lib/admins/slack/__tests__/validateGetSlackTagsQuery.test.ts @@ -13,6 +13,10 @@ vi.mock("@/lib/admins/validateAdminAuth", () => ({ const mockAuth = { accountId: "test-account", orgId: null, authToken: "token" }; +/** + * + * @param period + */ function makeRequest(period?: string) { const url = period ? `https://example.com/api/admins/coding/slack?period=${period}` diff --git a/lib/admins/slack/extractGithubPrUrls.ts b/lib/admins/slack/extractGithubPrUrls.ts index 1bf6287d..0b534e20 100644 --- a/lib/admins/slack/extractGithubPrUrls.ts +++ b/lib/admins/slack/extractGithubPrUrls.ts @@ -24,6 +24,10 @@ const PR_URL_EXACT = /^https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/\d+$/; /** * Extracts GitHub pull request URLs from a Slack message's text, attachments, and blocks. * Handles plain URLs, Slack-formatted links, action button URLs, and Block Kit element URLs. + * + * @param text + * @param attachments + * @param blocks */ export function extractGithubPrUrls( text: string, diff --git a/lib/admins/slack/fetchThreadPullRequests.ts b/lib/admins/slack/fetchThreadPullRequests.ts index 4c3081a0..c49fed8b 100644 --- a/lib/admins/slack/fetchThreadPullRequests.ts +++ b/lib/admins/slack/fetchThreadPullRequests.ts @@ -18,6 +18,10 @@ interface ConversationsRepliesResponse { /** * Fetches bot replies in a Slack thread and returns any GitHub PR URLs found. * Extracts URLs from message text, attachment action buttons, and Block Kit blocks. + * + * @param token + * @param channel + * @param threadTs */ export async function fetchThreadPullRequests( token: string, diff --git a/lib/ai/getModel.ts b/lib/ai/getModel.ts index edf4d425..99ca9c2f 100644 --- a/lib/ai/getModel.ts +++ b/lib/ai/getModel.ts @@ -3,6 +3,7 @@ import { GatewayLanguageModelEntry } from "@ai-sdk/gateway"; /** * Returns a specific model by its ID from the list of available models. + * * @param modelId - The ID of the model to find * @returns The matching model or undefined if not found */ diff --git a/lib/ai/isEmbedModel.ts b/lib/ai/isEmbedModel.ts index 7c5fbbfb..4901f1e8 100644 --- a/lib/ai/isEmbedModel.ts +++ b/lib/ai/isEmbedModel.ts @@ -3,6 +3,8 @@ import { GatewayLanguageModelEntry } from "@ai-sdk/gateway"; /** * Determines if a model is an embedding model (not suitable for chat). * Embed models typically have 0 output pricing since they only produce embeddings. + * + * @param m */ export const isEmbedModel = (m: GatewayLanguageModelEntry): boolean => { const pricing = m.pricing; diff --git a/lib/artists/__tests__/createArtistPostHandler.test.ts b/lib/artists/__tests__/createArtistPostHandler.test.ts index e63d244d..dd72b2e1 100644 --- a/lib/artists/__tests__/createArtistPostHandler.test.ts +++ b/lib/artists/__tests__/createArtistPostHandler.test.ts @@ -14,6 +14,11 @@ vi.mock("@/lib/auth/validateAuthContext", () => ({ validateAuthContext: (...args: unknown[]) => mockValidateAuthContext(...args), })); +/** + * + * @param body + * @param headers + */ function createRequest(body: unknown, headers: Record = {}): NextRequest { const defaultHeaders: Record = { "Content-Type": "application/json", diff --git a/lib/artists/__tests__/validateCreateArtistBody.test.ts b/lib/artists/__tests__/validateCreateArtistBody.test.ts index 4de5562b..d12fe1ba 100644 --- a/lib/artists/__tests__/validateCreateArtistBody.test.ts +++ b/lib/artists/__tests__/validateCreateArtistBody.test.ts @@ -9,6 +9,11 @@ vi.mock("@/lib/auth/validateAuthContext", () => ({ validateAuthContext: (...args: unknown[]) => mockValidateAuthContext(...args), })); +/** + * + * @param body + * @param headers + */ function createRequest(body: unknown, headers: Record = {}): NextRequest { const defaultHeaders: Record = { "Content-Type": "application/json" }; return new NextRequest("http://localhost/api/artists", { diff --git a/lib/auth/__tests__/validateAuthContext.test.ts b/lib/auth/__tests__/validateAuthContext.test.ts index 31dda345..c4769178 100644 --- a/lib/auth/__tests__/validateAuthContext.test.ts +++ b/lib/auth/__tests__/validateAuthContext.test.ts @@ -33,6 +33,10 @@ const mockGetAuthenticatedAccountId = vi.mocked(getAuthenticatedAccountId); const mockValidateOrganizationAccess = vi.mocked(validateOrganizationAccess); const mockCanAccessAccount = vi.mocked(canAccessAccount); +/** + * + * @param headers + */ function createMockRequest(headers: Record = {}): Request { return { headers: { diff --git a/lib/catalog/formatCatalogSongsAsCSV.ts b/lib/catalog/formatCatalogSongsAsCSV.ts index 5115eece..29cc443c 100644 --- a/lib/catalog/formatCatalogSongsAsCSV.ts +++ b/lib/catalog/formatCatalogSongsAsCSV.ts @@ -2,6 +2,8 @@ import { CatalogSong } from "./getCatalogSongs"; /** * Formats catalog songs into the CSV-like format expected by the scorer + * + * @param songs */ export function formatCatalogSongsAsCSV(songs: CatalogSong[]): string { const csvLines = songs.map(song => { diff --git a/lib/catalog/getCatalogDataAsCSV.ts b/lib/catalog/getCatalogDataAsCSV.ts index ea529c37..4a86fc0e 100644 --- a/lib/catalog/getCatalogDataAsCSV.ts +++ b/lib/catalog/getCatalogDataAsCSV.ts @@ -3,6 +3,8 @@ import { formatCatalogSongsAsCSV } from "./formatCatalogSongsAsCSV"; /** * Gets all catalog songs and formats them as CSV for the scorer + * + * @param catalogId */ export async function getCatalogDataAsCSV(catalogId: string): Promise { const allSongs: CatalogSong[] = []; diff --git a/lib/catalog/getCatalogSongs.ts b/lib/catalog/getCatalogSongs.ts index c58c33be..d7b5ca62 100644 --- a/lib/catalog/getCatalogSongs.ts +++ b/lib/catalog/getCatalogSongs.ts @@ -25,6 +25,13 @@ export interface CatalogSongsResponse { error?: string; } +/** + * + * @param catalogId + * @param pageSize + * @param page + * @param artistName + */ export async function getCatalogSongs( catalogId: string, pageSize: number = 100, diff --git a/lib/catalog/getCatalogs.ts b/lib/catalog/getCatalogs.ts index 9533183b..4ac8a842 100644 --- a/lib/catalog/getCatalogs.ts +++ b/lib/catalog/getCatalogs.ts @@ -8,6 +8,10 @@ export interface CatalogsResponse { error?: string; } +/** + * + * @param accountId + */ export async function getCatalogs(accountId: string): Promise { try { const response = await fetch( diff --git a/lib/chat/__tests__/integration/chatEndToEnd.test.ts b/lib/chat/__tests__/integration/chatEndToEnd.test.ts index 02e758be..f08d5bcb 100644 --- a/lib/chat/__tests__/integration/chatEndToEnd.test.ts +++ b/lib/chat/__tests__/integration/chatEndToEnd.test.ts @@ -170,6 +170,11 @@ const mockDeductCredits = vi.mocked(deductCredits); const mockGenerateChatTitle = vi.mocked(generateChatTitle); // Helper to create mock NextRequest +/** + * + * @param body + * @param headers + */ function createMockRequest(body: unknown, headers: Record = {}): Request { return { json: () => Promise.resolve(body), diff --git a/lib/chat/toolChains/getPrepareStepResult.ts b/lib/chat/toolChains/getPrepareStepResult.ts index 02dd8e71..c011c078 100644 --- a/lib/chat/toolChains/getPrepareStepResult.ts +++ b/lib/chat/toolChains/getPrepareStepResult.ts @@ -12,6 +12,8 @@ type PrepareStepOptions = { /** * Returns the next tool to run based on timeline progression through tool chains. * Uses toolCallsContent to track exact execution order and position in sequence. + * + * @param options */ const getPrepareStepResult = (options: PrepareStepOptions): PrepareStepResult | undefined => { const { steps } = options; diff --git a/lib/chats/processCompactChatRequest.ts b/lib/chats/processCompactChatRequest.ts index a1699c93..c98c2e97 100644 --- a/lib/chats/processCompactChatRequest.ts +++ b/lib/chats/processCompactChatRequest.ts @@ -17,6 +17,9 @@ interface ProcessCompactChatRequestParams { * Verifies the chat exists and the user has access before compacting. * * @param params - The parameters for processing the chat compaction. + * @param params.chatId + * @param params.prompt + * @param params.accountId * @returns The result of the compaction attempt. */ export async function processCompactChatRequest({ diff --git a/lib/coding-agent/__tests__/handleGitHubWebhook.test.ts b/lib/coding-agent/__tests__/handleGitHubWebhook.test.ts index 5e059f4e..194a7170 100644 --- a/lib/coding-agent/__tests__/handleGitHubWebhook.test.ts +++ b/lib/coding-agent/__tests__/handleGitHubWebhook.test.ts @@ -45,6 +45,12 @@ const BASE_PAYLOAD = { }, }; +/** + * + * @param body + * @param event + * @param signature + */ function makeRequest(body: unknown, event = "issue_comment", signature = "valid") { return { text: () => Promise.resolve(JSON.stringify(body)), diff --git a/lib/coding-agent/__tests__/onMergeTestToMainAction.test.ts b/lib/coding-agent/__tests__/onMergeTestToMainAction.test.ts index 8af470e1..f173d6ce 100644 --- a/lib/coding-agent/__tests__/onMergeTestToMainAction.test.ts +++ b/lib/coding-agent/__tests__/onMergeTestToMainAction.test.ts @@ -12,6 +12,9 @@ beforeEach(() => { process.env.GITHUB_TOKEN = "ghp_test"; }); +/** + * + */ function createMockBot() { return { onAction: vi.fn() } as any; } diff --git a/lib/coding-agent/encodeGitHubThreadId.ts b/lib/coding-agent/encodeGitHubThreadId.ts index 1cfff2fe..f4797e43 100644 --- a/lib/coding-agent/encodeGitHubThreadId.ts +++ b/lib/coding-agent/encodeGitHubThreadId.ts @@ -6,6 +6,8 @@ import type { GitHubThreadId } from "@chat-adapter/github"; * * - PR-level: `github:{owner}/{repo}:{prNumber}` * - Review comment: `github:{owner}/{repo}:{prNumber}:rc:{reviewCommentId}` + * + * @param thread */ export function encodeGitHubThreadId(thread: GitHubThreadId): string { const { owner, repo, prNumber, reviewCommentId } = thread; diff --git a/lib/coding-agent/handleMergeSuccess.ts b/lib/coding-agent/handleMergeSuccess.ts index f026f48d..c241923b 100644 --- a/lib/coding-agent/handleMergeSuccess.ts +++ b/lib/coding-agent/handleMergeSuccess.ts @@ -7,6 +7,8 @@ import type { CodingAgentThreadState } from "./types"; * Handles post-merge cleanup after all PRs merged successfully. * Deletes the shared PR state keys for all repos and persists the latest * snapshot via upsertAccountSnapshot. + * + * @param state */ export async function handleMergeSuccess(state: CodingAgentThreadState): Promise { try { diff --git a/lib/coding-agent/parseMergeActionId.ts b/lib/coding-agent/parseMergeActionId.ts index 5118249e..25fd3eeb 100644 --- a/lib/coding-agent/parseMergeActionId.ts +++ b/lib/coding-agent/parseMergeActionId.ts @@ -1,6 +1,8 @@ /** * Parses a merge action ID like "merge_pr:recoupable/api#42" * into { repo, number } or null if the format doesn't match. + * + * @param actionId */ export function parseMergeActionId(actionId: string) { const match = actionId.match(/^merge_pr:(.+)#(\d+)$/); diff --git a/lib/coding-agent/parseMergeTestToMainActionId.ts b/lib/coding-agent/parseMergeTestToMainActionId.ts index 1228615f..14133eac 100644 --- a/lib/coding-agent/parseMergeTestToMainActionId.ts +++ b/lib/coding-agent/parseMergeTestToMainActionId.ts @@ -1,6 +1,8 @@ /** * Parses a merge_test_to_main action ID like "merge_test_to_main:recoupable/api" * into the repo string, or null if the format doesn't match. + * + * @param actionId */ export function parseMergeTestToMainActionId(actionId: string): string | null { const prefix = "merge_test_to_main:"; diff --git a/lib/composio/getCallbackUrl.ts b/lib/composio/getCallbackUrl.ts index 570c9251..8c83505a 100644 --- a/lib/composio/getCallbackUrl.ts +++ b/lib/composio/getCallbackUrl.ts @@ -19,6 +19,7 @@ interface CallbackOptions { * * @param options.destination - Where to redirect: "chat" or "connectors" * @param options.roomId - For chat destination, the room ID to return to + * @param options * @returns Full callback URL with success indicator */ export function getCallbackUrl(options: CallbackOptions): string { diff --git a/lib/content/__tests__/validateCreateContentBody.test.ts b/lib/content/__tests__/validateCreateContentBody.test.ts index 1a71d5ae..31b1c461 100644 --- a/lib/content/__tests__/validateCreateContentBody.test.ts +++ b/lib/content/__tests__/validateCreateContentBody.test.ts @@ -20,6 +20,10 @@ vi.mock("@/lib/content/resolveArtistSlug", () => ({ resolveArtistSlug: vi.fn().mockResolvedValue("gatsby-grace"), })); +/** + * + * @param body + */ function createRequest(body: unknown): NextRequest { return new NextRequest("http://localhost/api/content/create", { method: "POST", diff --git a/lib/content/contentTemplates.ts b/lib/content/contentTemplates.ts index 7d0653e3..66da0d33 100644 --- a/lib/content/contentTemplates.ts +++ b/lib/content/contentTemplates.ts @@ -25,6 +25,10 @@ export const CONTENT_TEMPLATES: ContentTemplate[] = [ /** Derived from the first entry in CONTENT_TEMPLATES to avoid string duplication. */ export const DEFAULT_CONTENT_TEMPLATE = CONTENT_TEMPLATES[0].name; +/** + * + * @param template + */ export function isSupportedContentTemplate(template: string): boolean { return CONTENT_TEMPLATES.some(item => item.name === template); } diff --git a/lib/content/createContentHandler.ts b/lib/content/createContentHandler.ts index b0e845f3..5459cbb8 100644 --- a/lib/content/createContentHandler.ts +++ b/lib/content/createContentHandler.ts @@ -9,6 +9,8 @@ import { selectAccountSnapshots } from "@/lib/supabase/account_snapshots/selectA /** * Handler for POST /api/content/create. * Always returns runIds array (KISS — one response shape for single and batch). + * + * @param request */ export async function createContentHandler(request: NextRequest): Promise { const validated = await validateCreateContentBody(request); diff --git a/lib/content/getArtistContentReadiness.ts b/lib/content/getArtistContentReadiness.ts index a902ce0f..9238598e 100644 --- a/lib/content/getArtistContentReadiness.ts +++ b/lib/content/getArtistContentReadiness.ts @@ -22,6 +22,11 @@ export interface ArtistContentReadiness { /** * Checks whether an artist has the expected files for content creation. * Searches the main repo and org submodule repos. + * + * @param root0 + * @param root0.accountId + * @param root0.artistAccountId + * @param root0.artistSlug */ export async function getArtistContentReadiness({ accountId, diff --git a/lib/content/getArtistFileTree.ts b/lib/content/getArtistFileTree.ts index 908855a0..b5392b52 100644 --- a/lib/content/getArtistFileTree.ts +++ b/lib/content/getArtistFileTree.ts @@ -4,6 +4,9 @@ import { getOrgRepoUrls } from "@/lib/github/getOrgRepoUrls"; /** * Gets the file tree that contains the artist, checking the main repo * first, then falling back to org submodule repos. + * + * @param githubRepo + * @param artistSlug */ export async function getArtistFileTree( githubRepo: string, diff --git a/lib/content/getArtistRootPrefix.ts b/lib/content/getArtistRootPrefix.ts index 5a777abe..bf81d48a 100644 --- a/lib/content/getArtistRootPrefix.ts +++ b/lib/content/getArtistRootPrefix.ts @@ -1,3 +1,8 @@ +/** + * + * @param paths + * @param artistSlug + */ export function getArtistRootPrefix(paths: string[], artistSlug: string): string { const preferredPrefix = `artists/${artistSlug}/`; if (paths.some(path => path.startsWith(preferredPrefix))) { diff --git a/lib/content/getContentValidateHandler.ts b/lib/content/getContentValidateHandler.ts index e0c758b8..81cd0ce8 100644 --- a/lib/content/getContentValidateHandler.ts +++ b/lib/content/getContentValidateHandler.ts @@ -8,6 +8,8 @@ import { getArtistContentReadiness } from "@/lib/content/getArtistContentReadine * Handler for GET /api/content/validate. * NOTE: Phase 1 returns structural readiness scaffolding. Deep filesystem checks * are performed in the background task before spend-heavy steps. + * + * @param request */ export async function getContentValidateHandler(request: NextRequest): Promise { const validated = await validateGetContentValidateQuery(request); diff --git a/lib/content/isCompletedRun.ts b/lib/content/isCompletedRun.ts index 855ea068..951d20b2 100644 --- a/lib/content/isCompletedRun.ts +++ b/lib/content/isCompletedRun.ts @@ -5,6 +5,10 @@ export type TriggerRunLike = { output?: unknown; }; +/** + * + * @param run + */ export function isCompletedRun(run: TriggerRunLike): boolean { return run.status === "COMPLETED"; } diff --git a/lib/content/persistCreateContentRunVideo.ts b/lib/content/persistCreateContentRunVideo.ts index 25a77eed..69bac792 100644 --- a/lib/content/persistCreateContentRunVideo.ts +++ b/lib/content/persistCreateContentRunVideo.ts @@ -27,6 +27,8 @@ type CreateContentOutput = { * and returns the run with normalized output. * * This keeps Supabase writes in API only. + * + * @param run */ export async function persistCreateContentRunVideo(run: T): Promise { if (run.taskIdentifier !== CREATE_CONTENT_TASK_ID || !isCompletedRun(run)) { diff --git a/lib/content/validateCreateContentBody.ts b/lib/content/validateCreateContentBody.ts index 47e3d8fa..2c780e75 100644 --- a/lib/content/validateCreateContentBody.ts +++ b/lib/content/validateCreateContentBody.ts @@ -40,6 +40,8 @@ export type ValidatedCreateContentBody = { /** * Validates auth and request body for POST /api/content/create. + * + * @param request */ export async function validateCreateContentBody( request: NextRequest, diff --git a/lib/content/validateGetContentEstimateQuery.ts b/lib/content/validateGetContentEstimateQuery.ts index 5828e7cc..97af7468 100644 --- a/lib/content/validateGetContentEstimateQuery.ts +++ b/lib/content/validateGetContentEstimateQuery.ts @@ -15,6 +15,8 @@ export type ValidatedGetContentEstimateQuery = z.infer { diff --git a/lib/evals/callChatFunctionsWithResult.ts b/lib/evals/callChatFunctionsWithResult.ts index a792248b..b80fcb58 100644 --- a/lib/evals/callChatFunctionsWithResult.ts +++ b/lib/evals/callChatFunctionsWithResult.ts @@ -8,6 +8,8 @@ import { ChatRequestBody } from "@/lib/chat/validateChatRequest"; * * Note: result.toolCalls only contains calls from the LAST step. When using multi-step * tool chains, we need to collect toolCalls from result.steps to capture all tool usage. + * + * @param input */ export async function callChatFunctionsWithResult(input: string) { const messages: UIMessage[] = [ diff --git a/lib/evals/createToolsCalledScorer.ts b/lib/evals/createToolsCalledScorer.ts index 1d838ee3..8a9ac7e7 100644 --- a/lib/evals/createToolsCalledScorer.ts +++ b/lib/evals/createToolsCalledScorer.ts @@ -3,6 +3,9 @@ import { ToolsCalled } from "./scorers/ToolsCalled"; /** * Creates a scorer that checks if required tools were called. * Handles extracting output text and toolCalls from the task result. + * + * @param requiredTools + * @param penalizedTools */ export const createToolsCalledScorer = (requiredTools: string[], penalizedTools: string[] = []) => { return async (args: { output: unknown; expected?: string; input: string }) => { diff --git a/lib/evals/extractTextFromResult.ts b/lib/evals/extractTextFromResult.ts index fac24cf6..dc67f3ab 100644 --- a/lib/evals/extractTextFromResult.ts +++ b/lib/evals/extractTextFromResult.ts @@ -3,6 +3,8 @@ import { extractTextResultFromSteps } from "./extractTextResultFromSteps"; /** * Extract text from a GenerateTextResult + * + * @param result */ export function extractTextFromResult(result: Awaited>): string { // Handle multi-step responses (when maxSteps > 1) diff --git a/lib/evals/extractTextResultFromSteps.ts b/lib/evals/extractTextResultFromSteps.ts index 44c0ae0d..16881677 100644 --- a/lib/evals/extractTextResultFromSteps.ts +++ b/lib/evals/extractTextResultFromSteps.ts @@ -4,6 +4,8 @@ import type { TextPart } from "ai"; /** * Extract text from multi-step GenerateTextResult * Handles responses where maxSteps > 1 + * + * @param result */ export function extractTextResultFromSteps( result: Awaited>, diff --git a/lib/evals/getCatalogSongsCountExpected.ts b/lib/evals/getCatalogSongsCountExpected.ts index 6f04e59c..d94383ef 100644 --- a/lib/evals/getCatalogSongsCountExpected.ts +++ b/lib/evals/getCatalogSongsCountExpected.ts @@ -2,6 +2,9 @@ import { getCatalogs } from "@/lib/catalog/getCatalogs"; import { getCatalogSongs } from "@/lib/catalog/getCatalogSongs"; import { EVAL_ACCOUNT_ID } from "@/lib/consts"; +/** + * + */ async function getCatalogSongsCountExpected() { try { const catalogsData = await getCatalogs(EVAL_ACCOUNT_ID); diff --git a/lib/evals/getSpotifyFollowersExpected.ts b/lib/evals/getSpotifyFollowersExpected.ts index ef96e248..f5221937 100644 --- a/lib/evals/getSpotifyFollowersExpected.ts +++ b/lib/evals/getSpotifyFollowersExpected.ts @@ -1,5 +1,9 @@ import { getSpotifyFollowers } from "@/lib/spotify/getSpotifyFollowers"; +/** + * + * @param artist + */ async function getSpotifyFollowersExpected(artist: string) { try { const followerCount = await getSpotifyFollowers(artist); diff --git a/lib/evals/scorers/CatalogAvailability.ts b/lib/evals/scorers/CatalogAvailability.ts index f4829ea4..8cf292d9 100644 --- a/lib/evals/scorers/CatalogAvailability.ts +++ b/lib/evals/scorers/CatalogAvailability.ts @@ -5,6 +5,11 @@ import { z } from "zod"; /** * Custom scorer that uses AI to check if recommended songs are actually in the catalog + * + * @param root0 + * @param root0.output + * @param root0.expected + * @param root0.input */ export const CatalogAvailability = async ({ output, diff --git a/lib/evals/scorers/QuestionAnswered.ts b/lib/evals/scorers/QuestionAnswered.ts index abe0222c..a7bafd1d 100644 --- a/lib/evals/scorers/QuestionAnswered.ts +++ b/lib/evals/scorers/QuestionAnswered.ts @@ -5,6 +5,11 @@ import { z } from "zod"; /** * Custom scorer that checks if the AI actually answered the customer's question * with a specific answer, or if it deflected/explained why it couldn't answer + * + * @param root0 + * @param root0.output + * @param root0.expected + * @param root0.input */ export const QuestionAnswered = async ({ output, diff --git a/lib/evals/scorers/ToolsCalled.ts b/lib/evals/scorers/ToolsCalled.ts index 2d901ec3..6a451100 100644 --- a/lib/evals/scorers/ToolsCalled.ts +++ b/lib/evals/scorers/ToolsCalled.ts @@ -1,5 +1,13 @@ /** * Generic scorer that checks if specific tools were called + * + * @param root0 + * @param root0.output + * @param root0.expected + * @param root0.input + * @param root0.toolCalls + * @param root0.requiredTools + * @param root0.penalizedTools */ export const ToolsCalled = async ({ toolCalls, diff --git a/lib/flamingo/__tests__/getFlamingoPresetsHandler.test.ts b/lib/flamingo/__tests__/getFlamingoPresetsHandler.test.ts index 19109b2d..1c30d8fc 100644 --- a/lib/flamingo/__tests__/getFlamingoPresetsHandler.test.ts +++ b/lib/flamingo/__tests__/getFlamingoPresetsHandler.test.ts @@ -17,6 +17,9 @@ vi.mock("../presets", () => ({ getPresetSummaries: vi.fn(), })); +/** + * + */ function createMockRequest(): NextRequest { return { headers: new Headers({ "x-api-key": "test-key" }), diff --git a/lib/flamingo/getFlamingoPresetsHandler.ts b/lib/flamingo/getFlamingoPresetsHandler.ts index e35b5899..f33d491d 100644 --- a/lib/flamingo/getFlamingoPresetsHandler.ts +++ b/lib/flamingo/getFlamingoPresetsHandler.ts @@ -10,6 +10,7 @@ import { validateAuthContext } from "@/lib/auth/validateAuthContext"; * Returns a list of all available analysis presets. * Requires authentication via x-api-key header or Authorization bearer token. * + * @param request * @returns A NextResponse with the list of available presets. */ export async function getFlamingoPresetsHandler(request: NextRequest): Promise { diff --git a/lib/github/expandSubmoduleEntries.ts b/lib/github/expandSubmoduleEntries.ts index 9531bee1..3082c63b 100644 --- a/lib/github/expandSubmoduleEntries.ts +++ b/lib/github/expandSubmoduleEntries.ts @@ -11,9 +11,15 @@ interface SubmoduleRef { * Resolves submodule URLs from .gitmodules, fetches each submodule's tree, * and merges the results into the regular entries with correct path prefixes. * + * @param regularEntries.regularEntries * @param regularEntries - Non-submodule file tree entries * @param submoduleEntries - Submodule references (type "commit" from GitHub Trees API) * @param repo - Repository context for fetching .gitmodules + * @param regularEntries.submoduleEntries + * @param regularEntries.repo + * @param regularEntries.repo.owner + * @param regularEntries.repo.repo + * @param regularEntries.repo.branch * @returns Combined file tree entries with submodules expanded as directories */ export async function expandSubmoduleEntries({ diff --git a/lib/github/getRepoGitModules.ts b/lib/github/getRepoGitModules.ts index caa0304e..8913a6ae 100644 --- a/lib/github/getRepoGitModules.ts +++ b/lib/github/getRepoGitModules.ts @@ -4,9 +4,12 @@ import { parseGitModules, type SubmoduleEntry } from "./parseGitModules"; * Fetches and parses .gitmodules from a GitHub repository. * Uses the GitHub Contents API (works for both public and private repos). * + * @param owner.owner * @param owner - The GitHub repository owner * @param repo - The GitHub repository name * @param branch - The branch to fetch from + * @param owner.repo + * @param owner.branch * @returns Array of submodule entries, or null if .gitmodules doesn't exist or fetch fails */ export async function getRepoGitModules({ diff --git a/lib/github/resolveSubmodulePath.ts b/lib/github/resolveSubmodulePath.ts index 7c3f60ed..029f1b1d 100644 --- a/lib/github/resolveSubmodulePath.ts +++ b/lib/github/resolveSubmodulePath.ts @@ -6,8 +6,10 @@ import { getRepoGitModules } from "./getRepoGitModules"; * If the path falls within a submodule, returns the submodule's repo URL * and the relative path within it. Otherwise returns the original values. * + * @param githubRepo.githubRepo * @param githubRepo - The parent GitHub repository URL * @param path - The file path to resolve + * @param githubRepo.path * @returns The resolved repo URL and path */ export async function resolveSubmodulePath({ diff --git a/lib/mcp/resolveAccountId.ts b/lib/mcp/resolveAccountId.ts index 03d1d0d8..456fe4c6 100644 --- a/lib/mcp/resolveAccountId.ts +++ b/lib/mcp/resolveAccountId.ts @@ -16,6 +16,8 @@ export interface ResolveAccountIdResult { * Validates access when an org API key attempts to use an account_id override. * * @param params - The auth info and optional account_id override. + * @param params.authInfo + * @param params.accountIdOverride * @returns The resolved accountId or an error message. */ export async function resolveAccountId({ diff --git a/lib/mcp/tools/transcribe/registerTranscribeAudioTool.ts b/lib/mcp/tools/transcribe/registerTranscribeAudioTool.ts index 4942fdfb..d8a64f79 100644 --- a/lib/mcp/tools/transcribe/registerTranscribeAudioTool.ts +++ b/lib/mcp/tools/transcribe/registerTranscribeAudioTool.ts @@ -15,6 +15,10 @@ const transcribeAudioSchema = z.object({ type TranscribeAudioArgs = z.infer; +/** + * + * @param server + */ export function registerTranscribeAudioTool(server: McpServer): void { server.registerTool( "transcribe_audio", diff --git a/lib/notifications/__tests__/createNotificationHandler.test.ts b/lib/notifications/__tests__/createNotificationHandler.test.ts index ca7fb677..60b6e5ba 100644 --- a/lib/notifications/__tests__/createNotificationHandler.test.ts +++ b/lib/notifications/__tests__/createNotificationHandler.test.ts @@ -26,6 +26,10 @@ vi.mock("@/lib/networking/safeParseJson", () => ({ safeParseJson: vi.fn(async (req: Request) => req.json()), })); +/** + * + * @param body + */ function createRequest(body: unknown): NextRequest { return new NextRequest("https://recoup-api.vercel.app/api/notifications", { method: "POST", diff --git a/lib/notifications/__tests__/validateCreateNotificationBody.test.ts b/lib/notifications/__tests__/validateCreateNotificationBody.test.ts index 10390b15..645ccedc 100644 --- a/lib/notifications/__tests__/validateCreateNotificationBody.test.ts +++ b/lib/notifications/__tests__/validateCreateNotificationBody.test.ts @@ -16,6 +16,11 @@ vi.mock("@/lib/networking/safeParseJson", () => ({ safeParseJson: vi.fn(async (req: Request) => req.json()), })); +/** + * + * @param body + * @param headers + */ function createRequest(body: unknown, headers: Record = {}): NextRequest { const defaultHeaders: Record = { "Content-Type": "application/json" }; return new NextRequest("http://localhost/api/notifications", { diff --git a/lib/prompts/getSystemPrompt.ts b/lib/prompts/getSystemPrompt.ts index 54964670..5077609a 100644 --- a/lib/prompts/getSystemPrompt.ts +++ b/lib/prompts/getSystemPrompt.ts @@ -13,6 +13,7 @@ import { AccountWithDetails } from "@/lib/supabase/accounts/getAccountWithDetail * @param params.artistInstruction - The artist instruction * @param params.conversationName - The name of the conversation * @param params.accountWithDetails - The account with details + * @param params.orgId * @returns The system prompt */ export function getSystemPrompt({ diff --git a/lib/slack/getBotChannels.ts b/lib/slack/getBotChannels.ts index 01fb47ff..6c2f905a 100644 --- a/lib/slack/getBotChannels.ts +++ b/lib/slack/getBotChannels.ts @@ -9,6 +9,8 @@ interface ConversationsListResponse { /** * Returns all channels the bot is a member of, paginating through all results. + * + * @param token */ export async function getBotChannels(token: string): Promise> { const channels: Array<{ id: string; name: string }> = []; diff --git a/lib/slack/getBotUserId.ts b/lib/slack/getBotUserId.ts index 1c3e0924..673ec465 100644 --- a/lib/slack/getBotUserId.ts +++ b/lib/slack/getBotUserId.ts @@ -8,6 +8,8 @@ interface AuthTestResponse { /** * Returns the authenticated bot's Slack user ID via auth.test. + * + * @param token */ export async function getBotUserId(token: string): Promise { const authTest = await slackGet("auth.test", token); diff --git a/lib/slack/getSlackUserInfo.ts b/lib/slack/getSlackUserInfo.ts index eb144e45..91873ddf 100644 --- a/lib/slack/getSlackUserInfo.ts +++ b/lib/slack/getSlackUserInfo.ts @@ -16,6 +16,9 @@ interface UsersInfoResponse { /** * Fetches a Slack account's display name and avatar by their Slack ID. + * + * @param token + * @param userId */ export async function getSlackUserInfo( token: string, diff --git a/lib/spotify/getSpotifyFollowers.ts b/lib/spotify/getSpotifyFollowers.ts index 235de41e..acd1c3be 100644 --- a/lib/spotify/getSpotifyFollowers.ts +++ b/lib/spotify/getSpotifyFollowers.ts @@ -37,6 +37,7 @@ interface SpotifySearchResponse { /** * Get Spotify follower count for an artist + * * @param artistName - The name of the artist to search for * @returns Promise - The follower count of the first matching artist */ diff --git a/lib/supabase/account_artist_ids/getAccountArtistIds.ts b/lib/supabase/account_artist_ids/getAccountArtistIds.ts index e4e6b809..42b550d0 100644 --- a/lib/supabase/account_artist_ids/getAccountArtistIds.ts +++ b/lib/supabase/account_artist_ids/getAccountArtistIds.ts @@ -8,7 +8,9 @@ export type AccountArtistRow = ArtistQueryRow & { artist_id: string; pinned: boo * Get all artists for an array of artist IDs or account IDs, with full info. * Returns raw data - formatting should be done by caller. * - * @param params Object with artistIds or accountIds array + * @param params - Object with artistIds or accountIds array + * @param params.artistIds + * @param params.accountIds * @returns Array of raw artist rows from database */ export async function getAccountArtistIds(params: { diff --git a/lib/supabase/account_workspace_ids/getAccountWorkspaceIds.ts b/lib/supabase/account_workspace_ids/getAccountWorkspaceIds.ts index ae121fdd..4ca7ad8e 100644 --- a/lib/supabase/account_workspace_ids/getAccountWorkspaceIds.ts +++ b/lib/supabase/account_workspace_ids/getAccountWorkspaceIds.ts @@ -10,7 +10,7 @@ export type AccountWorkspaceRow = Omit & { * Get all workspaces for an account, with full info. * Returns raw data - formatting should be done by caller. * - * @param accountId The owner's account ID + * @param accountId - The owner's account ID * @returns Array of raw workspace rows from database */ export async function getAccountWorkspaceIds(accountId: string): Promise { diff --git a/lib/supabase/files/createFileRecord.ts b/lib/supabase/files/createFileRecord.ts index 6f836f3c..3182de11 100644 --- a/lib/supabase/files/createFileRecord.ts +++ b/lib/supabase/files/createFileRecord.ts @@ -25,6 +25,8 @@ export interface CreateFileRecordParams { /** * Create a file record in the database + * + * @param params */ export async function createFileRecord(params: CreateFileRecordParams): Promise { const { diff --git a/lib/supabase/song_artists/insertSongArtists.ts b/lib/supabase/song_artists/insertSongArtists.ts index b81879e3..69878d6d 100644 --- a/lib/supabase/song_artists/insertSongArtists.ts +++ b/lib/supabase/song_artists/insertSongArtists.ts @@ -5,6 +5,8 @@ export type SongArtistInsert = TablesInsert<"song_artists">; /** * Inserts song-artist relationships, skipping duplicates. + * + * @param songArtists */ export async function insertSongArtists(songArtists: SongArtistInsert[]): Promise { const records = songArtists.filter( diff --git a/lib/supabase/storage/uploadFileByKey.ts b/lib/supabase/storage/uploadFileByKey.ts index ba146fa3..ae149173 100644 --- a/lib/supabase/storage/uploadFileByKey.ts +++ b/lib/supabase/storage/uploadFileByKey.ts @@ -3,6 +3,12 @@ import { SUPABASE_STORAGE_BUCKET } from "@/lib/const"; /** * Upload file to Supabase storage by key + * + * @param key + * @param file + * @param options + * @param options.contentType + * @param options.upsert */ export async function uploadFileByKey( key: string, diff --git a/lib/tasks/__tests__/enrichTaskWithTriggerInfo.test.ts b/lib/tasks/__tests__/enrichTaskWithTriggerInfo.test.ts index 60d38a96..c6082f98 100644 --- a/lib/tasks/__tests__/enrichTaskWithTriggerInfo.test.ts +++ b/lib/tasks/__tests__/enrichTaskWithTriggerInfo.test.ts @@ -1,6 +1,9 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { enrichTaskWithTriggerInfo } from "../enrichTaskWithTriggerInfo"; +import { fetchTriggerRuns } from "@/lib/trigger/fetchTriggerRuns"; +import { retrieveTaskRun } from "@/lib/trigger/retrieveTaskRun"; + vi.mock("@/lib/trigger/fetchTriggerRuns", () => ({ fetchTriggerRuns: vi.fn(), })); @@ -9,9 +12,6 @@ vi.mock("@/lib/trigger/retrieveTaskRun", () => ({ retrieveTaskRun: vi.fn(), })); -import { fetchTriggerRuns } from "@/lib/trigger/fetchTriggerRuns"; -import { retrieveTaskRun } from "@/lib/trigger/retrieveTaskRun"; - const mockTask = { id: "task-123", title: "Test Task", diff --git a/lib/tasks/__tests__/getTaskRunHandler.test.ts b/lib/tasks/__tests__/getTaskRunHandler.test.ts index 9f17fffc..3ab107f8 100644 --- a/lib/tasks/__tests__/getTaskRunHandler.test.ts +++ b/lib/tasks/__tests__/getTaskRunHandler.test.ts @@ -23,6 +23,9 @@ vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), })); +/** + * + */ function createMockRequest(): NextRequest { return { url: "http://localhost:3000/api/tasks/runs", diff --git a/lib/tasks/__tests__/validateGetTaskRunQuery.test.ts b/lib/tasks/__tests__/validateGetTaskRunQuery.test.ts index f7126175..77d410da 100644 --- a/lib/tasks/__tests__/validateGetTaskRunQuery.test.ts +++ b/lib/tasks/__tests__/validateGetTaskRunQuery.test.ts @@ -24,6 +24,8 @@ vi.mock("@/lib/admins/checkIsAdmin", () => ({ /** * Creates a mock NextRequest with the given URL. + * + * @param url */ function createMockRequest(url: string): NextRequest { return { diff --git a/lib/transcribe/processAudioTranscription.ts b/lib/transcribe/processAudioTranscription.ts index 351eee34..0e05905a 100644 --- a/lib/transcribe/processAudioTranscription.ts +++ b/lib/transcribe/processAudioTranscription.ts @@ -7,6 +7,8 @@ import { ProcessTranscriptionParams, ProcessTranscriptionResult } from "./types" /** * Fetches audio from URL, transcribes it with OpenAI Whisper, and saves both * the original audio and transcript markdown to the customer's files. + * + * @param params */ export async function processAudioTranscription( params: ProcessTranscriptionParams, @@ -64,6 +66,10 @@ export async function processAudioTranscription( }; } +/** + * + * @param contentType + */ function getExtensionFromContentType(contentType: string): string { if (contentType.includes("wav")) return "wav"; if (contentType.includes("m4a") || contentType.includes("mp4")) return "m4a"; diff --git a/lib/transcribe/saveAudioToFiles.ts b/lib/transcribe/saveAudioToFiles.ts index 12bda1ef..2124e512 100644 --- a/lib/transcribe/saveAudioToFiles.ts +++ b/lib/transcribe/saveAudioToFiles.ts @@ -2,6 +2,10 @@ import { uploadFileByKey } from "@/lib/supabase/storage/uploadFileByKey"; import { createFileRecord } from "@/lib/supabase/files/createFileRecord"; import { SaveAudioParams, FileRecord } from "./types"; +/** + * + * @param params + */ export async function saveAudioToFiles(params: SaveAudioParams): Promise { const { audioBlob, diff --git a/lib/transcribe/saveTranscriptToFiles.ts b/lib/transcribe/saveTranscriptToFiles.ts index 627feb6d..fa7518c5 100644 --- a/lib/transcribe/saveTranscriptToFiles.ts +++ b/lib/transcribe/saveTranscriptToFiles.ts @@ -2,6 +2,10 @@ import { uploadFileByKey } from "@/lib/supabase/storage/uploadFileByKey"; import { createFileRecord } from "@/lib/supabase/files/createFileRecord"; import { SaveTranscriptParams, FileRecord } from "./types"; +/** + * + * @param params + */ export async function saveTranscriptToFiles(params: SaveTranscriptParams): Promise { const { markdown, ownerAccountId, artistAccountId, title = "Transcription" } = params; diff --git a/lib/transcribe/types.ts b/lib/transcribe/types.ts index 91c0ac10..916e699c 100644 --- a/lib/transcribe/types.ts +++ b/lib/transcribe/types.ts @@ -56,6 +56,8 @@ export interface ProcessTranscriptionResult { /** * Formats transcription errors into user-friendly messages. * Centralizes error message logic to avoid duplication. + * + * @param error */ export function formatTranscriptionError(error: unknown): { message: string; status: number } { const rawMessage = error instanceof Error ? error.message : "Transcription failed"; diff --git a/lib/trigger/triggerCreateContent.ts b/lib/trigger/triggerCreateContent.ts index 494f7063..e8732539 100644 --- a/lib/trigger/triggerCreateContent.ts +++ b/lib/trigger/triggerCreateContent.ts @@ -16,6 +16,8 @@ export interface TriggerCreateContentPayload { /** * Triggers the create-content task in Trigger.dev. + * + * @param payload */ export async function triggerCreateContent(payload: TriggerCreateContentPayload) { const handle = await tasks.trigger(CREATE_CONTENT_TASK_ID, payload); From bd01b287f4c72e739f2318c759e7b0544aff5b43 Mon Sep 17 00:00:00 2001 From: CTO Agent Date: Tue, 24 Mar 2026 16:02:12 +0000 Subject: [PATCH 3/3] feat: scaffold Recoup Content Agent Slack bot Add content-agent following the existing coding-agent pattern: - Slack @mention handler that parses artist_account_id, template, batch, lipsync args - Triggers existing create-content pipeline via Trigger.dev - poll-content-run task monitors runs and posts video results back to Slack thread - Callback endpoint receives poll results and posts to Slack - Redis-backed thread state via Chat SDK New env vars required: SLACK_CONTENT_BOT_TOKEN, SLACK_CONTENT_SIGNING_SECRET, CONTENT_AGENT_CALLBACK_SECRET Co-Authored-By: Paperclip --- app/api/content-agent/[platform]/route.ts | 59 ++++++++ app/api/content-agent/callback/route.ts | 16 ++ lib/content-agent/bot.ts | 52 +++++++ lib/content-agent/getThread.ts | 17 +++ .../handleContentAgentCallback.ts | 84 ++++++++++ .../handlers/handleContentAgentCallback.ts | 17 +++ .../handlers/handleContentAgentMention.ts | 143 ++++++++++++++++++ .../handlers/registerHandlers.ts | 10 ++ lib/content-agent/types.ts | 12 ++ .../validateContentAgentCallback.ts | 49 ++++++ lib/content-agent/validateEnv.ts | 19 +++ lib/trigger/triggerPollContentRun.ts | 18 +++ 12 files changed, 496 insertions(+) create mode 100644 app/api/content-agent/[platform]/route.ts create mode 100644 app/api/content-agent/callback/route.ts create mode 100644 lib/content-agent/bot.ts create mode 100644 lib/content-agent/getThread.ts create mode 100644 lib/content-agent/handleContentAgentCallback.ts create mode 100644 lib/content-agent/handlers/handleContentAgentCallback.ts create mode 100644 lib/content-agent/handlers/handleContentAgentMention.ts create mode 100644 lib/content-agent/handlers/registerHandlers.ts create mode 100644 lib/content-agent/types.ts create mode 100644 lib/content-agent/validateContentAgentCallback.ts create mode 100644 lib/content-agent/validateEnv.ts create mode 100644 lib/trigger/triggerPollContentRun.ts diff --git a/app/api/content-agent/[platform]/route.ts b/app/api/content-agent/[platform]/route.ts new file mode 100644 index 00000000..d8bc55cd --- /dev/null +++ b/app/api/content-agent/[platform]/route.ts @@ -0,0 +1,59 @@ +import type { NextRequest } from "next/server"; +import { after } from "next/server"; +import { contentAgentBot } from "@/lib/content-agent/bot"; +import { handleUrlVerification } from "@/lib/slack/handleUrlVerification"; +import "@/lib/content-agent/handlers/registerHandlers"; + +/** + * GET /api/content-agent/[platform] + * + * Handles webhook verification handshakes for the content agent bot. + * + * @param request - The incoming verification request + * @param params - Route params containing the platform name + */ +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ platform: string }> }, +) { + const { platform } = await params; + + const handler = contentAgentBot.webhooks[platform as keyof typeof contentAgentBot.webhooks]; + + if (!handler) { + return new Response("Unknown platform", { status: 404 }); + } + + return handler(request, { waitUntil: p => after(() => p) }); +} + +/** + * POST /api/content-agent/[platform] + * + * Webhook endpoint for the content agent bot. + * Handles Slack webhooks via dynamic [platform] segment. + * + * @param request - The incoming webhook request + * @param params - Route params containing the platform name + */ +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ platform: string }> }, +) { + const { platform } = await params; + + if (platform === "slack") { + const verification = await handleUrlVerification(request); + if (verification) return verification; + } + + await contentAgentBot.initialize(); + + const handler = contentAgentBot.webhooks[platform as keyof typeof contentAgentBot.webhooks]; + + if (!handler) { + return new Response("Unknown platform", { status: 404 }); + } + + return handler(request, { waitUntil: p => after(() => p) }); +} diff --git a/app/api/content-agent/callback/route.ts b/app/api/content-agent/callback/route.ts new file mode 100644 index 00000000..b3918981 --- /dev/null +++ b/app/api/content-agent/callback/route.ts @@ -0,0 +1,16 @@ +import type { NextRequest } from "next/server"; +import { contentAgentBot } from "@/lib/content-agent/bot"; +import { handleContentAgentCallback } from "@/lib/content-agent/handleContentAgentCallback"; + +/** + * POST /api/content-agent/callback + * + * Callback endpoint for the poll-content-run Trigger.dev task. + * Receives task results and posts them back to the Slack thread. + * + * @param request - The incoming callback request + */ +export async function POST(request: NextRequest) { + await contentAgentBot.initialize(); + return handleContentAgentCallback(request); +} diff --git a/lib/content-agent/bot.ts b/lib/content-agent/bot.ts new file mode 100644 index 00000000..d2beb45f --- /dev/null +++ b/lib/content-agent/bot.ts @@ -0,0 +1,52 @@ +import { Chat, ConsoleLogger } from "chat"; +import { SlackAdapter } from "@chat-adapter/slack"; +import { createIoRedisState } from "@chat-adapter/state-ioredis"; +import redis from "@/lib/redis/connection"; +import type { ContentAgentThreadState } from "./types"; +import { validateContentAgentEnv } from "./validateEnv"; + +const logger = new ConsoleLogger(); + +type ContentAgentAdapters = { + slack: SlackAdapter; +}; + +/** + * Creates a new Chat bot instance configured with the Slack adapter + * for the Recoup Content Agent. + */ +export function createContentAgentBot() { + validateContentAgentEnv(); + + if (redis.status === "wait") { + redis.connect().catch(() => { + throw new Error("[content-agent] Redis failed to connect"); + }); + } + + const state = createIoRedisState({ + client: redis, + keyPrefix: "content-agent", + logger, + }); + + const slack = new SlackAdapter({ + botToken: process.env.SLACK_CONTENT_BOT_TOKEN!, + signingSecret: process.env.SLACK_CONTENT_SIGNING_SECRET!, + logger, + }); + + return new Chat({ + userName: "Recoup Content Agent", + adapters: { slack }, + state, + }); +} + +export type ContentAgentBot = ReturnType; + +/** + * Singleton bot instance. Registers as the Chat SDK singleton + * so ThreadImpl can resolve adapters lazily from thread IDs. + */ +export const contentAgentBot = createContentAgentBot().registerSingleton(); diff --git a/lib/content-agent/getThread.ts b/lib/content-agent/getThread.ts new file mode 100644 index 00000000..3554cd99 --- /dev/null +++ b/lib/content-agent/getThread.ts @@ -0,0 +1,17 @@ +import { ThreadImpl } from "chat"; +import type { ContentAgentThreadState } from "./types"; + +/** + * Reconstructs a Thread from a stored thread ID using the Chat SDK singleton. + * + * @param threadId + */ +export function getThread(threadId: string) { + const adapterName = threadId.split(":")[0]; + const channelId = `${adapterName}:${threadId.split(":")[1]}`; + return new ThreadImpl({ + adapterName, + id: threadId, + channelId, + }); +} diff --git a/lib/content-agent/handleContentAgentCallback.ts b/lib/content-agent/handleContentAgentCallback.ts new file mode 100644 index 00000000..9aec98e6 --- /dev/null +++ b/lib/content-agent/handleContentAgentCallback.ts @@ -0,0 +1,84 @@ +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateContentAgentCallback } from "./validateContentAgentCallback"; +import { getThread } from "./getThread"; + +/** + * Handles content agent task callback from the poll-content-run Trigger.dev task. + * Verifies the shared secret and posts results back to the Slack thread. + * + * @param request - The incoming callback request + * @returns A NextResponse + */ +export async function handleContentAgentCallback(request: Request): Promise { + const secret = request.headers.get("x-callback-secret"); + const expectedSecret = process.env.CONTENT_AGENT_CALLBACK_SECRET; + + if (!secret || secret !== expectedSecret) { + return NextResponse.json( + { status: "error", error: "Unauthorized" }, + { status: 401, headers: getCorsHeaders() }, + ); + } + + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json( + { status: "error", error: "Invalid JSON body" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + const validated = validateContentAgentCallback(body); + + if (validated instanceof NextResponse) { + return validated; + } + + const thread = getThread(validated.threadId); + + switch (validated.status) { + case "completed": { + const results = validated.results ?? []; + const videos = results.filter(r => r.status === "completed" && r.videoUrl); + const failed = results.filter(r => r.status === "failed"); + + if (videos.length > 0) { + const lines = videos.map((v, i) => { + const label = videos.length > 1 ? `**Video ${i + 1}:** ` : ""; + const caption = v.captionText ? `\n> ${v.captionText}` : ""; + return `${label}${v.videoUrl}${caption}`; + }); + + if (failed.length > 0) { + lines.push(`\n_${failed.length} run(s) failed._`); + } + + await thread.post(lines.join("\n\n")); + } else { + await thread.post("Content generation finished but no videos were produced."); + } + + await thread.setState({ status: "completed" }); + break; + } + + case "failed": + await thread.setState({ status: "failed" }); + await thread.post( + `Content generation failed: ${validated.message ?? "Unknown error"}`, + ); + break; + + case "timeout": + await thread.setState({ status: "timeout" }); + await thread.post( + "Content generation timed out after 30 minutes. The pipeline may still be running — check the Trigger.dev dashboard.", + ); + break; + } + + return NextResponse.json({ status: "ok" }, { headers: getCorsHeaders() }); +} diff --git a/lib/content-agent/handlers/handleContentAgentCallback.ts b/lib/content-agent/handlers/handleContentAgentCallback.ts new file mode 100644 index 00000000..88867f08 --- /dev/null +++ b/lib/content-agent/handlers/handleContentAgentCallback.ts @@ -0,0 +1,17 @@ +import type { ContentAgentBot } from "../bot"; + +/** + * Registers the onSubscribedMessage handler for the content agent. + * Handles replies in active threads while content is being generated. + * + * @param bot + */ +export function registerOnSubscribedMessage(bot: ContentAgentBot) { + bot.onSubscribedMessage(async (thread, _message) => { + const state = await thread.state; + + if (state?.status === "running") { + await thread.post("Still generating your content. I'll reply here when it's ready."); + } + }); +} diff --git a/lib/content-agent/handlers/handleContentAgentMention.ts b/lib/content-agent/handlers/handleContentAgentMention.ts new file mode 100644 index 00000000..df1bcf00 --- /dev/null +++ b/lib/content-agent/handlers/handleContentAgentMention.ts @@ -0,0 +1,143 @@ +import type { ContentAgentBot } from "../bot"; +import { triggerCreateContent } from "@/lib/trigger/triggerCreateContent"; +import { triggerPollContentRun } from "@/lib/trigger/triggerPollContentRun"; +import { resolveArtistSlug } from "@/lib/content/resolveArtistSlug"; +import { getArtistContentReadiness } from "@/lib/content/getArtistContentReadiness"; +import { selectAccountSnapshots } from "@/lib/supabase/account_snapshots/selectAccountSnapshots"; +import { + DEFAULT_CONTENT_TEMPLATE, + isSupportedContentTemplate, +} from "@/lib/content/contentTemplates"; + +/** + * Parses the mention text into content generation parameters. + * + * Format: [template] [batch=N] [lipsync] + */ +function parseMentionArgs(text: string) { + const tokens = text.trim().split(/\s+/); + const artistAccountId = tokens[0]; + let template = DEFAULT_CONTENT_TEMPLATE; + let batch = 1; + let lipsync = false; + + for (let i = 1; i < tokens.length; i++) { + const token = tokens[i].toLowerCase(); + if (token.startsWith("batch=")) { + const n = parseInt(token.split("=")[1], 10); + if (!isNaN(n) && n >= 1 && n <= 30) batch = n; + } else if (token === "lipsync") { + lipsync = true; + } else if (!token.startsWith("batch") && token !== "lipsync") { + template = tokens[i]; // preserve original case for template name + } + } + + return { artistAccountId, template, batch, lipsync }; +} + +/** + * Registers the onNewMention handler on the content agent bot. + * Parses the mention text, validates the artist, triggers content creation, + * and starts a polling task to report results back. + * + * @param bot + */ +export function registerOnNewMention(bot: ContentAgentBot) { + bot.onNewMention(async (thread, message) => { + try { + const { artistAccountId, template, batch, lipsync } = parseMentionArgs(message.text); + + if (!artistAccountId) { + await thread.post( + "Please provide an artist account ID.\n\nUsage: `@RecoupContentAgent [template] [batch=N] [lipsync]`", + ); + return; + } + + if (!isSupportedContentTemplate(template)) { + await thread.post(`Unsupported template: \`${template}\`. Check available templates.`); + return; + } + + // Resolve artist slug + const artistSlug = await resolveArtistSlug(artistAccountId); + if (!artistSlug) { + await thread.post( + `Artist not found for account ID \`${artistAccountId}\`. Please check the ID and try again.`, + ); + return; + } + + // Resolve GitHub repo + let githubRepo: string; + try { + const readiness = await getArtistContentReadiness({ + accountId: artistAccountId, + artistAccountId, + artistSlug, + }); + githubRepo = readiness.githubRepo; + } catch { + const snapshots = await selectAccountSnapshots(artistAccountId); + const repo = snapshots?.[0]?.github_repo; + if (!repo) { + await thread.post( + `No GitHub repository found for artist \`${artistSlug}\`. Content creation requires a configured repo.`, + ); + return; + } + githubRepo = repo; + } + + // Post acknowledgment + const batchNote = batch > 1 ? ` (${batch} videos)` : ""; + const lipsyncNote = lipsync ? " with lipsync" : ""; + await thread.post( + `Generating content for **${artistSlug}**${batchNote}${lipsyncNote}... Template: \`${template}\`. I'll reply here when ready (~5-10 min).`, + ); + + // Trigger content creation + const payload = { + accountId: artistAccountId, + artistSlug, + template, + lipsync, + captionLength: "short" as const, + upscale: false, + githubRepo, + }; + + const results = await Promise.allSettled( + Array.from({ length: batch }, () => triggerCreateContent(payload)), + ); + const runIds = results + .filter(r => r.status === "fulfilled") + .map(r => (r as PromiseFulfilledResult<{ id: string }>).value.id); + + if (runIds.length === 0) { + await thread.post("Failed to trigger content creation. Please try again."); + return; + } + + // Set thread state + await thread.setState({ + status: "running", + artistAccountId, + template, + lipsync, + batch, + runIds, + }); + + // Trigger polling task + await triggerPollContentRun({ + runIds, + callbackThreadId: thread.id, + }); + } catch (error) { + console.error("[content-agent] onNewMention error:", error); + await thread.post("Something went wrong starting content generation. Please try again."); + } + }); +} diff --git a/lib/content-agent/handlers/registerHandlers.ts b/lib/content-agent/handlers/registerHandlers.ts new file mode 100644 index 00000000..689df791 --- /dev/null +++ b/lib/content-agent/handlers/registerHandlers.ts @@ -0,0 +1,10 @@ +import { contentAgentBot } from "../bot"; +import { registerOnNewMention } from "./handleContentAgentMention"; +import { registerOnSubscribedMessage } from "./handleContentAgentCallback"; + +/** + * Registers all content agent event handlers on the bot singleton. + * Import this file once to attach handlers to the bot. + */ +registerOnNewMention(contentAgentBot); +registerOnSubscribedMessage(contentAgentBot); diff --git a/lib/content-agent/types.ts b/lib/content-agent/types.ts new file mode 100644 index 00000000..bae35862 --- /dev/null +++ b/lib/content-agent/types.ts @@ -0,0 +1,12 @@ +/** + * Thread state for the content agent bot. + * Stored in Redis via Chat SDK's state adapter. + */ +export interface ContentAgentThreadState { + status: "running" | "completed" | "failed" | "timeout"; + artistAccountId: string; + template: string; + lipsync: boolean; + batch: number; + runIds: string[]; +} diff --git a/lib/content-agent/validateContentAgentCallback.ts b/lib/content-agent/validateContentAgentCallback.ts new file mode 100644 index 00000000..73fd6d9c --- /dev/null +++ b/lib/content-agent/validateContentAgentCallback.ts @@ -0,0 +1,49 @@ +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { z } from "zod"; + +const contentRunResultSchema = z.object({ + runId: z.string(), + status: z.enum(["completed", "failed", "timeout"]), + videoUrl: z.string().optional(), + captionText: z.string().optional(), + error: z.string().optional(), +}); + +export const contentAgentCallbackSchema = z.object({ + threadId: z.string({ message: "threadId is required" }).min(1, "threadId cannot be empty"), + status: z.enum(["completed", "failed", "timeout"]), + results: z.array(contentRunResultSchema).optional(), + message: z.string().optional(), +}); + +export type ContentAgentCallbackBody = z.infer; + +/** + * Validates the content agent callback body against the expected schema. + * + * @param body - The parsed JSON body of the callback request. + * @returns A NextResponse with an error if validation fails, or the validated body. + */ +export function validateContentAgentCallback( + body: unknown, +): NextResponse | ContentAgentCallbackBody { + const result = contentAgentCallbackSchema.safeParse(body); + + if (!result.success) { + const firstError = result.error.issues[0]; + return NextResponse.json( + { + status: "error", + missing_fields: firstError.path, + error: firstError.message, + }, + { + status: 400, + headers: getCorsHeaders(), + }, + ); + } + + return result.data; +} diff --git a/lib/content-agent/validateEnv.ts b/lib/content-agent/validateEnv.ts new file mode 100644 index 00000000..551b0bb4 --- /dev/null +++ b/lib/content-agent/validateEnv.ts @@ -0,0 +1,19 @@ +const REQUIRED_ENV_VARS = [ + "SLACK_CONTENT_BOT_TOKEN", + "SLACK_CONTENT_SIGNING_SECRET", + "CONTENT_AGENT_CALLBACK_SECRET", + "REDIS_URL", +] as const; + +/** + * Validates that all required environment variables for the content agent are set. + * Throws an error listing all missing variables. + */ +export function validateContentAgentEnv(): void { + const missing = REQUIRED_ENV_VARS.filter(name => !process.env[name]); + if (missing.length > 0) { + throw new Error( + `[content-agent] Missing required environment variables:\n${missing.map(v => ` - ${v}`).join("\n")}`, + ); + } +} diff --git a/lib/trigger/triggerPollContentRun.ts b/lib/trigger/triggerPollContentRun.ts new file mode 100644 index 00000000..f6b1d8a8 --- /dev/null +++ b/lib/trigger/triggerPollContentRun.ts @@ -0,0 +1,18 @@ +import { tasks } from "@trigger.dev/sdk"; + +type PollContentRunPayload = { + runIds: string[]; + callbackThreadId: string; +}; + +/** + * Triggers the poll-content-run task to monitor content creation runs + * and post results back to the Slack thread via callback. + * + * @param payload - The run IDs to poll and the callback thread ID + * @returns The task handle with runId + */ +export async function triggerPollContentRun(payload: PollContentRunPayload) { + const handle = await tasks.trigger("poll-content-run", payload); + return handle; +}