diff --git a/lib/tasks/__tests__/enrichTaskWithTriggerInfo.test.ts b/lib/tasks/__tests__/enrichTaskWithTriggerInfo.test.ts deleted file mode 100644 index 60d38a96..00000000 --- a/lib/tasks/__tests__/enrichTaskWithTriggerInfo.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { enrichTaskWithTriggerInfo } from "../enrichTaskWithTriggerInfo"; - -vi.mock("@/lib/trigger/fetchTriggerRuns", () => ({ - fetchTriggerRuns: vi.fn(), -})); - -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", - prompt: "Do something", - schedule: "0 9 * * *", - account_id: "account-456", - artist_account_id: "artist-789", - trigger_schedule_id: "sched_abc", - enabled: true, - created_at: "2026-01-01T00:00:00Z", - next_run: null, - last_run: null, - model: null, - timezone: null, -} as Parameters[0]; - -const mockRun = { - id: "run_xyz", - status: "COMPLETED", - createdAt: "2026-03-20T09:00:00.000Z", - startedAt: "2026-03-20T09:00:01.000Z", - finishedAt: "2026-03-20T09:01:00.000Z", - durationMs: 59000, -}; - -describe("enrichTaskWithTriggerInfo", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("returns recent_runs and upcoming from Trigger.dev", async () => { - vi.mocked(fetchTriggerRuns).mockResolvedValue([mockRun] as never); - vi.mocked(retrieveTaskRun).mockResolvedValue({ - ...mockRun, - payload: { - upcoming: ["2026-03-27T09:00:00Z", "2026-04-03T09:00:00Z"], - }, - } as never); - - const result = await enrichTaskWithTriggerInfo(mockTask); - - expect(result.recent_runs).toHaveLength(1); - expect(result.recent_runs[0].id).toBe("run_xyz"); - expect(result.recent_runs[0].status).toBe("COMPLETED"); - expect(result.recent_runs[0].durationMs).toBe(59000); - expect(result.upcoming).toEqual(["2026-03-27T09:00:00Z", "2026-04-03T09:00:00Z"]); - expect(fetchTriggerRuns).toHaveBeenCalledWith({ "filter[schedule]": "sched_abc" }, 5); - }); - - it("returns empty arrays when trigger_schedule_id is null", async () => { - const taskWithoutSchedule = { ...mockTask, trigger_schedule_id: null }; - - const result = await enrichTaskWithTriggerInfo(taskWithoutSchedule); - - expect(result.recent_runs).toEqual([]); - expect(result.upcoming).toEqual([]); - expect(fetchTriggerRuns).not.toHaveBeenCalled(); - }); - - it("returns empty upcoming when no runs exist", async () => { - vi.mocked(fetchTriggerRuns).mockResolvedValue([] as never); - - const result = await enrichTaskWithTriggerInfo(mockTask); - - expect(result.recent_runs).toEqual([]); - expect(result.upcoming).toEqual([]); - expect(retrieveTaskRun).not.toHaveBeenCalled(); - }); - - it("returns empty arrays when Trigger.dev API fails", async () => { - vi.mocked(fetchTriggerRuns).mockRejectedValue(new Error("API error")); - - const result = await enrichTaskWithTriggerInfo(mockTask); - - expect(result.recent_runs).toEqual([]); - expect(result.upcoming).toEqual([]); - }); - - it("returns runs but empty upcoming when payload has no upcoming", async () => { - vi.mocked(fetchTriggerRuns).mockResolvedValue([mockRun] as never); - vi.mocked(retrieveTaskRun).mockResolvedValue({ - ...mockRun, - payload: {}, - } as never); - - const result = await enrichTaskWithTriggerInfo(mockTask); - - expect(result.recent_runs).toHaveLength(1); - expect(result.upcoming).toEqual([]); - }); -}); diff --git a/lib/tasks/__tests__/enrichTasks.test.ts b/lib/tasks/__tests__/enrichTasks.test.ts new file mode 100644 index 00000000..fde0daad --- /dev/null +++ b/lib/tasks/__tests__/enrichTasks.test.ts @@ -0,0 +1,130 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { enrichTasks } from "../enrichTasks"; + +vi.mock("@/lib/trigger/fetchTriggerRuns", () => ({ + fetchTriggerRuns: vi.fn(), +})); + +vi.mock("@/lib/trigger/retrieveTaskRun", () => ({ + retrieveTaskRun: vi.fn(), +})); + +vi.mock("@/lib/supabase/account_emails/selectAccountEmails", () => ({ + default: vi.fn(), +})); + +import { fetchTriggerRuns } from "@/lib/trigger/fetchTriggerRuns"; +import { retrieveTaskRun } from "@/lib/trigger/retrieveTaskRun"; +import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmails"; + +const mockTask = { + id: "task-123", + title: "Test Task", + prompt: "Do something", + schedule: "0 9 * * *", + account_id: "account-456", + artist_account_id: "artist-789", + trigger_schedule_id: "sched_abc", + enabled: true, + created_at: "2026-01-01T00:00:00Z", + next_run: null, + last_run: null, + model: null, + updated_at: null, +} as Parameters[0][number]; + +const mockRun = { + id: "run_xyz", + status: "COMPLETED", + createdAt: "2026-03-20T09:00:00.000Z", + startedAt: "2026-03-20T09:00:01.000Z", + finishedAt: "2026-03-20T09:01:00.000Z", + durationMs: 59000, +}; + +describe("enrichTasks", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns recent_runs, upcoming, and owner_email", async () => { + vi.mocked(fetchTriggerRuns).mockResolvedValue([mockRun] as never); + vi.mocked(retrieveTaskRun).mockResolvedValue({ + ...mockRun, + payload: { + upcoming: ["2026-03-27T09:00:00Z", "2026-04-03T09:00:00Z"], + }, + } as never); + vi.mocked(selectAccountEmails).mockResolvedValue([ + { + id: "email-1", + account_id: "account-456", + email: "owner@example.com", + updated_at: "2026-01-01T00:00:00Z", + }, + ]); + + const result = await enrichTasks([mockTask]); + + expect(result).toEqual([ + { + ...mockTask, + recent_runs: [mockRun], + upcoming: ["2026-03-27T09:00:00Z", "2026-04-03T09:00:00Z"], + owner_email: "owner@example.com", + }, + ]); + expect(fetchTriggerRuns).toHaveBeenCalledWith({ "filter[schedule]": "sched_abc" }, 5); + expect(selectAccountEmails).toHaveBeenCalledWith({ accountIds: ["account-456"] }); + }); + + it("returns empty trigger fields and null owner_email when no schedule or email exists", async () => { + vi.mocked(selectAccountEmails).mockResolvedValue([]); + + const result = await enrichTasks([{ ...mockTask, trigger_schedule_id: null }]); + + expect(result).toEqual([ + { + ...mockTask, + trigger_schedule_id: null, + recent_runs: [], + upcoming: [], + owner_email: null, + }, + ]); + expect(fetchTriggerRuns).not.toHaveBeenCalled(); + }); + + it("returns empty trigger enrichment when Trigger.dev fails", async () => { + vi.mocked(fetchTriggerRuns).mockRejectedValue(new Error("API error")); + vi.mocked(selectAccountEmails).mockResolvedValue([]); + + const result = await enrichTasks([mockTask]); + + expect(result).toEqual([ + { + ...mockTask, + recent_runs: [], + upcoming: [], + owner_email: null, + }, + ]); + }); + + it("returns empty upcoming when no runs exist", async () => { + vi.mocked(fetchTriggerRuns).mockResolvedValue([] as never); + vi.mocked(selectAccountEmails).mockResolvedValue([]); + + const result = await enrichTasks([mockTask]); + + expect(result).toEqual([ + { + ...mockTask, + recent_runs: [], + upcoming: [], + owner_email: null, + }, + ]); + expect(retrieveTaskRun).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/tasks/__tests__/getTasksHandler.test.ts b/lib/tasks/__tests__/getTasksHandler.test.ts new file mode 100644 index 00000000..c2b900e1 --- /dev/null +++ b/lib/tasks/__tests__/getTasksHandler.test.ts @@ -0,0 +1,124 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { getTasksHandler } from "@/lib/tasks/getTasksHandler"; +import { validateGetTasksQuery } from "@/lib/tasks/validateGetTasksQuery"; +import { selectScheduledActions } from "@/lib/supabase/scheduled_actions/selectScheduledActions"; +import { enrichTasks } from "@/lib/tasks/enrichTasks"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("@/lib/tasks/validateGetTasksQuery", () => ({ + validateGetTasksQuery: vi.fn(), +})); + +vi.mock("@/lib/supabase/scheduled_actions/selectScheduledActions", () => ({ + selectScheduledActions: vi.fn(), +})); + +vi.mock("@/lib/tasks/enrichTasks", () => ({ + enrichTasks: vi.fn(), +})); + +describe("getTasksHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns enriched tasks with owner_email", async () => { + const validatedQuery = { + account_id: "owner-1", + artist_account_id: "artist-1", + }; + + const tasks = [ + { + id: "task-1", + account_id: "owner-1", + artist_account_id: "artist-1", + created_at: null, + enabled: true, + last_run: null, + model: null, + next_run: null, + prompt: "prompt 1", + schedule: "0 9 * * *", + title: "Task One", + trigger_schedule_id: null, + updated_at: null, + }, + { + id: "task-2", + account_id: "owner-2", + artist_account_id: "artist-1", + created_at: null, + enabled: true, + last_run: null, + model: null, + next_run: null, + prompt: "prompt 2", + schedule: "0 10 * * *", + title: "Task Two", + trigger_schedule_id: null, + updated_at: null, + }, + ]; + + vi.mocked(validateGetTasksQuery).mockResolvedValue(validatedQuery); + vi.mocked(selectScheduledActions).mockResolvedValue(tasks); + vi.mocked(enrichTasks).mockResolvedValue([ + { + ...tasks[0], + recent_runs: [], + upcoming: [], + owner_email: "owner1@example.com", + }, + { + ...tasks[1], + recent_runs: [], + upcoming: [], + owner_email: null, + }, + ]); + + const request = new NextRequest("http://localhost:3000/api/tasks"); + const response = await getTasksHandler(request); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(enrichTasks).toHaveBeenCalledWith(tasks); + expect(body).toEqual({ + status: "success", + tasks: [ + { + ...tasks[0], + recent_runs: [], + upcoming: [], + owner_email: "owner1@example.com", + }, + { + ...tasks[1], + recent_runs: [], + upcoming: [], + owner_email: null, + }, + ], + }); + }); + + it("returns validator errors directly", async () => { + const errorResponse = NextResponse.json( + { status: "error", error: "Unauthorized" }, + { status: 401 }, + ); + vi.mocked(validateGetTasksQuery).mockResolvedValue(errorResponse); + + const request = new NextRequest("http://localhost:3000/api/tasks"); + const response = await getTasksHandler(request); + + expect(response).toBe(errorResponse); + expect(selectScheduledActions).not.toHaveBeenCalled(); + expect(enrichTasks).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/tasks/enrichTaskWithTriggerInfo.ts b/lib/tasks/enrichTaskWithTriggerInfo.ts deleted file mode 100644 index 42a4870f..00000000 --- a/lib/tasks/enrichTaskWithTriggerInfo.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { fetchTriggerRuns, type TriggerRun } from "@/lib/trigger/fetchTriggerRuns"; -import { retrieveTaskRun } from "@/lib/trigger/retrieveTaskRun"; -import type { Tables } from "@/types/database.types"; - -type ScheduledAction = Tables<"scheduled_actions">; - -interface EnrichedTask extends ScheduledAction { - recent_runs: TriggerRun[]; - upcoming: string[]; -} - -/** - * Enriches a scheduled action with recent runs and upcoming schedule times - * from the Trigger.dev API. - * - * @param task - The scheduled action from the database - * @returns The task with recent_runs and upcoming fields added - */ -export async function enrichTaskWithTriggerInfo(task: ScheduledAction): Promise { - const scheduleId = task.trigger_schedule_id; - - if (!scheduleId) { - return { ...task, recent_runs: [], upcoming: [] }; - } - - try { - const recentRuns = await fetchTriggerRuns({ "filter[schedule]": scheduleId }, 5); - - let upcoming: string[] = []; - - const latestRun = recentRuns[0]; - if (latestRun) { - try { - const fullRun = await retrieveTaskRun(latestRun.id); - const payload = fullRun?.payload as { upcoming?: unknown[] } | undefined; - if (Array.isArray(payload?.upcoming)) { - upcoming = payload.upcoming.filter((item): item is string => typeof item === "string"); - } - } catch { - // payload retrieval failed — skip upcoming - } - } - - return { ...task, recent_runs: recentRuns, upcoming }; - } catch { - // Trigger.dev API failed — return task without enrichment - return { ...task, recent_runs: [], upcoming: [] }; - } -} diff --git a/lib/tasks/enrichTasks.ts b/lib/tasks/enrichTasks.ts new file mode 100644 index 00000000..e74df550 --- /dev/null +++ b/lib/tasks/enrichTasks.ts @@ -0,0 +1,86 @@ +import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmails"; +import { fetchTriggerRuns, type TriggerRun } from "@/lib/trigger/fetchTriggerRuns"; +import { retrieveTaskRun } from "@/lib/trigger/retrieveTaskRun"; +import type { Tables } from "@/types/database.types"; + +type ScheduledAction = Tables<"scheduled_actions">; + +export type EnrichedTask = ScheduledAction & { + recent_runs: TriggerRun[]; + upcoming: string[]; + owner_email: string | null; +}; + +interface TriggerInfo { + recent_runs: TriggerRun[]; + upcoming: string[]; +} + +type TriggerInfoEntry = readonly [string, TriggerInfo]; + +/** + * Enriches tasks with Trigger.dev metadata and owner email. + * + * @param tasks - Scheduled actions to enrich + * @returns Enriched task rows for API responses + */ +export async function enrichTasks(tasks: ScheduledAction[]): Promise { + const triggerInfoEntriesPromise: Promise = Promise.all( + tasks.map(async (task): Promise => { + const scheduleId = task.trigger_schedule_id; + + if (!scheduleId) { + return [task.id, { recent_runs: [], upcoming: [] }] as const; + } + + try { + const recentRuns = await fetchTriggerRuns({ "filter[schedule]": scheduleId }, 5); + + let upcoming: string[] = []; + + const latestRun = recentRuns[0]; + if (latestRun) { + try { + const fullRun = await retrieveTaskRun(latestRun.id); + const payload = fullRun?.payload as { upcoming?: unknown[] } | undefined; + if (Array.isArray(payload?.upcoming)) { + upcoming = payload.upcoming.filter( + (item): item is string => typeof item === "string", + ); + } + } catch { + // payload retrieval failed — skip upcoming + } + } + + return [task.id, { recent_runs: recentRuns, upcoming }] as const; + } catch { + // Trigger.dev API failed — return task without trigger enrichment + return [task.id, { recent_runs: [], upcoming: [] }] as const; + } + }), + ); + + const [triggerInfoEntries, accountEmails] = await Promise.all([ + triggerInfoEntriesPromise, + selectAccountEmails({ + accountIds: [...new Set(tasks.map(task => task.account_id))], + }), + ]); + + const emailByAccountId = new Map( + accountEmails.flatMap(accountEmail => + accountEmail.account_id && accountEmail.email + ? [[accountEmail.account_id, accountEmail.email] as const] + : [], + ), + ); + + const triggerInfoMap = new Map(triggerInfoEntries); + + return tasks.map(task => ({ + ...task, + ...triggerInfoMap.get(task.id)!, + owner_email: emailByAccountId.get(task.account_id) ?? null, + })); +} diff --git a/lib/tasks/getTasksHandler.ts b/lib/tasks/getTasksHandler.ts index 508263df..66f8b8dd 100644 --- a/lib/tasks/getTasksHandler.ts +++ b/lib/tasks/getTasksHandler.ts @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { selectScheduledActions } from "@/lib/supabase/scheduled_actions/selectScheduledActions"; import { validateGetTasksQuery } from "@/lib/tasks/validateGetTasksQuery"; -import { enrichTaskWithTriggerInfo } from "@/lib/tasks/enrichTaskWithTriggerInfo"; +import { enrichTasks } from "@/lib/tasks/enrichTasks"; /** * Retrieves tasks (scheduled actions) from the database, enriched with @@ -19,8 +19,7 @@ export async function getTasksHandler(request: NextRequest): Promise enrichTaskWithTriggerInfo(task))); + const enrichedTasks = await enrichTasks(tasks); return NextResponse.json( {