From 8caa0c9362f8ee13560262ae4f83330dce3dfd79 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 15 Jan 2026 21:27:45 -0500 Subject: [PATCH 01/23] fix: make organizationId validation lenient instead of rejecting When a user provides an organizationId they don't have access to (e.g., stale localStorage value), ignore it instead of returning 403. This prevents blocking chat requests due to stale org selections. The orgId will be null in this case, and a warning is logged. Co-Authored-By: Claude Opus 4.5 --- .../__tests__/validateChatRequest.test.ts | 9 ++++---- lib/chat/validateChatRequest.ts | 21 +++++++------------ 2 files changed, 12 insertions(+), 18 deletions(-) diff --git a/lib/chat/__tests__/validateChatRequest.test.ts b/lib/chat/__tests__/validateChatRequest.test.ts index b15ebf4e..3848e20e 100644 --- a/lib/chat/__tests__/validateChatRequest.test.ts +++ b/lib/chat/__tests__/validateChatRequest.test.ts @@ -494,7 +494,7 @@ describe("validateChatRequest", () => { expect((result as any).orgId).toBe("different-org-456"); }); - it("rejects organizationId when user is NOT a member of org", async () => { + it("ignores organizationId when user is NOT a member of org (lenient behavior)", async () => { mockGetAuthenticatedAccountId.mockResolvedValue("user-account-123"); mockValidateOrganizationAccess.mockResolvedValue(false); @@ -505,10 +505,9 @@ describe("validateChatRequest", () => { const result = await validateChatRequest(request as any); - expect(result).toBeInstanceOf(NextResponse); - const json = await (result as NextResponse).json(); - expect(json.status).toBe("error"); - expect(json.message).toBe("Access denied to specified organizationId"); + // Should succeed but with orgId: null (ignores invalid org) + expect(result).not.toBeInstanceOf(NextResponse); + expect((result as any).orgId).toBeNull(); }); it("uses API key orgId when no organizationId is provided", async () => { diff --git a/lib/chat/validateChatRequest.ts b/lib/chat/validateChatRequest.ts index 5805dba2..6f19bd15 100644 --- a/lib/chat/validateChatRequest.ts +++ b/lib/chat/validateChatRequest.ts @@ -147,21 +147,16 @@ export async function validateChatRequest( organizationId: validatedBody.organizationId, }); - if (!hasOrgAccess) { - return NextResponse.json( - { - status: "error", - message: "Access denied to specified organizationId", - }, - { - status: 403, - headers: getCorsHeaders(), - }, + if (hasOrgAccess) { + // Use the provided organizationId as orgId + orgId = validatedBody.organizationId; + } else { + // User doesn't have access to the specified org - ignore the invalid organizationId + // This can happen when users have stale org selections in localStorage + console.warn( + `[validateChatRequest] Account ${accountId} does not have access to org ${validatedBody.organizationId}, ignoring`, ); } - - // Use the provided organizationId as orgId - orgId = validatedBody.organizationId; } // Normalize chat content: From fc4039ffdd1edfb07798d4acfe729deee892d31c Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Fri, 16 Jan 2026 11:36:03 -0500 Subject: [PATCH 02/23] feat: add Bearer token auth support to createChatHandler - Support both x-api-key and Authorization Bearer token for /api/chats endpoint - Enforce exactly one auth mechanism (same pattern as /api/chat) - Add 4 new tests for Bearer token authentication - Enable frontend clients to call this endpoint directly with Privy JWT Part of chat/create migration from Recoup-Chat to recoup-api. Co-Authored-By: Claude Opus 4.5 --- lib/chats/__tests__/createChatHandler.test.ts | 153 +++++++++++++++++- lib/chats/createChatHandler.ts | 47 +++++- 2 files changed, 191 insertions(+), 9 deletions(-) diff --git a/lib/chats/__tests__/createChatHandler.test.ts b/lib/chats/__tests__/createChatHandler.test.ts index e05b0bdc..e2d65584 100644 --- a/lib/chats/__tests__/createChatHandler.test.ts +++ b/lib/chats/__tests__/createChatHandler.test.ts @@ -3,6 +3,7 @@ import { NextRequest, NextResponse } from "next/server"; import { createChatHandler } from "../createChatHandler"; import { getApiKeyAccountId } from "@/lib/auth/getApiKeyAccountId"; +import { getAuthenticatedAccountId } from "@/lib/auth/getAuthenticatedAccountId"; import { validateOverrideAccountId } from "@/lib/accounts/validateOverrideAccountId"; import { insertRoom } from "@/lib/supabase/rooms/insertRoom"; import { safeParseJson } from "@/lib/networking/safeParseJson"; @@ -13,6 +14,10 @@ vi.mock("@/lib/auth/getApiKeyAccountId", () => ({ getApiKeyAccountId: vi.fn(), })); +vi.mock("@/lib/auth/getAuthenticatedAccountId", () => ({ + getAuthenticatedAccountId: vi.fn(), +})); + vi.mock("@/lib/accounts/validateOverrideAccountId", () => ({ validateOverrideAccountId: vi.fn(), })); @@ -38,8 +43,7 @@ vi.mock("../generateChatTitle", () => ({ })); /** - * - * @param apiKey + * Creates a mock request with API key auth */ function createMockRequest(apiKey = "test-api-key"): NextRequest { return { @@ -49,6 +53,46 @@ function createMockRequest(apiKey = "test-api-key"): NextRequest { } as unknown as NextRequest; } +/** + * Creates a mock request with Bearer token auth + */ +function createMockBearerRequest(token = "test-bearer-token"): NextRequest { + return { + headers: { + get: (name: string) => (name === "authorization" ? `Bearer ${token}` : null), + }, + } as unknown as NextRequest; +} + +/** + * Creates a mock request with no auth + */ +function createMockNoAuthRequest(): NextRequest { + return { + headers: { + get: () => null, + }, + } as unknown as NextRequest; +} + +/** + * Creates a mock request with both auth mechanisms + */ +function createMockBothAuthRequest( + apiKey = "test-api-key", + token = "test-bearer-token", +): NextRequest { + return { + headers: { + get: (name: string) => { + if (name === "x-api-key") return apiKey; + if (name === "authorization") return `Bearer ${token}`; + return null; + }, + }, + } as unknown as NextRequest; +} + describe("createChatHandler", () => { beforeEach(() => { vi.clearAllMocks(); @@ -270,4 +314,109 @@ describe("createChatHandler", () => { }); }); }); + + describe("with Bearer token authentication", () => { + it("uses Bearer token's accountId when no API key provided", async () => { + const bearerAccountId = "bearer-account-123"; + const artistId = "123e4567-e89b-12d3-a456-426614174000"; + + vi.mocked(getAuthenticatedAccountId).mockResolvedValue(bearerAccountId); + vi.mocked(safeParseJson).mockResolvedValue({ artistId }); + vi.mocked(insertRoom).mockResolvedValue({ + id: "generated-uuid-123", + account_id: bearerAccountId, + artist_id: artistId, + topic: null, + }); + + const request = createMockBearerRequest(); + const response = await createChatHandler(request); + const json = await response.json(); + + expect(response.status).toBe(200); + expect(json.status).toBe("success"); + expect(getApiKeyAccountId).not.toHaveBeenCalled(); + expect(getAuthenticatedAccountId).toHaveBeenCalledWith(request); + expect(insertRoom).toHaveBeenCalledWith({ + id: "generated-uuid-123", + account_id: bearerAccountId, + artist_id: artistId, + topic: null, + }); + }); + + it("returns 401 when Bearer token validation fails", async () => { + vi.mocked(getAuthenticatedAccountId).mockResolvedValue( + NextResponse.json( + { status: "error", message: "Invalid token" }, + { status: 401 }, + ), + ); + + const request = createMockBearerRequest(); + const response = await createChatHandler(request); + const json = await response.json(); + + expect(response.status).toBe(401); + expect(json.status).toBe("error"); + expect(json.message).toBe("Invalid token"); + expect(insertRoom).not.toHaveBeenCalled(); + }); + + it("generates title from firstMessage with Bearer auth", async () => { + const bearerAccountId = "bearer-account-123"; + const artistId = "123e4567-e89b-12d3-a456-426614174000"; + const firstMessage = "Help me with my marketing"; + const generatedTitle = "Marketing Help"; + + vi.mocked(getAuthenticatedAccountId).mockResolvedValue(bearerAccountId); + vi.mocked(safeParseJson).mockResolvedValue({ + artistId, + firstMessage, + }); + vi.mocked(generateChatTitle).mockResolvedValue(generatedTitle); + vi.mocked(insertRoom).mockResolvedValue({ + id: "generated-uuid-123", + account_id: bearerAccountId, + artist_id: artistId, + topic: generatedTitle, + }); + + const request = createMockBearerRequest(); + const response = await createChatHandler(request); + const json = await response.json(); + + expect(response.status).toBe(200); + expect(json.status).toBe("success"); + expect(generateChatTitle).toHaveBeenCalledWith(firstMessage); + }); + }); + + describe("auth mechanism enforcement", () => { + it("returns 401 when no auth is provided", async () => { + const request = createMockNoAuthRequest(); + const response = await createChatHandler(request); + const json = await response.json(); + + expect(response.status).toBe(401); + expect(json.status).toBe("error"); + expect(json.message).toBe( + "Exactly one of x-api-key or Authorization must be provided", + ); + expect(insertRoom).not.toHaveBeenCalled(); + }); + + it("returns 401 when both auth mechanisms are provided", async () => { + const request = createMockBothAuthRequest(); + const response = await createChatHandler(request); + const json = await response.json(); + + expect(response.status).toBe(401); + expect(json.status).toBe("error"); + expect(json.message).toBe( + "Exactly one of x-api-key or Authorization must be provided", + ); + expect(insertRoom).not.toHaveBeenCalled(); + }); + }); }); diff --git a/lib/chats/createChatHandler.ts b/lib/chats/createChatHandler.ts index 584b5f8f..53e156b8 100644 --- a/lib/chats/createChatHandler.ts +++ b/lib/chats/createChatHandler.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { getApiKeyAccountId } from "@/lib/auth/getApiKeyAccountId"; +import { getAuthenticatedAccountId } from "@/lib/auth/getAuthenticatedAccountId"; import { validateOverrideAccountId } from "@/lib/accounts/validateOverrideAccountId"; import { insertRoom } from "@/lib/supabase/rooms/insertRoom"; import { generateUUID } from "@/lib/uuid/generateUUID"; @@ -11,21 +12,53 @@ import { generateChatTitle } from "@/lib/chats/generateChatTitle"; /** * Handler for creating a new chat room. * - * Requires authentication via x-api-key header. - * The account ID is inferred from the API key, unless an accountId is provided - * in the request body by an organization API key with access to that account. + * Requires authentication via x-api-key header OR Authorization Bearer token. + * Exactly one authentication mechanism must be provided. + * The account ID is inferred from the API key or Bearer token, unless an accountId + * is provided in the request body by an organization API key with access to that account. * * @param request - The NextRequest object * @returns A NextResponse with the created chat or an error */ export async function createChatHandler(request: NextRequest): Promise { try { - const accountIdOrError = await getApiKeyAccountId(request); - if (accountIdOrError instanceof NextResponse) { - return accountIdOrError; + // Check which auth mechanism is provided + const apiKey = request.headers.get("x-api-key"); + const authHeader = request.headers.get("authorization"); + const hasApiKey = !!apiKey; + const hasAuth = !!authHeader; + + // Enforce that exactly one auth mechanism is provided + if ((hasApiKey && hasAuth) || (!hasApiKey && !hasAuth)) { + return NextResponse.json( + { + status: "error", + message: "Exactly one of x-api-key or Authorization must be provided", + }, + { + status: 401, + headers: getCorsHeaders(), + }, + ); } - let accountId = accountIdOrError; + let accountId: string; + + if (hasApiKey) { + // API key authentication + const accountIdOrError = await getApiKeyAccountId(request); + if (accountIdOrError instanceof NextResponse) { + return accountIdOrError; + } + accountId = accountIdOrError; + } else { + // Bearer token authentication + const accountIdOrError = await getAuthenticatedAccountId(request); + if (accountIdOrError instanceof NextResponse) { + return accountIdOrError; + } + accountId = accountIdOrError; + } const body = await safeParseJson(request); From 0b726f3a7f883a239910df5c395c1aa2ebd8c3c9 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Fri, 16 Jan 2026 11:55:53 -0500 Subject: [PATCH 03/23] feat: add /api/artist-agents endpoint for fetching artist agents Add new endpoint to fetch artist agents by social IDs with Bearer token and API key authentication support. - GET /api/artist-agents?socialId=xxx&socialId=yyy - Supports Bearer token auth (for frontend clients) - Supports x-api-key auth (for API consumers) - Returns aggregated agents by platform type Co-Authored-By: Claude Opus 4.5 --- app/api/artist-agents/route.ts | 34 +++ .../__tests__/getArtistAgentsHandler.test.ts | 200 ++++++++++++++++++ lib/artistAgents/getArtistAgents.ts | 62 ++++++ lib/artistAgents/getArtistAgentsHandler.ts | 100 +++++++++ lib/artistAgents/getSocialPlatformByLink.ts | 21 ++ 5 files changed, 417 insertions(+) create mode 100644 app/api/artist-agents/route.ts create mode 100644 lib/artistAgents/__tests__/getArtistAgentsHandler.test.ts create mode 100644 lib/artistAgents/getArtistAgents.ts create mode 100644 lib/artistAgents/getArtistAgentsHandler.ts create mode 100644 lib/artistAgents/getSocialPlatformByLink.ts diff --git a/app/api/artist-agents/route.ts b/app/api/artist-agents/route.ts new file mode 100644 index 00000000..4f1ecb51 --- /dev/null +++ b/app/api/artist-agents/route.ts @@ -0,0 +1,34 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getArtistAgentsHandler } from "@/lib/artistAgents/getArtistAgentsHandler"; + +/** + * OPTIONS handler for CORS preflight requests. + * + * @returns A NextResponse with CORS headers. + */ +export async function OPTIONS() { + return new NextResponse(null, { + status: 200, + headers: getCorsHeaders(), + }); +} + +/** + * GET /api/artist-agents + * + * Fetch artist agents by social IDs. + * + * Authentication: x-api-key header OR Authorization Bearer token required. + * Exactly one authentication mechanism must be provided. + * + * Query parameters: + * - socialId: One or more social IDs (can be repeated, e.g., ?socialId=123&socialId=456) + * + * @param request - The request object + * @returns A NextResponse with the agents array or an error + */ +export async function GET(request: NextRequest): Promise { + return getArtistAgentsHandler(request); +} diff --git a/lib/artistAgents/__tests__/getArtistAgentsHandler.test.ts b/lib/artistAgents/__tests__/getArtistAgentsHandler.test.ts new file mode 100644 index 00000000..4db45328 --- /dev/null +++ b/lib/artistAgents/__tests__/getArtistAgentsHandler.test.ts @@ -0,0 +1,200 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { getArtistAgentsHandler } from "../getArtistAgentsHandler"; + +import { getApiKeyAccountId } from "@/lib/auth/getApiKeyAccountId"; +import { getAuthenticatedAccountId } from "@/lib/auth/getAuthenticatedAccountId"; +import { getArtistAgents } from "../getArtistAgents"; + +// Mock dependencies +vi.mock("@/lib/auth/getApiKeyAccountId", () => ({ + getApiKeyAccountId: vi.fn(), +})); + +vi.mock("@/lib/auth/getAuthenticatedAccountId", () => ({ + getAuthenticatedAccountId: vi.fn(), +})); + +vi.mock("../getArtistAgents", () => ({ + getArtistAgents: vi.fn(), +})); + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +/** + * Creates a mock request with API key auth and socialId query params + */ +function createMockRequest( + socialIds: string[] = [], + apiKey = "test-api-key", +): NextRequest { + const url = new URL("http://localhost/api/artist-agents"); + socialIds.forEach((id) => url.searchParams.append("socialId", id)); + return { + url: url.toString(), + headers: { + get: (name: string) => (name === "x-api-key" ? apiKey : null), + }, + } as unknown as NextRequest; +} + +/** + * Creates a mock request with Bearer token auth + */ +function createMockBearerRequest( + socialIds: string[] = [], + token = "test-bearer-token", +): NextRequest { + const url = new URL("http://localhost/api/artist-agents"); + socialIds.forEach((id) => url.searchParams.append("socialId", id)); + return { + url: url.toString(), + headers: { + get: (name: string) => (name === "authorization" ? `Bearer ${token}` : null), + }, + } as unknown as NextRequest; +} + +/** + * Creates a mock request with no auth + */ +function createMockNoAuthRequest(socialIds: string[] = []): NextRequest { + const url = new URL("http://localhost/api/artist-agents"); + socialIds.forEach((id) => url.searchParams.append("socialId", id)); + return { + url: url.toString(), + headers: { + get: () => null, + }, + } as unknown as NextRequest; +} + +describe("getArtistAgentsHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("validation", () => { + it("returns 400 when no socialId is provided", async () => { + vi.mocked(getApiKeyAccountId).mockResolvedValue("account-123"); + + const request = createMockRequest([]); + const response = await getArtistAgentsHandler(request); + const json = await response.json(); + + expect(response.status).toBe(400); + expect(json.status).toBe("error"); + expect(json.message).toBe("At least one socialId is required"); + }); + }); + + describe("with API key authentication", () => { + it("returns agents for valid socialIds", async () => { + const mockAgents = [ + { type: "twitter", agentId: "agent-1", updated_at: "2024-01-01T00:00:00Z" }, + { type: "instagram", agentId: "agent-2", updated_at: "2024-01-02T00:00:00Z" }, + ]; + + vi.mocked(getApiKeyAccountId).mockResolvedValue("account-123"); + vi.mocked(getArtistAgents).mockResolvedValue(mockAgents); + + const request = createMockRequest(["social-1", "social-2"]); + const response = await getArtistAgentsHandler(request); + const json = await response.json(); + + expect(response.status).toBe(200); + expect(json.status).toBe("success"); + expect(json.agents).toEqual(mockAgents); + expect(getArtistAgents).toHaveBeenCalledWith(["social-1", "social-2"]); + }); + + it("returns 401 when API key validation fails", async () => { + vi.mocked(getApiKeyAccountId).mockResolvedValue( + NextResponse.json( + { status: "error", message: "Invalid API key" }, + { status: 401 }, + ), + ); + + const request = createMockRequest(["social-1"]); + const response = await getArtistAgentsHandler(request); + const json = await response.json(); + + expect(response.status).toBe(401); + expect(json.status).toBe("error"); + expect(json.message).toBe("Invalid API key"); + expect(getArtistAgents).not.toHaveBeenCalled(); + }); + }); + + describe("with Bearer token authentication", () => { + it("returns agents for valid socialIds with Bearer token", async () => { + const mockAgents = [ + { type: "spotify", agentId: "agent-3", updated_at: "2024-01-03T00:00:00Z" }, + ]; + + vi.mocked(getAuthenticatedAccountId).mockResolvedValue("bearer-account-123"); + vi.mocked(getArtistAgents).mockResolvedValue(mockAgents); + + const request = createMockBearerRequest(["social-1"]); + const response = await getArtistAgentsHandler(request); + const json = await response.json(); + + expect(response.status).toBe(200); + expect(json.status).toBe("success"); + expect(json.agents).toEqual(mockAgents); + expect(getApiKeyAccountId).not.toHaveBeenCalled(); + expect(getAuthenticatedAccountId).toHaveBeenCalledWith(request); + }); + + it("returns 401 when Bearer token validation fails", async () => { + vi.mocked(getAuthenticatedAccountId).mockResolvedValue( + NextResponse.json( + { status: "error", message: "Invalid token" }, + { status: 401 }, + ), + ); + + const request = createMockBearerRequest(["social-1"]); + const response = await getArtistAgentsHandler(request); + const json = await response.json(); + + expect(response.status).toBe(401); + expect(json.status).toBe("error"); + expect(json.message).toBe("Invalid token"); + expect(getArtistAgents).not.toHaveBeenCalled(); + }); + }); + + describe("auth mechanism enforcement", () => { + it("returns 401 when no auth is provided", async () => { + const request = createMockNoAuthRequest(["social-1"]); + const response = await getArtistAgentsHandler(request); + const json = await response.json(); + + expect(response.status).toBe(401); + expect(json.status).toBe("error"); + expect(json.message).toBe( + "Exactly one of x-api-key or Authorization must be provided", + ); + expect(getArtistAgents).not.toHaveBeenCalled(); + }); + }); + + describe("error handling", () => { + it("returns 500 when getArtistAgents throws", async () => { + vi.mocked(getApiKeyAccountId).mockResolvedValue("account-123"); + vi.mocked(getArtistAgents).mockRejectedValue(new Error("Database error")); + + const request = createMockRequest(["social-1"]); + const response = await getArtistAgentsHandler(request); + const json = await response.json(); + + expect(response.status).toBe(500); + expect(json.status).toBe("error"); + expect(json.message).toBe("Failed to fetch artist agents"); + }); + }); +}); diff --git a/lib/artistAgents/getArtistAgents.ts b/lib/artistAgents/getArtistAgents.ts new file mode 100644 index 00000000..d48b2f1a --- /dev/null +++ b/lib/artistAgents/getArtistAgents.ts @@ -0,0 +1,62 @@ +import supabase from "@/lib/supabase/serverClient"; +import getSocialPlatformByLink from "./getSocialPlatformByLink"; + +export interface ArtistAgent { + type: string; + agentId: string; + updated_at: string; +} + +/** + * Fetches artist agents by their social IDs. + * + * Queries the agent_status and agents tables to find agents associated with + * the given social IDs, then transforms them into a format suitable for the client. + * + * @param artistSocialIds - Array of social IDs to look up + * @returns Array of ArtistAgent objects, aggregated by type + */ +export async function getArtistAgents( + artistSocialIds: string[], +): Promise { + const { data, error } = await supabase + .from("agent_status") + .select("*, agent:agents(*)") + .in("social_id", artistSocialIds); + + if (error) { + console.error("Error fetching artist agents:", error); + return []; + } + + if (!data) return []; + + const agentIds = [...new Set(data.map((ele) => ele.agent.id))]; + + const { data: agents } = await supabase + .from("agents") + .select("*, agent_status(*, social:socials(*))") + .in("id", agentIds); + + if (!agents) return []; + + const transformedAgents = agents.map((agent) => ({ + type: new String( + agent.agent_status.length > 1 + ? "wrapped" + : getSocialPlatformByLink(agent.agent_status[0].social.profile_url), + ).toLowerCase(), + agentId: agent.id, + updated_at: agent.updated_at, + })); + + // Aggregate agents by type (latest one for each type wins) + const aggregatedAgents: Record = {}; + + transformedAgents.forEach((agent) => { + const type = agent.type.toLowerCase(); + aggregatedAgents[type] = agent; + }); + + return Object.values(aggregatedAgents); +} diff --git a/lib/artistAgents/getArtistAgentsHandler.ts b/lib/artistAgents/getArtistAgentsHandler.ts new file mode 100644 index 00000000..c8f018eb --- /dev/null +++ b/lib/artistAgents/getArtistAgentsHandler.ts @@ -0,0 +1,100 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getApiKeyAccountId } from "@/lib/auth/getApiKeyAccountId"; +import { getAuthenticatedAccountId } from "@/lib/auth/getAuthenticatedAccountId"; +import { getArtistAgents } from "./getArtistAgents"; + +/** + * Handler for fetching artist agents by social IDs. + * + * Requires authentication via x-api-key header OR Authorization Bearer token. + * Exactly one authentication mechanism must be provided. + * + * Query parameters: + * - socialId: One or more social IDs (can be repeated) + * + * @param request - The NextRequest object + * @returns A NextResponse with the agents array or an error + */ +export async function getArtistAgentsHandler( + request: NextRequest, +): Promise { + try { + // Check which auth mechanism is provided + const apiKey = request.headers.get("x-api-key"); + const authHeader = request.headers.get("authorization"); + const hasApiKey = !!apiKey; + const hasAuth = !!authHeader; + + // Enforce that exactly one auth mechanism is provided + if ((hasApiKey && hasAuth) || (!hasApiKey && !hasAuth)) { + return NextResponse.json( + { + status: "error", + message: "Exactly one of x-api-key or Authorization must be provided", + }, + { + status: 401, + headers: getCorsHeaders(), + }, + ); + } + + // Authenticate + if (hasApiKey) { + const accountIdOrError = await getApiKeyAccountId(request); + if (accountIdOrError instanceof NextResponse) { + return accountIdOrError; + } + // accountId validated but not used - just ensuring auth + } else { + const accountIdOrError = await getAuthenticatedAccountId(request); + if (accountIdOrError instanceof NextResponse) { + return accountIdOrError; + } + // accountId validated but not used - just ensuring auth + } + + // Parse socialId query parameters + const { searchParams } = new URL(request.url); + const socialIds = searchParams.getAll("socialId"); + + if (!socialIds.length) { + return NextResponse.json( + { + status: "error", + message: "At least one socialId is required", + }, + { + status: 400, + headers: getCorsHeaders(), + }, + ); + } + + const agents = await getArtistAgents(socialIds); + + return NextResponse.json( + { + status: "success", + agents, + }, + { + status: 200, + headers: getCorsHeaders(), + }, + ); + } catch (error) { + console.error("[ERROR] getArtistAgentsHandler:", error); + return NextResponse.json( + { + status: "error", + message: "Failed to fetch artist agents", + }, + { + status: 500, + headers: getCorsHeaders(), + }, + ); + } +} diff --git a/lib/artistAgents/getSocialPlatformByLink.ts b/lib/artistAgents/getSocialPlatformByLink.ts new file mode 100644 index 00000000..c06e983d --- /dev/null +++ b/lib/artistAgents/getSocialPlatformByLink.ts @@ -0,0 +1,21 @@ +/** + * Determines the social media platform based on a profile URL. + * + * @param link - The social media profile URL + * @returns The platform name in uppercase (e.g., "TWITTER", "INSTAGRAM") + */ +const getSocialPlatformByLink = (link: string): string => { + if (!link) return "NONE"; + if (link.includes("x.com") || link.includes("twitter.com")) return "TWITTER"; + if (link.includes("instagram.com")) return "INSTAGRAM"; + if (link.includes("spotify.com")) return "SPOTIFY"; + if (link.includes("tiktok.com")) return "TIKTOK"; + if (link.includes("apple.com")) return "APPLE"; + if (link.includes("youtube.")) return "YOUTUBE"; + if (link.includes("facebook.com")) return "FACEBOOK"; + if (link.includes("threads.net") || link.includes("threads.com")) return "THREADS"; + + return "NONE"; +}; + +export default getSocialPlatformByLink; From d1e56757b0ca09cc04ed1e0ea9801065508e573e Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Fri, 16 Jan 2026 12:25:48 -0500 Subject: [PATCH 04/23] revert: rollback validateChatRequest lenient org behavior Reverts the changes that made organizationId validation lenient. Restores the original strict behavior that rejects requests when user is not a member of the specified organization. Co-Authored-By: Claude Opus 4.5 --- .../__tests__/validateChatRequest.test.ts | 9 ++++---- lib/chat/validateChatRequest.ts | 21 ++++++++++++------- 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/lib/chat/__tests__/validateChatRequest.test.ts b/lib/chat/__tests__/validateChatRequest.test.ts index 3848e20e..b15ebf4e 100644 --- a/lib/chat/__tests__/validateChatRequest.test.ts +++ b/lib/chat/__tests__/validateChatRequest.test.ts @@ -494,7 +494,7 @@ describe("validateChatRequest", () => { expect((result as any).orgId).toBe("different-org-456"); }); - it("ignores organizationId when user is NOT a member of org (lenient behavior)", async () => { + it("rejects organizationId when user is NOT a member of org", async () => { mockGetAuthenticatedAccountId.mockResolvedValue("user-account-123"); mockValidateOrganizationAccess.mockResolvedValue(false); @@ -505,9 +505,10 @@ describe("validateChatRequest", () => { const result = await validateChatRequest(request as any); - // Should succeed but with orgId: null (ignores invalid org) - expect(result).not.toBeInstanceOf(NextResponse); - expect((result as any).orgId).toBeNull(); + expect(result).toBeInstanceOf(NextResponse); + const json = await (result as NextResponse).json(); + expect(json.status).toBe("error"); + expect(json.message).toBe("Access denied to specified organizationId"); }); it("uses API key orgId when no organizationId is provided", async () => { diff --git a/lib/chat/validateChatRequest.ts b/lib/chat/validateChatRequest.ts index 6f19bd15..5805dba2 100644 --- a/lib/chat/validateChatRequest.ts +++ b/lib/chat/validateChatRequest.ts @@ -147,16 +147,21 @@ export async function validateChatRequest( organizationId: validatedBody.organizationId, }); - if (hasOrgAccess) { - // Use the provided organizationId as orgId - orgId = validatedBody.organizationId; - } else { - // User doesn't have access to the specified org - ignore the invalid organizationId - // This can happen when users have stale org selections in localStorage - console.warn( - `[validateChatRequest] Account ${accountId} does not have access to org ${validatedBody.organizationId}, ignoring`, + if (!hasOrgAccess) { + return NextResponse.json( + { + status: "error", + message: "Access denied to specified organizationId", + }, + { + status: 403, + headers: getCorsHeaders(), + }, ); } + + // Use the provided organizationId as orgId + orgId = validatedBody.organizationId; } // Normalize chat content: From 0fbf0a4f01d400c106fee67bef3cf9102f9553f9 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Fri, 16 Jan 2026 12:26:31 -0500 Subject: [PATCH 05/23] revert: rollback createChatHandler changes Restores the original createChatHandler implementation. Co-Authored-By: Claude Opus 4.5 --- lib/chats/__tests__/createChatHandler.test.ts | 153 +----------------- lib/chats/createChatHandler.ts | 47 +----- 2 files changed, 9 insertions(+), 191 deletions(-) diff --git a/lib/chats/__tests__/createChatHandler.test.ts b/lib/chats/__tests__/createChatHandler.test.ts index e2d65584..e05b0bdc 100644 --- a/lib/chats/__tests__/createChatHandler.test.ts +++ b/lib/chats/__tests__/createChatHandler.test.ts @@ -3,7 +3,6 @@ import { NextRequest, NextResponse } from "next/server"; import { createChatHandler } from "../createChatHandler"; import { getApiKeyAccountId } from "@/lib/auth/getApiKeyAccountId"; -import { getAuthenticatedAccountId } from "@/lib/auth/getAuthenticatedAccountId"; import { validateOverrideAccountId } from "@/lib/accounts/validateOverrideAccountId"; import { insertRoom } from "@/lib/supabase/rooms/insertRoom"; import { safeParseJson } from "@/lib/networking/safeParseJson"; @@ -14,10 +13,6 @@ vi.mock("@/lib/auth/getApiKeyAccountId", () => ({ getApiKeyAccountId: vi.fn(), })); -vi.mock("@/lib/auth/getAuthenticatedAccountId", () => ({ - getAuthenticatedAccountId: vi.fn(), -})); - vi.mock("@/lib/accounts/validateOverrideAccountId", () => ({ validateOverrideAccountId: vi.fn(), })); @@ -43,7 +38,8 @@ vi.mock("../generateChatTitle", () => ({ })); /** - * Creates a mock request with API key auth + * + * @param apiKey */ function createMockRequest(apiKey = "test-api-key"): NextRequest { return { @@ -53,46 +49,6 @@ function createMockRequest(apiKey = "test-api-key"): NextRequest { } as unknown as NextRequest; } -/** - * Creates a mock request with Bearer token auth - */ -function createMockBearerRequest(token = "test-bearer-token"): NextRequest { - return { - headers: { - get: (name: string) => (name === "authorization" ? `Bearer ${token}` : null), - }, - } as unknown as NextRequest; -} - -/** - * Creates a mock request with no auth - */ -function createMockNoAuthRequest(): NextRequest { - return { - headers: { - get: () => null, - }, - } as unknown as NextRequest; -} - -/** - * Creates a mock request with both auth mechanisms - */ -function createMockBothAuthRequest( - apiKey = "test-api-key", - token = "test-bearer-token", -): NextRequest { - return { - headers: { - get: (name: string) => { - if (name === "x-api-key") return apiKey; - if (name === "authorization") return `Bearer ${token}`; - return null; - }, - }, - } as unknown as NextRequest; -} - describe("createChatHandler", () => { beforeEach(() => { vi.clearAllMocks(); @@ -314,109 +270,4 @@ describe("createChatHandler", () => { }); }); }); - - describe("with Bearer token authentication", () => { - it("uses Bearer token's accountId when no API key provided", async () => { - const bearerAccountId = "bearer-account-123"; - const artistId = "123e4567-e89b-12d3-a456-426614174000"; - - vi.mocked(getAuthenticatedAccountId).mockResolvedValue(bearerAccountId); - vi.mocked(safeParseJson).mockResolvedValue({ artistId }); - vi.mocked(insertRoom).mockResolvedValue({ - id: "generated-uuid-123", - account_id: bearerAccountId, - artist_id: artistId, - topic: null, - }); - - const request = createMockBearerRequest(); - const response = await createChatHandler(request); - const json = await response.json(); - - expect(response.status).toBe(200); - expect(json.status).toBe("success"); - expect(getApiKeyAccountId).not.toHaveBeenCalled(); - expect(getAuthenticatedAccountId).toHaveBeenCalledWith(request); - expect(insertRoom).toHaveBeenCalledWith({ - id: "generated-uuid-123", - account_id: bearerAccountId, - artist_id: artistId, - topic: null, - }); - }); - - it("returns 401 when Bearer token validation fails", async () => { - vi.mocked(getAuthenticatedAccountId).mockResolvedValue( - NextResponse.json( - { status: "error", message: "Invalid token" }, - { status: 401 }, - ), - ); - - const request = createMockBearerRequest(); - const response = await createChatHandler(request); - const json = await response.json(); - - expect(response.status).toBe(401); - expect(json.status).toBe("error"); - expect(json.message).toBe("Invalid token"); - expect(insertRoom).not.toHaveBeenCalled(); - }); - - it("generates title from firstMessage with Bearer auth", async () => { - const bearerAccountId = "bearer-account-123"; - const artistId = "123e4567-e89b-12d3-a456-426614174000"; - const firstMessage = "Help me with my marketing"; - const generatedTitle = "Marketing Help"; - - vi.mocked(getAuthenticatedAccountId).mockResolvedValue(bearerAccountId); - vi.mocked(safeParseJson).mockResolvedValue({ - artistId, - firstMessage, - }); - vi.mocked(generateChatTitle).mockResolvedValue(generatedTitle); - vi.mocked(insertRoom).mockResolvedValue({ - id: "generated-uuid-123", - account_id: bearerAccountId, - artist_id: artistId, - topic: generatedTitle, - }); - - const request = createMockBearerRequest(); - const response = await createChatHandler(request); - const json = await response.json(); - - expect(response.status).toBe(200); - expect(json.status).toBe("success"); - expect(generateChatTitle).toHaveBeenCalledWith(firstMessage); - }); - }); - - describe("auth mechanism enforcement", () => { - it("returns 401 when no auth is provided", async () => { - const request = createMockNoAuthRequest(); - const response = await createChatHandler(request); - const json = await response.json(); - - expect(response.status).toBe(401); - expect(json.status).toBe("error"); - expect(json.message).toBe( - "Exactly one of x-api-key or Authorization must be provided", - ); - expect(insertRoom).not.toHaveBeenCalled(); - }); - - it("returns 401 when both auth mechanisms are provided", async () => { - const request = createMockBothAuthRequest(); - const response = await createChatHandler(request); - const json = await response.json(); - - expect(response.status).toBe(401); - expect(json.status).toBe("error"); - expect(json.message).toBe( - "Exactly one of x-api-key or Authorization must be provided", - ); - expect(insertRoom).not.toHaveBeenCalled(); - }); - }); }); diff --git a/lib/chats/createChatHandler.ts b/lib/chats/createChatHandler.ts index 53e156b8..584b5f8f 100644 --- a/lib/chats/createChatHandler.ts +++ b/lib/chats/createChatHandler.ts @@ -1,7 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { getApiKeyAccountId } from "@/lib/auth/getApiKeyAccountId"; -import { getAuthenticatedAccountId } from "@/lib/auth/getAuthenticatedAccountId"; import { validateOverrideAccountId } from "@/lib/accounts/validateOverrideAccountId"; import { insertRoom } from "@/lib/supabase/rooms/insertRoom"; import { generateUUID } from "@/lib/uuid/generateUUID"; @@ -12,53 +11,21 @@ import { generateChatTitle } from "@/lib/chats/generateChatTitle"; /** * Handler for creating a new chat room. * - * Requires authentication via x-api-key header OR Authorization Bearer token. - * Exactly one authentication mechanism must be provided. - * The account ID is inferred from the API key or Bearer token, unless an accountId - * is provided in the request body by an organization API key with access to that account. + * Requires authentication via x-api-key header. + * The account ID is inferred from the API key, unless an accountId is provided + * in the request body by an organization API key with access to that account. * * @param request - The NextRequest object * @returns A NextResponse with the created chat or an error */ export async function createChatHandler(request: NextRequest): Promise { try { - // Check which auth mechanism is provided - const apiKey = request.headers.get("x-api-key"); - const authHeader = request.headers.get("authorization"); - const hasApiKey = !!apiKey; - const hasAuth = !!authHeader; - - // Enforce that exactly one auth mechanism is provided - if ((hasApiKey && hasAuth) || (!hasApiKey && !hasAuth)) { - return NextResponse.json( - { - status: "error", - message: "Exactly one of x-api-key or Authorization must be provided", - }, - { - status: 401, - headers: getCorsHeaders(), - }, - ); + const accountIdOrError = await getApiKeyAccountId(request); + if (accountIdOrError instanceof NextResponse) { + return accountIdOrError; } - let accountId: string; - - if (hasApiKey) { - // API key authentication - const accountIdOrError = await getApiKeyAccountId(request); - if (accountIdOrError instanceof NextResponse) { - return accountIdOrError; - } - accountId = accountIdOrError; - } else { - // Bearer token authentication - const accountIdOrError = await getAuthenticatedAccountId(request); - if (accountIdOrError instanceof NextResponse) { - return accountIdOrError; - } - accountId = accountIdOrError; - } + let accountId = accountIdOrError; const body = await safeParseJson(request); From add518d2017b465edd817e09d4e69a7175507b49 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Fri, 16 Jan 2026 12:40:38 -0500 Subject: [PATCH 06/23] feat: add GET /api/agent-templates endpoint with Bearer auth Migrate agent templates listing from Recoup-Chat to recoup-api with: - Bearer token authentication via Privy - Combined query for owned, public, and shared templates - User favorites tracking - Shared emails for private templates Co-Authored-By: Claude Opus 4.5 --- app/api/agent-templates/route.ts | 33 +++ .../getAgentTemplatesHandler.test.ts | 226 ++++++++++++++++++ .../getAgentTemplateSharesByTemplateIds.ts | 25 ++ .../getAgentTemplatesHandler.ts | 78 ++++++ .../getSharedEmailsForTemplates.ts | 50 ++++ .../getSharedTemplatesForUser.ts | 44 ++++ .../getUserAccessibleTemplates.ts | 41 ++++ .../getUserTemplateFavorites.ts | 20 ++ .../listAgentTemplatesForUser.ts | 25 ++ lib/agentTemplates/types.ts | 22 ++ 10 files changed, 564 insertions(+) create mode 100644 app/api/agent-templates/route.ts create mode 100644 lib/agentTemplates/__tests__/getAgentTemplatesHandler.test.ts create mode 100644 lib/agentTemplates/getAgentTemplateSharesByTemplateIds.ts create mode 100644 lib/agentTemplates/getAgentTemplatesHandler.ts create mode 100644 lib/agentTemplates/getSharedEmailsForTemplates.ts create mode 100644 lib/agentTemplates/getSharedTemplatesForUser.ts create mode 100644 lib/agentTemplates/getUserAccessibleTemplates.ts create mode 100644 lib/agentTemplates/getUserTemplateFavorites.ts create mode 100644 lib/agentTemplates/listAgentTemplatesForUser.ts create mode 100644 lib/agentTemplates/types.ts diff --git a/app/api/agent-templates/route.ts b/app/api/agent-templates/route.ts new file mode 100644 index 00000000..8308693f --- /dev/null +++ b/app/api/agent-templates/route.ts @@ -0,0 +1,33 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getAgentTemplatesHandler } from "@/lib/agentTemplates/getAgentTemplatesHandler"; + +/** + * OPTIONS handler for CORS preflight requests. + * + * @returns A NextResponse with CORS headers. + */ +export async function OPTIONS() { + return new NextResponse(null, { + status: 200, + headers: getCorsHeaders(), + }); +} + +/** + * GET /api/agent-templates + * + * Fetch agent templates accessible to the authenticated user. + * + * Authentication: Authorization Bearer token required. + * + * Query parameters: + * - userId: Optional user ID (defaults to authenticated user) + * + * @param request - The request object + * @returns A NextResponse with the templates array or an error + */ +export async function GET(request: NextRequest): Promise { + return getAgentTemplatesHandler(request); +} diff --git a/lib/agentTemplates/__tests__/getAgentTemplatesHandler.test.ts b/lib/agentTemplates/__tests__/getAgentTemplatesHandler.test.ts new file mode 100644 index 00000000..1631ae65 --- /dev/null +++ b/lib/agentTemplates/__tests__/getAgentTemplatesHandler.test.ts @@ -0,0 +1,226 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { getAgentTemplatesHandler } from "../getAgentTemplatesHandler"; + +import { getAuthenticatedAccountId } from "@/lib/auth/getAuthenticatedAccountId"; +import { getUserAccessibleTemplates } from "../getUserAccessibleTemplates"; +import { getSharedEmailsForTemplates } from "../getSharedEmailsForTemplates"; + +// Mock dependencies +vi.mock("@/lib/auth/getAuthenticatedAccountId", () => ({ + getAuthenticatedAccountId: vi.fn(), +})); + +vi.mock("../getUserAccessibleTemplates", () => ({ + getUserAccessibleTemplates: vi.fn(), +})); + +vi.mock("../getSharedEmailsForTemplates", () => ({ + getSharedEmailsForTemplates: vi.fn(), +})); + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +/** + * Creates a mock request with Bearer token auth + */ +function createMockBearerRequest( + userId?: string, + token = "test-bearer-token", +): NextRequest { + const url = new URL("http://localhost/api/agent-templates"); + if (userId) { + url.searchParams.set("userId", userId); + } + return { + url: url.toString(), + headers: { + get: (name: string) => + name === "authorization" ? `Bearer ${token}` : null, + }, + } as unknown as NextRequest; +} + +/** + * Creates a mock request with no auth + */ +function createMockNoAuthRequest(userId?: string): NextRequest { + const url = new URL("http://localhost/api/agent-templates"); + if (userId) { + url.searchParams.set("userId", userId); + } + return { + url: url.toString(), + headers: { + get: () => null, + }, + } as unknown as NextRequest; +} + +describe("getAgentTemplatesHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("authentication", () => { + it("returns 401 when no auth is provided", async () => { + vi.mocked(getAuthenticatedAccountId).mockResolvedValue( + NextResponse.json( + { status: "error", message: "Authorization header with Bearer token required" }, + { status: 401 }, + ), + ); + + const request = createMockNoAuthRequest(); + const response = await getAgentTemplatesHandler(request); + const json = await response.json(); + + expect(response.status).toBe(401); + expect(json.status).toBe("error"); + expect(json.message).toBe( + "Authorization header with Bearer token required", + ); + }); + + it("returns 401 when Bearer token validation fails", async () => { + vi.mocked(getAuthenticatedAccountId).mockResolvedValue( + NextResponse.json( + { status: "error", message: "Invalid token" }, + { status: 401 }, + ), + ); + + const request = createMockBearerRequest("user-123"); + const response = await getAgentTemplatesHandler(request); + const json = await response.json(); + + expect(response.status).toBe(401); + expect(json.status).toBe("error"); + expect(json.message).toBe("Invalid token"); + }); + }); + + describe("with valid authentication", () => { + it("returns templates for authenticated user", async () => { + const mockTemplates = [ + { + id: "template-1", + title: "Test Template", + description: "A test template", + prompt: "Test prompt", + tags: ["tag1"], + creator: "user-123", + is_private: false, + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + favorites_count: 5, + is_favourite: true, + }, + ]; + + // Expected output includes shared_emails: [] for public templates + const expectedTemplates = mockTemplates.map((t) => ({ + ...t, + shared_emails: [], + })); + + vi.mocked(getAuthenticatedAccountId).mockResolvedValue("user-123"); + vi.mocked(getUserAccessibleTemplates).mockResolvedValue(mockTemplates); + vi.mocked(getSharedEmailsForTemplates).mockResolvedValue({}); + + const request = createMockBearerRequest("user-123"); + const response = await getAgentTemplatesHandler(request); + const json = await response.json(); + + expect(response.status).toBe(200); + expect(json.status).toBe("success"); + expect(json.templates).toEqual(expectedTemplates); + expect(getUserAccessibleTemplates).toHaveBeenCalledWith("user-123"); + }); + + it("includes shared emails for private templates", async () => { + const mockTemplates = [ + { + id: "template-1", + title: "Private Template", + description: "A private template", + prompt: "Private prompt", + tags: ["private"], + creator: "user-123", + is_private: true, + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + favorites_count: 0, + is_favourite: false, + }, + ]; + + const mockSharedEmails = { + "template-1": ["shared@example.com", "another@example.com"], + }; + + vi.mocked(getAuthenticatedAccountId).mockResolvedValue("user-123"); + vi.mocked(getUserAccessibleTemplates).mockResolvedValue(mockTemplates); + vi.mocked(getSharedEmailsForTemplates).mockResolvedValue(mockSharedEmails); + + const request = createMockBearerRequest("user-123"); + const response = await getAgentTemplatesHandler(request); + const json = await response.json(); + + expect(response.status).toBe(200); + expect(json.templates[0].shared_emails).toEqual([ + "shared@example.com", + "another@example.com", + ]); + expect(getSharedEmailsForTemplates).toHaveBeenCalledWith(["template-1"]); + }); + + it("does not fetch shared emails when no private templates exist", async () => { + const mockTemplates = [ + { + id: "template-1", + title: "Public Template", + description: "A public template", + prompt: "Public prompt", + tags: ["public"], + creator: "user-123", + is_private: false, + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + favorites_count: 10, + is_favourite: false, + }, + ]; + + vi.mocked(getAuthenticatedAccountId).mockResolvedValue("user-123"); + vi.mocked(getUserAccessibleTemplates).mockResolvedValue(mockTemplates); + + const request = createMockBearerRequest("user-123"); + const response = await getAgentTemplatesHandler(request); + const json = await response.json(); + + expect(response.status).toBe(200); + expect(json.templates[0].shared_emails).toEqual([]); + expect(getSharedEmailsForTemplates).not.toHaveBeenCalled(); + }); + }); + + describe("error handling", () => { + it("returns 500 when getUserAccessibleTemplates throws", async () => { + vi.mocked(getAuthenticatedAccountId).mockResolvedValue("user-123"); + vi.mocked(getUserAccessibleTemplates).mockRejectedValue( + new Error("Database error"), + ); + + const request = createMockBearerRequest("user-123"); + const response = await getAgentTemplatesHandler(request); + const json = await response.json(); + + expect(response.status).toBe(500); + expect(json.status).toBe("error"); + expect(json.message).toBe("Failed to fetch agent templates"); + }); + }); +}); diff --git a/lib/agentTemplates/getAgentTemplateSharesByTemplateIds.ts b/lib/agentTemplates/getAgentTemplateSharesByTemplateIds.ts new file mode 100644 index 00000000..5cd958c0 --- /dev/null +++ b/lib/agentTemplates/getAgentTemplateSharesByTemplateIds.ts @@ -0,0 +1,25 @@ +import supabase from "@/lib/supabase/serverClient"; +import type { AgentTemplateShare } from "./types"; + +/** + * Get all agent template shares for specific template IDs + * @param templateIds Array of template IDs to get shares for + * @returns Array of share records + */ +export async function getAgentTemplateSharesByTemplateIds( + templateIds: string[], +): Promise { + if (!Array.isArray(templateIds) || templateIds.length === 0) return []; + + const { data, error } = await supabase + .from("agent_template_shares") + .select("template_id, user_id, created_at") + .in("template_id", templateIds); + + if (error) { + console.error("Error fetching agent template shares:", error); + throw error; + } + + return (data as AgentTemplateShare[]) || []; +} diff --git a/lib/agentTemplates/getAgentTemplatesHandler.ts b/lib/agentTemplates/getAgentTemplatesHandler.ts new file mode 100644 index 00000000..a59e8bf0 --- /dev/null +++ b/lib/agentTemplates/getAgentTemplatesHandler.ts @@ -0,0 +1,78 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getAuthenticatedAccountId } from "@/lib/auth/getAuthenticatedAccountId"; +import { getUserAccessibleTemplates } from "./getUserAccessibleTemplates"; +import { getSharedEmailsForTemplates } from "./getSharedEmailsForTemplates"; + +/** + * Handler for fetching agent templates. + * + * Requires authentication via Authorization Bearer token. + * + * Query parameters: + * - userId: Optional user ID to fetch templates for (must match authenticated user) + * + * @param request - The NextRequest object + * @returns A NextResponse with the templates array or an error + */ +export async function getAgentTemplatesHandler( + request: NextRequest, +): Promise { + try { + // Authenticate using Bearer token + const accountIdOrError = await getAuthenticatedAccountId(request); + if (accountIdOrError instanceof NextResponse) { + return accountIdOrError; + } + + const accountId = accountIdOrError; + + // Parse userId from query params (optional) + const { searchParams } = new URL(request.url); + const userId = searchParams.get("userId") || accountId; + + // Fetch templates accessible to the user + const templates = await getUserAccessibleTemplates(userId); + + // Get shared emails for private templates + const privateTemplateIds = templates + .filter((template) => template.is_private) + .map((template) => template.id); + + let sharedEmails: Record = {}; + if (privateTemplateIds.length > 0) { + sharedEmails = await getSharedEmailsForTemplates(privateTemplateIds); + } + + // Add shared emails to templates + const templatesWithEmails = templates.map((template) => ({ + ...template, + shared_emails: template.is_private + ? sharedEmails[template.id] || [] + : [], + })); + + return NextResponse.json( + { + status: "success", + templates: templatesWithEmails, + }, + { + status: 200, + headers: getCorsHeaders(), + }, + ); + } catch (error) { + console.error("[ERROR] getAgentTemplatesHandler:", error); + return NextResponse.json( + { + status: "error", + message: "Failed to fetch agent templates", + }, + { + status: 500, + headers: getCorsHeaders(), + }, + ); + } +} diff --git a/lib/agentTemplates/getSharedEmailsForTemplates.ts b/lib/agentTemplates/getSharedEmailsForTemplates.ts new file mode 100644 index 00000000..6b7cc88a --- /dev/null +++ b/lib/agentTemplates/getSharedEmailsForTemplates.ts @@ -0,0 +1,50 @@ +import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmails"; +import { getAgentTemplateSharesByTemplateIds } from "./getAgentTemplateSharesByTemplateIds"; + +export async function getSharedEmailsForTemplates( + templateIds: string[], +): Promise> { + if (!templateIds || templateIds.length === 0) return {}; + + // Get all shares for these templates using existing utility + const shares = await getAgentTemplateSharesByTemplateIds(templateIds); + + if (shares.length === 0) return {}; + + // Get all user IDs who have access to these templates + const userIds = [...new Set(shares.map((share) => share.user_id))]; + + // Get emails for these users using existing utility + const emails = await selectAccountEmails({ accountIds: userIds }); + + // Create a map of user_id to email + const userEmailMap: Record = {}; + emails.forEach((emailRecord) => { + if (emailRecord.account_id && emailRecord.email) { + if (!userEmailMap[emailRecord.account_id]) { + userEmailMap[emailRecord.account_id] = []; + } + userEmailMap[emailRecord.account_id].push(emailRecord.email); + } + }); + + // Create the final map of template_id to emails + const emailMap: Record = {}; + + shares.forEach((share) => { + if (!emailMap[share.template_id]) { + emailMap[share.template_id] = []; + } + + // Add all emails for this user + const userEmails = userEmailMap[share.user_id] || []; + emailMap[share.template_id].push(...userEmails); + }); + + // Remove duplicates for each template + Object.keys(emailMap).forEach((templateId) => { + emailMap[templateId] = [...new Set(emailMap[templateId])]; + }); + + return emailMap; +} diff --git a/lib/agentTemplates/getSharedTemplatesForUser.ts b/lib/agentTemplates/getSharedTemplatesForUser.ts new file mode 100644 index 00000000..5d6f5203 --- /dev/null +++ b/lib/agentTemplates/getSharedTemplatesForUser.ts @@ -0,0 +1,44 @@ +import supabase from "@/lib/supabase/serverClient"; +import type { AgentTemplateRow } from "./types"; + +interface SharedTemplateData { + templates: AgentTemplateRow | AgentTemplateRow[]; +} + +export async function getSharedTemplatesForUser( + userId: string, +): Promise { + const { data, error } = await supabase + .from("agent_template_shares") + .select( + ` + templates:agent_templates( + id, title, description, prompt, tags, creator, is_private, created_at, favorites_count, updated_at + ) + `, + ) + .eq("user_id", userId); + + if (error) throw error; + + const templates: AgentTemplateRow[] = []; + const processedIds = new Set(); + + data?.forEach((share: SharedTemplateData) => { + if (!share || !share.templates) return; + + // Handle both single template and array of templates + const templateList = Array.isArray(share.templates) + ? share.templates + : [share.templates]; + + templateList?.forEach((template: AgentTemplateRow) => { + if (template && template.id && !processedIds.has(template.id)) { + templates.push(template); + processedIds.add(template.id); + } + }); + }); + + return templates; +} diff --git a/lib/agentTemplates/getUserAccessibleTemplates.ts b/lib/agentTemplates/getUserAccessibleTemplates.ts new file mode 100644 index 00000000..507d5121 --- /dev/null +++ b/lib/agentTemplates/getUserAccessibleTemplates.ts @@ -0,0 +1,41 @@ +import type { AgentTemplateRow } from "./types"; +import { listAgentTemplatesForUser } from "./listAgentTemplatesForUser"; +import { getSharedTemplatesForUser } from "./getSharedTemplatesForUser"; +import { getUserTemplateFavorites } from "./getUserTemplateFavorites"; + +export async function getUserAccessibleTemplates(userId?: string | null) { + if (userId && userId !== "undefined") { + // Get owned and public templates + const ownedAndPublic = await listAgentTemplatesForUser(userId); + + // Get shared templates using dedicated utility + const sharedTemplates = await getSharedTemplatesForUser(userId); + + // Combine templates and avoid duplicates + const allTemplates = [...ownedAndPublic]; + const templateIds = new Set(ownedAndPublic.map((t) => t.id)); + + sharedTemplates.forEach((template) => { + if (!templateIds.has(template.id)) { + allTemplates.push(template); + templateIds.add(template.id); + } + }); + + // Get user's favorite templates + const favouriteIds = await getUserTemplateFavorites(userId); + + // Mark favorites + return allTemplates.map((template: AgentTemplateRow) => ({ + ...template, + is_favourite: favouriteIds.has(template.id), + })); + } + + // For anonymous users, return public templates only + const publicTemplates = await listAgentTemplatesForUser(null); + return publicTemplates.map((template: AgentTemplateRow) => ({ + ...template, + is_favourite: false, + })); +} diff --git a/lib/agentTemplates/getUserTemplateFavorites.ts b/lib/agentTemplates/getUserTemplateFavorites.ts new file mode 100644 index 00000000..f47af1f3 --- /dev/null +++ b/lib/agentTemplates/getUserTemplateFavorites.ts @@ -0,0 +1,20 @@ +import supabase from "@/lib/supabase/serverClient"; + +interface TemplateFavorite { + template_id: string; +} + +export async function getUserTemplateFavorites( + userId: string, +): Promise> { + const { data, error } = await supabase + .from("agent_template_favorites") + .select("template_id") + .eq("user_id", userId); + + if (error) throw error; + + return new Set( + (data || []).map((f: TemplateFavorite) => f.template_id), + ); +} diff --git a/lib/agentTemplates/listAgentTemplatesForUser.ts b/lib/agentTemplates/listAgentTemplatesForUser.ts new file mode 100644 index 00000000..9067ec0d --- /dev/null +++ b/lib/agentTemplates/listAgentTemplatesForUser.ts @@ -0,0 +1,25 @@ +import supabase from "@/lib/supabase/serverClient"; + +export async function listAgentTemplatesForUser(userId?: string | null) { + if (userId && userId !== "undefined") { + const { data, error } = await supabase + .from("agent_templates") + .select( + "id, title, description, prompt, tags, creator, is_private, created_at, favorites_count, updated_at", + ) + .or(`creator.eq.${userId},is_private.eq.false`) + .order("title"); + if (error) throw error; + return data ?? []; + } + + const { data, error } = await supabase + .from("agent_templates") + .select( + "id, title, description, prompt, tags, creator, is_private, created_at, favorites_count, updated_at", + ) + .eq("is_private", false) + .order("title"); + if (error) throw error; + return data ?? []; +} diff --git a/lib/agentTemplates/types.ts b/lib/agentTemplates/types.ts new file mode 100644 index 00000000..53c7cb40 --- /dev/null +++ b/lib/agentTemplates/types.ts @@ -0,0 +1,22 @@ +export interface AgentTemplateRow { + id: string; + title: string; + description: string; + prompt: string; + tags: string[] | null; + creator: string | null; + is_private: boolean; + created_at: string | null; + favorites_count: number | null; + updated_at: string | null; + // computed for requesting user + is_favourite?: boolean; + // emails the template is shared with (only for private templates) + shared_emails?: string[]; +} + +export interface AgentTemplateShare { + template_id: string; + user_id: string; + created_at: string; +} From 08f3ab7a635f763cecb5822f8d98cd9c33889686 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Fri, 16 Jan 2026 12:47:57 -0500 Subject: [PATCH 07/23] chore: delete duplicate getSocialPlatformByLink.ts in artistAgents - Updated getArtistAgents.ts to import from lib/artists/getSocialPlatformByLink.ts - Deleted duplicate lib/artistAgents/getSocialPlatformByLink.ts YAGNI cleanup: the canonical location for this utility is lib/artists/ Co-Authored-By: Claude Opus 4.5 --- lib/artistAgents/getArtistAgents.ts | 12 +++++------- lib/artistAgents/getSocialPlatformByLink.ts | 21 --------------------- 2 files changed, 5 insertions(+), 28 deletions(-) delete mode 100644 lib/artistAgents/getSocialPlatformByLink.ts diff --git a/lib/artistAgents/getArtistAgents.ts b/lib/artistAgents/getArtistAgents.ts index d48b2f1a..0fafa037 100644 --- a/lib/artistAgents/getArtistAgents.ts +++ b/lib/artistAgents/getArtistAgents.ts @@ -1,5 +1,5 @@ import supabase from "@/lib/supabase/serverClient"; -import getSocialPlatformByLink from "./getSocialPlatformByLink"; +import { getSocialPlatformByLink } from "@/lib/artists/getSocialPlatformByLink"; export interface ArtistAgent { type: string; @@ -16,9 +16,7 @@ export interface ArtistAgent { * @param artistSocialIds - Array of social IDs to look up * @returns Array of ArtistAgent objects, aggregated by type */ -export async function getArtistAgents( - artistSocialIds: string[], -): Promise { +export async function getArtistAgents(artistSocialIds: string[]): Promise { const { data, error } = await supabase .from("agent_status") .select("*, agent:agents(*)") @@ -31,7 +29,7 @@ export async function getArtistAgents( if (!data) return []; - const agentIds = [...new Set(data.map((ele) => ele.agent.id))]; + const agentIds = [...new Set(data.map(ele => ele.agent.id))]; const { data: agents } = await supabase .from("agents") @@ -40,7 +38,7 @@ export async function getArtistAgents( if (!agents) return []; - const transformedAgents = agents.map((agent) => ({ + const transformedAgents = agents.map(agent => ({ type: new String( agent.agent_status.length > 1 ? "wrapped" @@ -53,7 +51,7 @@ export async function getArtistAgents( // Aggregate agents by type (latest one for each type wins) const aggregatedAgents: Record = {}; - transformedAgents.forEach((agent) => { + transformedAgents.forEach(agent => { const type = agent.type.toLowerCase(); aggregatedAgents[type] = agent; }); diff --git a/lib/artistAgents/getSocialPlatformByLink.ts b/lib/artistAgents/getSocialPlatformByLink.ts deleted file mode 100644 index c06e983d..00000000 --- a/lib/artistAgents/getSocialPlatformByLink.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Determines the social media platform based on a profile URL. - * - * @param link - The social media profile URL - * @returns The platform name in uppercase (e.g., "TWITTER", "INSTAGRAM") - */ -const getSocialPlatformByLink = (link: string): string => { - if (!link) return "NONE"; - if (link.includes("x.com") || link.includes("twitter.com")) return "TWITTER"; - if (link.includes("instagram.com")) return "INSTAGRAM"; - if (link.includes("spotify.com")) return "SPOTIFY"; - if (link.includes("tiktok.com")) return "TIKTOK"; - if (link.includes("apple.com")) return "APPLE"; - if (link.includes("youtube.")) return "YOUTUBE"; - if (link.includes("facebook.com")) return "FACEBOOK"; - if (link.includes("threads.net") || link.includes("threads.com")) return "THREADS"; - - return "NONE"; -}; - -export default getSocialPlatformByLink; From 69fca2dccc0259e9328faf82f5296ad18419d5d7 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Fri, 16 Jan 2026 14:03:55 -0500 Subject: [PATCH 08/23] feat: add POST /api/agent-templates/favorites endpoint - Create toggleAgentTemplateFavoriteHandler with Bearer token auth - Add addAgentTemplateFavorite and removeAgentTemplateFavorite helpers - Add 8 unit tests for the new handler Co-Authored-By: Claude Opus 4.5 --- app/api/agent-templates/favorites/route.ts | 34 ++++ ...toggleAgentTemplateFavoriteHandler.test.ts | 180 ++++++++++++++++++ .../addAgentTemplateFavorite.ts | 27 +++ .../removeAgentTemplateFavorite.ts | 26 +++ .../toggleAgentTemplateFavoriteHandler.ts | 96 ++++++++++ 5 files changed, 363 insertions(+) create mode 100644 app/api/agent-templates/favorites/route.ts create mode 100644 lib/agentTemplates/__tests__/toggleAgentTemplateFavoriteHandler.test.ts create mode 100644 lib/agentTemplates/addAgentTemplateFavorite.ts create mode 100644 lib/agentTemplates/removeAgentTemplateFavorite.ts create mode 100644 lib/agentTemplates/toggleAgentTemplateFavoriteHandler.ts diff --git a/app/api/agent-templates/favorites/route.ts b/app/api/agent-templates/favorites/route.ts new file mode 100644 index 00000000..ccecc929 --- /dev/null +++ b/app/api/agent-templates/favorites/route.ts @@ -0,0 +1,34 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { toggleAgentTemplateFavoriteHandler } from "@/lib/agentTemplates/toggleAgentTemplateFavoriteHandler"; + +/** + * OPTIONS handler for CORS preflight requests. + * + * @returns A NextResponse with CORS headers. + */ +export async function OPTIONS() { + return new NextResponse(null, { + status: 200, + headers: getCorsHeaders(), + }); +} + +/** + * POST /api/agent-templates/favorites + * + * Toggle a template's favorite status for the authenticated user. + * + * Authentication: Authorization Bearer token required. + * + * Request body: + * - templateId: string - The template ID to favorite/unfavorite + * - isFavourite: boolean - true to add, false to remove + * + * @param request - The request object + * @returns A NextResponse with success or an error + */ +export async function POST(request: NextRequest): Promise { + return toggleAgentTemplateFavoriteHandler(request); +} diff --git a/lib/agentTemplates/__tests__/toggleAgentTemplateFavoriteHandler.test.ts b/lib/agentTemplates/__tests__/toggleAgentTemplateFavoriteHandler.test.ts new file mode 100644 index 00000000..1f4fcc4b --- /dev/null +++ b/lib/agentTemplates/__tests__/toggleAgentTemplateFavoriteHandler.test.ts @@ -0,0 +1,180 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { toggleAgentTemplateFavoriteHandler } from "../toggleAgentTemplateFavoriteHandler"; + +import { getAuthenticatedAccountId } from "@/lib/auth/getAuthenticatedAccountId"; +import { addAgentTemplateFavorite } from "../addAgentTemplateFavorite"; +import { removeAgentTemplateFavorite } from "../removeAgentTemplateFavorite"; + +// Mock dependencies +vi.mock("@/lib/auth/getAuthenticatedAccountId", () => ({ + getAuthenticatedAccountId: vi.fn(), +})); + +vi.mock("../addAgentTemplateFavorite", () => ({ + addAgentTemplateFavorite: vi.fn(), +})); + +vi.mock("../removeAgentTemplateFavorite", () => ({ + removeAgentTemplateFavorite: vi.fn(), +})); + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +/** + * Creates a mock POST request with Bearer token auth + */ +function createMockBearerRequest( + body: { templateId?: string; isFavourite?: boolean }, + token = "test-bearer-token", +): NextRequest { + const url = new URL("http://localhost/api/agent-templates/favorites"); + return { + url: url.toString(), + method: "POST", + headers: { + get: (name: string) => (name === "authorization" ? `Bearer ${token}` : null), + }, + json: async () => body, + } as unknown as NextRequest; +} + +/** + * Creates a mock request with no auth + */ +function createMockNoAuthRequest(body: { templateId?: string; isFavourite?: boolean }): NextRequest { + const url = new URL("http://localhost/api/agent-templates/favorites"); + return { + url: url.toString(), + method: "POST", + headers: { + get: () => null, + }, + json: async () => body, + } as unknown as NextRequest; +} + +describe("toggleAgentTemplateFavoriteHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("authentication", () => { + it("returns 401 when no auth is provided", async () => { + vi.mocked(getAuthenticatedAccountId).mockResolvedValue( + NextResponse.json( + { status: "error", message: "Authorization header with Bearer token required" }, + { status: 401 }, + ), + ); + + const request = createMockNoAuthRequest({ templateId: "template-1", isFavourite: true }); + const response = await toggleAgentTemplateFavoriteHandler(request); + const json = await response.json(); + + expect(response.status).toBe(401); + expect(json.status).toBe("error"); + expect(json.message).toBe("Authorization header with Bearer token required"); + }); + + it("returns 401 when Bearer token validation fails", async () => { + vi.mocked(getAuthenticatedAccountId).mockResolvedValue( + NextResponse.json({ status: "error", message: "Invalid token" }, { status: 401 }), + ); + + const request = createMockBearerRequest({ templateId: "template-1", isFavourite: true }); + const response = await toggleAgentTemplateFavoriteHandler(request); + const json = await response.json(); + + expect(response.status).toBe(401); + expect(json.status).toBe("error"); + expect(json.message).toBe("Invalid token"); + }); + }); + + describe("validation", () => { + it("returns 400 when templateId is missing", async () => { + vi.mocked(getAuthenticatedAccountId).mockResolvedValue("user-123"); + + const request = createMockBearerRequest({ isFavourite: true }); + const response = await toggleAgentTemplateFavoriteHandler(request); + const json = await response.json(); + + expect(response.status).toBe(400); + expect(json.status).toBe("error"); + expect(json.message).toBe("Missing templateId"); + }); + + it("returns 400 when isFavourite is missing", async () => { + vi.mocked(getAuthenticatedAccountId).mockResolvedValue("user-123"); + + const request = createMockBearerRequest({ templateId: "template-1" }); + const response = await toggleAgentTemplateFavoriteHandler(request); + const json = await response.json(); + + expect(response.status).toBe(400); + expect(json.status).toBe("error"); + expect(json.message).toBe("Missing isFavourite"); + }); + }); + + describe("with valid authentication", () => { + it("adds favorite when isFavourite is true", async () => { + vi.mocked(getAuthenticatedAccountId).mockResolvedValue("user-123"); + vi.mocked(addAgentTemplateFavorite).mockResolvedValue({ success: true }); + + const request = createMockBearerRequest({ templateId: "template-1", isFavourite: true }); + const response = await toggleAgentTemplateFavoriteHandler(request); + const json = await response.json(); + + expect(response.status).toBe(200); + expect(json.success).toBe(true); + expect(addAgentTemplateFavorite).toHaveBeenCalledWith("template-1", "user-123"); + expect(removeAgentTemplateFavorite).not.toHaveBeenCalled(); + }); + + it("removes favorite when isFavourite is false", async () => { + vi.mocked(getAuthenticatedAccountId).mockResolvedValue("user-123"); + vi.mocked(removeAgentTemplateFavorite).mockResolvedValue({ success: true }); + + const request = createMockBearerRequest({ templateId: "template-1", isFavourite: false }); + const response = await toggleAgentTemplateFavoriteHandler(request); + const json = await response.json(); + + expect(response.status).toBe(200); + expect(json.success).toBe(true); + expect(removeAgentTemplateFavorite).toHaveBeenCalledWith("template-1", "user-123"); + expect(addAgentTemplateFavorite).not.toHaveBeenCalled(); + }); + }); + + describe("error handling", () => { + it("returns 500 when addAgentTemplateFavorite throws", async () => { + vi.mocked(getAuthenticatedAccountId).mockResolvedValue("user-123"); + vi.mocked(addAgentTemplateFavorite).mockRejectedValue(new Error("Database error")); + + const request = createMockBearerRequest({ templateId: "template-1", isFavourite: true }); + const response = await toggleAgentTemplateFavoriteHandler(request); + const json = await response.json(); + + expect(response.status).toBe(500); + expect(json.status).toBe("error"); + expect(json.message).toBe("Failed to toggle favorite"); + }); + + it("returns 500 when removeAgentTemplateFavorite throws", async () => { + vi.mocked(getAuthenticatedAccountId).mockResolvedValue("user-123"); + vi.mocked(removeAgentTemplateFavorite).mockRejectedValue(new Error("Database error")); + + const request = createMockBearerRequest({ templateId: "template-1", isFavourite: false }); + const response = await toggleAgentTemplateFavoriteHandler(request); + const json = await response.json(); + + expect(response.status).toBe(500); + expect(json.status).toBe("error"); + expect(json.message).toBe("Failed to toggle favorite"); + }); + }); +}); diff --git a/lib/agentTemplates/addAgentTemplateFavorite.ts b/lib/agentTemplates/addAgentTemplateFavorite.ts new file mode 100644 index 00000000..bd2569d4 --- /dev/null +++ b/lib/agentTemplates/addAgentTemplateFavorite.ts @@ -0,0 +1,27 @@ +import supabase from "@/lib/supabase/serverClient"; + +/** + * Adds an agent template to a user's favorites. + * + * @param templateId - The ID of the template to favorite + * @param userId - The ID of the user adding the favorite + * @returns An object with success: true + * @throws Error if the database operation fails (except for duplicate entries) + */ +export async function addAgentTemplateFavorite( + templateId: string, + userId: string, +): Promise<{ success: true }> { + const { error } = await supabase + .from("agent_template_favorites") + .insert({ template_id: templateId, user_id: userId }) + .select("template_id") + .maybeSingle(); + + // Ignore unique violation (23505) - user already favorited this template + if (error && error.code !== "23505") { + throw error; + } + + return { success: true }; +} diff --git a/lib/agentTemplates/removeAgentTemplateFavorite.ts b/lib/agentTemplates/removeAgentTemplateFavorite.ts new file mode 100644 index 00000000..8ad682c8 --- /dev/null +++ b/lib/agentTemplates/removeAgentTemplateFavorite.ts @@ -0,0 +1,26 @@ +import supabase from "@/lib/supabase/serverClient"; + +/** + * Removes an agent template from a user's favorites. + * + * @param templateId - The ID of the template to unfavorite + * @param userId - The ID of the user removing the favorite + * @returns An object with success: true + * @throws Error if the database operation fails + */ +export async function removeAgentTemplateFavorite( + templateId: string, + userId: string, +): Promise<{ success: true }> { + const { error } = await supabase + .from("agent_template_favorites") + .delete() + .eq("template_id", templateId) + .eq("user_id", userId); + + if (error) { + throw error; + } + + return { success: true }; +} diff --git a/lib/agentTemplates/toggleAgentTemplateFavoriteHandler.ts b/lib/agentTemplates/toggleAgentTemplateFavoriteHandler.ts new file mode 100644 index 00000000..4110bdcb --- /dev/null +++ b/lib/agentTemplates/toggleAgentTemplateFavoriteHandler.ts @@ -0,0 +1,96 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getAuthenticatedAccountId } from "@/lib/auth/getAuthenticatedAccountId"; +import { addAgentTemplateFavorite } from "./addAgentTemplateFavorite"; +import { removeAgentTemplateFavorite } from "./removeAgentTemplateFavorite"; + +interface ToggleFavoriteRequestBody { + templateId?: string; + isFavourite?: boolean; +} + +/** + * Handler for toggling agent template favorites. + * + * Requires authentication via Authorization Bearer token. + * + * Request body: + * - templateId: The ID of the template to favorite/unfavorite + * - isFavourite: true to add to favorites, false to remove + * + * @param request - The NextRequest object + * @returns A NextResponse with success or an error + */ +export async function toggleAgentTemplateFavoriteHandler( + request: NextRequest, +): Promise { + try { + // Authenticate using Bearer token + const accountIdOrError = await getAuthenticatedAccountId(request); + if (accountIdOrError instanceof NextResponse) { + return accountIdOrError; + } + + const accountId = accountIdOrError; + + // Parse request body + const body: ToggleFavoriteRequestBody = await request.json(); + const { templateId, isFavourite } = body; + + // Validate required fields + if (!templateId) { + return NextResponse.json( + { + status: "error", + message: "Missing templateId", + }, + { + status: 400, + headers: getCorsHeaders(), + }, + ); + } + + if (typeof isFavourite !== "boolean") { + return NextResponse.json( + { + status: "error", + message: "Missing isFavourite", + }, + { + status: 400, + headers: getCorsHeaders(), + }, + ); + } + + // Toggle favorite + if (isFavourite) { + await addAgentTemplateFavorite(templateId, accountId); + } else { + await removeAgentTemplateFavorite(templateId, accountId); + } + + return NextResponse.json( + { + success: true, + }, + { + status: 200, + headers: getCorsHeaders(), + }, + ); + } catch (error) { + console.error("[ERROR] toggleAgentTemplateFavoriteHandler:", error); + return NextResponse.json( + { + status: "error", + message: "Failed to toggle favorite", + }, + { + status: 500, + headers: getCorsHeaders(), + }, + ); + } +} From 59c7af157d7b805ea5404549d7edcad02a1adc94 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Fri, 16 Jan 2026 14:13:06 -0500 Subject: [PATCH 09/23] feat: add GET /api/agent-creator endpoint Add public endpoint for fetching agent creator info (name, image, is_admin) to be used by the frontend for displaying agent creator attribution. - Create new /api/agent-creator route with CORS support - Add getAgentCreatorHandler with proper error handling - Add ADMIN_EMAILS config for admin detection - Add 6 unit tests for the handler Co-Authored-By: Claude Opus 4.5 --- app/api/agent-creator/route.ts | 33 ++++ lib/admin.ts | 5 + .../__tests__/getAgentCreatorHandler.test.ts | 149 ++++++++++++++++++ lib/agentCreator/getAgentCreatorHandler.ts | 70 ++++++++ 4 files changed, 257 insertions(+) create mode 100644 app/api/agent-creator/route.ts create mode 100644 lib/admin.ts create mode 100644 lib/agentCreator/__tests__/getAgentCreatorHandler.test.ts create mode 100644 lib/agentCreator/getAgentCreatorHandler.ts diff --git a/app/api/agent-creator/route.ts b/app/api/agent-creator/route.ts new file mode 100644 index 00000000..288608b6 --- /dev/null +++ b/app/api/agent-creator/route.ts @@ -0,0 +1,33 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getAgentCreatorHandler } from "@/lib/agentCreator/getAgentCreatorHandler"; + +/** + * OPTIONS handler for CORS preflight requests. + * + * @returns A NextResponse with CORS headers. + */ +export async function OPTIONS() { + return new NextResponse(null, { + status: 200, + headers: getCorsHeaders(), + }); +} + +/** + * GET /api/agent-creator + * + * Fetch agent creator information for display in the UI. + * + * This is a public endpoint that does not require authentication. + * + * Query parameters: + * - creatorId: Required - The account ID of the agent creator + * + * @param request - The request object + * @returns A NextResponse with creator info (name, image, is_admin) or an error + */ +export async function GET(request: NextRequest): Promise { + return getAgentCreatorHandler(request); +} diff --git a/lib/admin.ts b/lib/admin.ts new file mode 100644 index 00000000..dd057266 --- /dev/null +++ b/lib/admin.ts @@ -0,0 +1,5 @@ +/** + * List of admin email addresses. + * Users with these emails are identified as admins in the agent creator endpoint. + */ +export const ADMIN_EMAILS: string[] = ["sidney+1@recoupable.com"]; diff --git a/lib/agentCreator/__tests__/getAgentCreatorHandler.test.ts b/lib/agentCreator/__tests__/getAgentCreatorHandler.test.ts new file mode 100644 index 00000000..0cc8aadf --- /dev/null +++ b/lib/agentCreator/__tests__/getAgentCreatorHandler.test.ts @@ -0,0 +1,149 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; +import { getAgentCreatorHandler } from "../getAgentCreatorHandler"; + +import { getAccountWithDetails } from "@/lib/supabase/accounts/getAccountWithDetails"; +import { ADMIN_EMAILS } from "@/lib/admin"; + +// Mock dependencies +vi.mock("@/lib/supabase/accounts/getAccountWithDetails", () => ({ + getAccountWithDetails: vi.fn(), +})); + +vi.mock("@/lib/admin", () => ({ + ADMIN_EMAILS: ["admin@example.com"], +})); + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +/** + * Creates a mock request with creatorId query param + * + * @param creatorId + */ +function createMockRequest(creatorId?: string): NextRequest { + const url = new URL("http://localhost/api/agent-creator"); + if (creatorId) { + url.searchParams.set("creatorId", creatorId); + } + return { + url: url.toString(), + nextUrl: { + searchParams: url.searchParams, + }, + } as unknown as NextRequest; +} + +describe("getAgentCreatorHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("validation", () => { + it("returns 400 when creatorId is missing", async () => { + const request = createMockRequest(); + const response = await getAgentCreatorHandler(request); + const json = await response.json(); + + expect(response.status).toBe(400); + expect(json.message).toBe("Missing creatorId"); + }); + }); + + describe("successful responses", () => { + it("returns creator info with name and image", async () => { + vi.mocked(getAccountWithDetails).mockResolvedValue({ + id: "creator-123", + name: "Test Creator", + created_at: "2024-01-01T00:00:00Z", + organization_id: null, + image: "https://example.com/avatar.jpg", + email: "testcreator@example.com", + wallet: null, + account_id: "creator-123", + }); + + const request = createMockRequest("creator-123"); + const response = await getAgentCreatorHandler(request); + const json = await response.json(); + + expect(response.status).toBe(200); + expect(json.creator).toEqual({ + name: "Test Creator", + image: "https://example.com/avatar.jpg", + is_admin: false, + }); + expect(getAccountWithDetails).toHaveBeenCalledWith("creator-123"); + }); + + it("returns is_admin true for admin emails", async () => { + vi.mocked(getAccountWithDetails).mockResolvedValue({ + id: "admin-123", + name: "Admin User", + created_at: "2024-01-01T00:00:00Z", + organization_id: null, + image: "https://example.com/admin.jpg", + email: "admin@example.com", // This matches ADMIN_EMAILS mock + wallet: null, + account_id: "admin-123", + }); + + const request = createMockRequest("admin-123"); + const response = await getAgentCreatorHandler(request); + const json = await response.json(); + + expect(response.status).toBe(200); + expect(json.creator.is_admin).toBe(true); + }); + + it("returns null values when account has no name or image", async () => { + vi.mocked(getAccountWithDetails).mockResolvedValue({ + id: "creator-456", + name: null, + created_at: "2024-01-01T00:00:00Z", + organization_id: null, + image: undefined, + email: "noinfo@example.com", + wallet: null, + account_id: "creator-456", + }); + + const request = createMockRequest("creator-456"); + const response = await getAgentCreatorHandler(request); + const json = await response.json(); + + expect(response.status).toBe(200); + expect(json.creator).toEqual({ + name: null, + image: null, + is_admin: false, + }); + }); + }); + + describe("error handling", () => { + it("returns 404 when creator not found", async () => { + vi.mocked(getAccountWithDetails).mockResolvedValue(null); + + const request = createMockRequest("nonexistent-123"); + const response = await getAgentCreatorHandler(request); + const json = await response.json(); + + expect(response.status).toBe(404); + expect(json.message).toBe("Creator not found"); + }); + + it("returns 400 when database query throws", async () => { + vi.mocked(getAccountWithDetails).mockRejectedValue(new Error("Database error")); + + const request = createMockRequest("creator-123"); + const response = await getAgentCreatorHandler(request); + const json = await response.json(); + + expect(response.status).toBe(400); + expect(json.message).toBe("Database error"); + }); + }); +}); diff --git a/lib/agentCreator/getAgentCreatorHandler.ts b/lib/agentCreator/getAgentCreatorHandler.ts new file mode 100644 index 00000000..d2711ddb --- /dev/null +++ b/lib/agentCreator/getAgentCreatorHandler.ts @@ -0,0 +1,70 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getAccountWithDetails } from "@/lib/supabase/accounts/getAccountWithDetails"; +import { ADMIN_EMAILS } from "@/lib/admin"; + +/** + * Handler for fetching agent creator information. + * + * This is a public endpoint that returns basic creator info (name, image, admin status) + * for displaying agent creator attribution in the UI. + * + * Query parameters: + * - creatorId: Required - The account ID of the agent creator + * + * @param request - The NextRequest object + * @returns A NextResponse with the creator info or an error + */ +export async function getAgentCreatorHandler(request: NextRequest): Promise { + const creatorId = request.nextUrl.searchParams.get("creatorId"); + + if (!creatorId) { + return NextResponse.json( + { message: "Missing creatorId" }, + { + status: 400, + headers: getCorsHeaders(), + }, + ); + } + + try { + const account = await getAccountWithDetails(creatorId); + + if (!account) { + return NextResponse.json( + { message: "Creator not found" }, + { + status: 404, + headers: getCorsHeaders(), + }, + ); + } + + const email = account.email || null; + const isAdmin = !!email && ADMIN_EMAILS.includes(email); + + return NextResponse.json( + { + creator: { + name: account.name || null, + image: account.image || null, + is_admin: isAdmin, + }, + }, + { + status: 200, + headers: getCorsHeaders(), + }, + ); + } catch (e) { + const message = e instanceof Error ? e.message : "failed"; + return NextResponse.json( + { message }, + { + status: 400, + headers: getCorsHeaders(), + }, + ); + } +} From ba22bbabb7f538fb75b28282e106d54018e87f98 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Fri, 16 Jan 2026 14:19:34 -0500 Subject: [PATCH 10/23] refactor: move getAgentTemplateSharesByTemplateIds to lib/supabase - Create lib/supabase/agent_template_shares/selectAgentTemplateShares.ts - Add 5 unit tests for the new function - Update getSharedEmailsForTemplates.ts to use the new function - Remove orphaned AgentTemplateShare type from types.ts - Delete old lib/agentTemplates/getAgentTemplateSharesByTemplateIds.ts This follows the established Supabase directory pattern of lib/supabase/{table_name}/select{TableName}.ts Co-Authored-By: Claude Opus 4.5 --- .../getAgentTemplateSharesByTemplateIds.ts | 25 ------- .../getSharedEmailsForTemplates.ts | 16 +++-- lib/agentTemplates/types.ts | 6 -- .../selectAgentTemplateShares.test.ts | 70 +++++++++++++++++++ .../selectAgentTemplateShares.ts | 35 ++++++++++ 5 files changed, 115 insertions(+), 37 deletions(-) delete mode 100644 lib/agentTemplates/getAgentTemplateSharesByTemplateIds.ts create mode 100644 lib/supabase/agent_template_shares/__tests__/selectAgentTemplateShares.test.ts create mode 100644 lib/supabase/agent_template_shares/selectAgentTemplateShares.ts diff --git a/lib/agentTemplates/getAgentTemplateSharesByTemplateIds.ts b/lib/agentTemplates/getAgentTemplateSharesByTemplateIds.ts deleted file mode 100644 index 5cd958c0..00000000 --- a/lib/agentTemplates/getAgentTemplateSharesByTemplateIds.ts +++ /dev/null @@ -1,25 +0,0 @@ -import supabase from "@/lib/supabase/serverClient"; -import type { AgentTemplateShare } from "./types"; - -/** - * Get all agent template shares for specific template IDs - * @param templateIds Array of template IDs to get shares for - * @returns Array of share records - */ -export async function getAgentTemplateSharesByTemplateIds( - templateIds: string[], -): Promise { - if (!Array.isArray(templateIds) || templateIds.length === 0) return []; - - const { data, error } = await supabase - .from("agent_template_shares") - .select("template_id, user_id, created_at") - .in("template_id", templateIds); - - if (error) { - console.error("Error fetching agent template shares:", error); - throw error; - } - - return (data as AgentTemplateShare[]) || []; -} diff --git a/lib/agentTemplates/getSharedEmailsForTemplates.ts b/lib/agentTemplates/getSharedEmailsForTemplates.ts index 6b7cc88a..6622c5ef 100644 --- a/lib/agentTemplates/getSharedEmailsForTemplates.ts +++ b/lib/agentTemplates/getSharedEmailsForTemplates.ts @@ -1,25 +1,29 @@ import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmails"; -import { getAgentTemplateSharesByTemplateIds } from "./getAgentTemplateSharesByTemplateIds"; +import selectAgentTemplateShares from "@/lib/supabase/agent_template_shares/selectAgentTemplateShares"; +/** + * + * @param templateIds + */ export async function getSharedEmailsForTemplates( templateIds: string[], ): Promise> { if (!templateIds || templateIds.length === 0) return {}; // Get all shares for these templates using existing utility - const shares = await getAgentTemplateSharesByTemplateIds(templateIds); + const shares = await selectAgentTemplateShares({ templateIds }); if (shares.length === 0) return {}; // Get all user IDs who have access to these templates - const userIds = [...new Set(shares.map((share) => share.user_id))]; + const userIds = [...new Set(shares.map(share => share.user_id))]; // Get emails for these users using existing utility const emails = await selectAccountEmails({ accountIds: userIds }); // Create a map of user_id to email const userEmailMap: Record = {}; - emails.forEach((emailRecord) => { + emails.forEach(emailRecord => { if (emailRecord.account_id && emailRecord.email) { if (!userEmailMap[emailRecord.account_id]) { userEmailMap[emailRecord.account_id] = []; @@ -31,7 +35,7 @@ export async function getSharedEmailsForTemplates( // Create the final map of template_id to emails const emailMap: Record = {}; - shares.forEach((share) => { + shares.forEach(share => { if (!emailMap[share.template_id]) { emailMap[share.template_id] = []; } @@ -42,7 +46,7 @@ export async function getSharedEmailsForTemplates( }); // Remove duplicates for each template - Object.keys(emailMap).forEach((templateId) => { + Object.keys(emailMap).forEach(templateId => { emailMap[templateId] = [...new Set(emailMap[templateId])]; }); diff --git a/lib/agentTemplates/types.ts b/lib/agentTemplates/types.ts index 53c7cb40..edb7c241 100644 --- a/lib/agentTemplates/types.ts +++ b/lib/agentTemplates/types.ts @@ -14,9 +14,3 @@ export interface AgentTemplateRow { // emails the template is shared with (only for private templates) shared_emails?: string[]; } - -export interface AgentTemplateShare { - template_id: string; - user_id: string; - created_at: string; -} diff --git a/lib/supabase/agent_template_shares/__tests__/selectAgentTemplateShares.test.ts b/lib/supabase/agent_template_shares/__tests__/selectAgentTemplateShares.test.ts new file mode 100644 index 00000000..f67772e7 --- /dev/null +++ b/lib/supabase/agent_template_shares/__tests__/selectAgentTemplateShares.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import selectAgentTemplateShares from "../selectAgentTemplateShares"; + +const mockFrom = vi.fn(); +const mockSelect = vi.fn(); +const mockIn = vi.fn(); + +vi.mock("@/lib/supabase/serverClient", () => ({ + default: { + from: (...args: unknown[]) => mockFrom(...args), + }, +})); + +describe("selectAgentTemplateShares", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockFrom.mockReturnValue({ select: mockSelect }); + mockSelect.mockReturnValue({ in: mockIn }); + }); + + it("returns empty array when templateIds is empty", async () => { + const result = await selectAgentTemplateShares({ templateIds: [] }); + + expect(mockFrom).not.toHaveBeenCalled(); + expect(result).toEqual([]); + }); + + it("returns empty array when templateIds is not provided", async () => { + const result = await selectAgentTemplateShares({}); + + expect(mockFrom).not.toHaveBeenCalled(); + expect(result).toEqual([]); + }); + + it("returns shares for given template IDs", async () => { + const mockShares = [ + { template_id: "tmpl-1", user_id: "user-1", created_at: "2024-01-01T00:00:00Z" }, + { template_id: "tmpl-1", user_id: "user-2", created_at: "2024-01-02T00:00:00Z" }, + { template_id: "tmpl-2", user_id: "user-1", created_at: "2024-01-03T00:00:00Z" }, + ]; + mockIn.mockResolvedValue({ data: mockShares, error: null }); + + const result = await selectAgentTemplateShares({ + templateIds: ["tmpl-1", "tmpl-2"], + }); + + expect(mockFrom).toHaveBeenCalledWith("agent_template_shares"); + expect(mockSelect).toHaveBeenCalledWith("template_id, user_id, created_at"); + expect(mockIn).toHaveBeenCalledWith("template_id", ["tmpl-1", "tmpl-2"]); + expect(result).toEqual(mockShares); + }); + + it("throws error when database query fails", async () => { + const mockError = { message: "Database connection failed" }; + mockIn.mockResolvedValue({ data: null, error: mockError }); + + await expect(selectAgentTemplateShares({ templateIds: ["tmpl-1"] })).rejects.toEqual(mockError); + }); + + it("returns empty array when data is null but no error", async () => { + mockIn.mockResolvedValue({ data: null, error: null }); + + const result = await selectAgentTemplateShares({ + templateIds: ["tmpl-1"], + }); + + expect(result).toEqual([]); + }); +}); diff --git a/lib/supabase/agent_template_shares/selectAgentTemplateShares.ts b/lib/supabase/agent_template_shares/selectAgentTemplateShares.ts new file mode 100644 index 00000000..561f2ac5 --- /dev/null +++ b/lib/supabase/agent_template_shares/selectAgentTemplateShares.ts @@ -0,0 +1,35 @@ +import supabase from "@/lib/supabase/serverClient"; + +export interface AgentTemplateShare { + template_id: string; + user_id: string; + created_at: string; +} + +/** + * Select agent template shares by template IDs + * + * @param params - The parameters for the query + * @param params.templateIds - Optional array of template IDs to get shares for + * @returns Array of share records + */ +export default async function selectAgentTemplateShares({ + templateIds, +}: { + templateIds?: string[]; +}): Promise { + if (!Array.isArray(templateIds) || templateIds.length === 0) { + return []; + } + + const { data, error } = await supabase + .from("agent_template_shares") + .select("template_id, user_id, created_at") + .in("template_id", templateIds); + + if (error) { + throw error; + } + + return (data as AgentTemplateShare[]) || []; +} From a2401724efa4fbe3775e32d2c7b027f26e2dea3d Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Fri, 16 Jan 2026 14:23:51 -0500 Subject: [PATCH 11/23] refactor: rename getSharedTemplatesForUser to getSharedTemplatesForAccount Renamed file and function to use 'Account' instead of 'User' for consistency with codebase naming conventions. This is a pure rename refactor - no logic changes were made. - Created getSharedTemplatesForAccount.ts with renamed function - Updated getUserAccessibleTemplates.ts to use new function - Added 6 unit tests for the renamed function Co-Authored-By: Claude Opus 4.5 --- .../getSharedTemplatesForAccount.test.ts | 186 ++++++++++++++++++ ...ser.ts => getSharedTemplatesForAccount.ts} | 6 +- .../getUserAccessibleTemplates.ts | 4 +- 3 files changed, 191 insertions(+), 5 deletions(-) create mode 100644 lib/agentTemplates/__tests__/getSharedTemplatesForAccount.test.ts rename lib/agentTemplates/{getSharedTemplatesForUser.ts => getSharedTemplatesForAccount.ts} (91%) diff --git a/lib/agentTemplates/__tests__/getSharedTemplatesForAccount.test.ts b/lib/agentTemplates/__tests__/getSharedTemplatesForAccount.test.ts new file mode 100644 index 00000000..17bedaab --- /dev/null +++ b/lib/agentTemplates/__tests__/getSharedTemplatesForAccount.test.ts @@ -0,0 +1,186 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import { getSharedTemplatesForAccount } from "../getSharedTemplatesForAccount"; + +const mockFrom = vi.fn(); +const mockSelect = vi.fn(); +const mockEq = vi.fn(); + +vi.mock("@/lib/supabase/serverClient", () => ({ + default: { + from: (...args: unknown[]) => mockFrom(...args), + }, +})); + +describe("getSharedTemplatesForAccount", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockFrom.mockReturnValue({ select: mockSelect }); + mockSelect.mockReturnValue({ eq: mockEq }); + }); + + it("returns empty array when no shared templates exist", async () => { + mockEq.mockResolvedValue({ data: [], error: null }); + + const result = await getSharedTemplatesForAccount("account-123"); + + expect(mockFrom).toHaveBeenCalledWith("agent_template_shares"); + expect(result).toEqual([]); + }); + + it("returns templates for account with shared templates", async () => { + const mockSharedData = [ + { + templates: { + id: "tmpl-1", + title: "Shared Template", + description: "A shared template", + prompt: "Test prompt", + tags: ["tag1"], + creator: "other-user", + is_private: true, + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + favorites_count: 0, + }, + }, + ]; + mockEq.mockResolvedValue({ data: mockSharedData, error: null }); + + const result = await getSharedTemplatesForAccount("account-123"); + + expect(mockFrom).toHaveBeenCalledWith("agent_template_shares"); + expect(mockEq).toHaveBeenCalledWith("user_id", "account-123"); + expect(result).toHaveLength(1); + expect(result[0].id).toBe("tmpl-1"); + expect(result[0].title).toBe("Shared Template"); + }); + + it("handles multiple templates and deduplicates by ID", async () => { + const mockSharedData = [ + { + templates: { + id: "tmpl-1", + title: "Template 1", + description: "First template", + prompt: "Prompt 1", + tags: [], + creator: "user-a", + is_private: true, + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + favorites_count: 0, + }, + }, + { + templates: { + id: "tmpl-1", // Duplicate + title: "Template 1", + description: "First template", + prompt: "Prompt 1", + tags: [], + creator: "user-a", + is_private: true, + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + favorites_count: 0, + }, + }, + { + templates: { + id: "tmpl-2", + title: "Template 2", + description: "Second template", + prompt: "Prompt 2", + tags: [], + creator: "user-b", + is_private: false, + created_at: "2024-01-02T00:00:00Z", + updated_at: "2024-01-02T00:00:00Z", + favorites_count: 5, + }, + }, + ]; + mockEq.mockResolvedValue({ data: mockSharedData, error: null }); + + const result = await getSharedTemplatesForAccount("account-123"); + + expect(result).toHaveLength(2); + expect(result.map((t) => t.id)).toEqual(["tmpl-1", "tmpl-2"]); + }); + + it("handles templates as array within share", async () => { + const mockSharedData = [ + { + templates: [ + { + id: "tmpl-1", + title: "Template 1", + description: "First", + prompt: "P1", + tags: [], + creator: "u1", + is_private: true, + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + favorites_count: 0, + }, + { + id: "tmpl-2", + title: "Template 2", + description: "Second", + prompt: "P2", + tags: [], + creator: "u2", + is_private: false, + created_at: "2024-01-02T00:00:00Z", + updated_at: "2024-01-02T00:00:00Z", + favorites_count: 3, + }, + ], + }, + ]; + mockEq.mockResolvedValue({ data: mockSharedData, error: null }); + + const result = await getSharedTemplatesForAccount("account-123"); + + expect(result).toHaveLength(2); + }); + + it("throws error when database query fails", async () => { + const mockError = { message: "Database connection failed" }; + mockEq.mockResolvedValue({ data: null, error: mockError }); + + await expect(getSharedTemplatesForAccount("account-123")).rejects.toEqual( + mockError, + ); + }); + + it("skips null or undefined share entries", async () => { + const mockSharedData = [ + null, + undefined, + { templates: null }, + { + templates: { + id: "tmpl-1", + title: "Valid Template", + description: "A valid template", + prompt: "Valid prompt", + tags: [], + creator: "user-1", + is_private: true, + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + favorites_count: 0, + }, + }, + ]; + mockEq.mockResolvedValue({ data: mockSharedData, error: null }); + + const result = await getSharedTemplatesForAccount("account-123"); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe("tmpl-1"); + }); +}); diff --git a/lib/agentTemplates/getSharedTemplatesForUser.ts b/lib/agentTemplates/getSharedTemplatesForAccount.ts similarity index 91% rename from lib/agentTemplates/getSharedTemplatesForUser.ts rename to lib/agentTemplates/getSharedTemplatesForAccount.ts index 5d6f5203..5b2112d8 100644 --- a/lib/agentTemplates/getSharedTemplatesForUser.ts +++ b/lib/agentTemplates/getSharedTemplatesForAccount.ts @@ -5,8 +5,8 @@ interface SharedTemplateData { templates: AgentTemplateRow | AgentTemplateRow[]; } -export async function getSharedTemplatesForUser( - userId: string, +export async function getSharedTemplatesForAccount( + accountId: string, ): Promise { const { data, error } = await supabase .from("agent_template_shares") @@ -17,7 +17,7 @@ export async function getSharedTemplatesForUser( ) `, ) - .eq("user_id", userId); + .eq("user_id", accountId); if (error) throw error; diff --git a/lib/agentTemplates/getUserAccessibleTemplates.ts b/lib/agentTemplates/getUserAccessibleTemplates.ts index 507d5121..4297aeef 100644 --- a/lib/agentTemplates/getUserAccessibleTemplates.ts +++ b/lib/agentTemplates/getUserAccessibleTemplates.ts @@ -1,6 +1,6 @@ import type { AgentTemplateRow } from "./types"; import { listAgentTemplatesForUser } from "./listAgentTemplatesForUser"; -import { getSharedTemplatesForUser } from "./getSharedTemplatesForUser"; +import { getSharedTemplatesForAccount } from "./getSharedTemplatesForAccount"; import { getUserTemplateFavorites } from "./getUserTemplateFavorites"; export async function getUserAccessibleTemplates(userId?: string | null) { @@ -9,7 +9,7 @@ export async function getUserAccessibleTemplates(userId?: string | null) { const ownedAndPublic = await listAgentTemplatesForUser(userId); // Get shared templates using dedicated utility - const sharedTemplates = await getSharedTemplatesForUser(userId); + const sharedTemplates = await getSharedTemplatesForAccount(userId); // Combine templates and avoid duplicates const allTemplates = [...ownedAndPublic]; From 6e48b24f3e630c1d52ff8b3a17e526070970cbc7 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Fri, 16 Jan 2026 14:28:23 -0500 Subject: [PATCH 12/23] refactor: use selectAgentTemplateShares in getSharedTemplatesForAccount Replace direct Supabase query with selectAgentTemplateShares function: - Extend selectAgentTemplateShares with userId and includeTemplates params - Add AgentTemplateShareWithTemplate interface for typed template joins - Update getSharedTemplatesForAccount to use the new function - Add 6 new unit tests for userId and includeTemplates functionality Co-Authored-By: Claude Opus 4.5 --- .../getSharedTemplatesForAccount.test.ts | 59 ++++++---- .../getSharedTemplatesForAccount.ts | 32 ++---- .../selectAgentTemplateShares.test.ts | 107 +++++++++++++++++- .../selectAgentTemplateShares.ts | 57 ++++++++-- 4 files changed, 204 insertions(+), 51 deletions(-) diff --git a/lib/agentTemplates/__tests__/getSharedTemplatesForAccount.test.ts b/lib/agentTemplates/__tests__/getSharedTemplatesForAccount.test.ts index 17bedaab..50d144b8 100644 --- a/lib/agentTemplates/__tests__/getSharedTemplatesForAccount.test.ts +++ b/lib/agentTemplates/__tests__/getSharedTemplatesForAccount.test.ts @@ -2,35 +2,35 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { getSharedTemplatesForAccount } from "../getSharedTemplatesForAccount"; -const mockFrom = vi.fn(); -const mockSelect = vi.fn(); -const mockEq = vi.fn(); - -vi.mock("@/lib/supabase/serverClient", () => ({ - default: { - from: (...args: unknown[]) => mockFrom(...args), - }, +const mockSelectAgentTemplateShares = vi.fn(); + +vi.mock("@/lib/supabase/agent_template_shares/selectAgentTemplateShares", () => ({ + default: (...args: unknown[]) => mockSelectAgentTemplateShares(...args), })); describe("getSharedTemplatesForAccount", () => { beforeEach(() => { vi.clearAllMocks(); - mockFrom.mockReturnValue({ select: mockSelect }); - mockSelect.mockReturnValue({ eq: mockEq }); }); it("returns empty array when no shared templates exist", async () => { - mockEq.mockResolvedValue({ data: [], error: null }); + mockSelectAgentTemplateShares.mockResolvedValue([]); const result = await getSharedTemplatesForAccount("account-123"); - expect(mockFrom).toHaveBeenCalledWith("agent_template_shares"); + expect(mockSelectAgentTemplateShares).toHaveBeenCalledWith({ + userId: "account-123", + includeTemplates: true, + }); expect(result).toEqual([]); }); it("returns templates for account with shared templates", async () => { const mockSharedData = [ { + template_id: "tmpl-1", + user_id: "account-123", + created_at: "2024-01-01T00:00:00Z", templates: { id: "tmpl-1", title: "Shared Template", @@ -45,12 +45,14 @@ describe("getSharedTemplatesForAccount", () => { }, }, ]; - mockEq.mockResolvedValue({ data: mockSharedData, error: null }); + mockSelectAgentTemplateShares.mockResolvedValue(mockSharedData); const result = await getSharedTemplatesForAccount("account-123"); - expect(mockFrom).toHaveBeenCalledWith("agent_template_shares"); - expect(mockEq).toHaveBeenCalledWith("user_id", "account-123"); + expect(mockSelectAgentTemplateShares).toHaveBeenCalledWith({ + userId: "account-123", + includeTemplates: true, + }); expect(result).toHaveLength(1); expect(result[0].id).toBe("tmpl-1"); expect(result[0].title).toBe("Shared Template"); @@ -59,6 +61,9 @@ describe("getSharedTemplatesForAccount", () => { it("handles multiple templates and deduplicates by ID", async () => { const mockSharedData = [ { + template_id: "tmpl-1", + user_id: "account-123", + created_at: "2024-01-01T00:00:00Z", templates: { id: "tmpl-1", title: "Template 1", @@ -73,6 +78,9 @@ describe("getSharedTemplatesForAccount", () => { }, }, { + template_id: "tmpl-1", + user_id: "account-123", + created_at: "2024-01-01T00:00:00Z", templates: { id: "tmpl-1", // Duplicate title: "Template 1", @@ -87,6 +95,9 @@ describe("getSharedTemplatesForAccount", () => { }, }, { + template_id: "tmpl-2", + user_id: "account-123", + created_at: "2024-01-02T00:00:00Z", templates: { id: "tmpl-2", title: "Template 2", @@ -101,7 +112,7 @@ describe("getSharedTemplatesForAccount", () => { }, }, ]; - mockEq.mockResolvedValue({ data: mockSharedData, error: null }); + mockSelectAgentTemplateShares.mockResolvedValue(mockSharedData); const result = await getSharedTemplatesForAccount("account-123"); @@ -112,6 +123,9 @@ describe("getSharedTemplatesForAccount", () => { it("handles templates as array within share", async () => { const mockSharedData = [ { + template_id: "tmpl-1", + user_id: "account-123", + created_at: "2024-01-01T00:00:00Z", templates: [ { id: "tmpl-1", @@ -140,16 +154,16 @@ describe("getSharedTemplatesForAccount", () => { ], }, ]; - mockEq.mockResolvedValue({ data: mockSharedData, error: null }); + mockSelectAgentTemplateShares.mockResolvedValue(mockSharedData); const result = await getSharedTemplatesForAccount("account-123"); expect(result).toHaveLength(2); }); - it("throws error when database query fails", async () => { + it("throws error when selectAgentTemplateShares fails", async () => { const mockError = { message: "Database connection failed" }; - mockEq.mockResolvedValue({ data: null, error: mockError }); + mockSelectAgentTemplateShares.mockRejectedValue(mockError); await expect(getSharedTemplatesForAccount("account-123")).rejects.toEqual( mockError, @@ -160,8 +174,11 @@ describe("getSharedTemplatesForAccount", () => { const mockSharedData = [ null, undefined, - { templates: null }, + { template_id: "x", user_id: "y", created_at: "z", templates: null }, { + template_id: "tmpl-1", + user_id: "account-123", + created_at: "2024-01-01T00:00:00Z", templates: { id: "tmpl-1", title: "Valid Template", @@ -176,7 +193,7 @@ describe("getSharedTemplatesForAccount", () => { }, }, ]; - mockEq.mockResolvedValue({ data: mockSharedData, error: null }); + mockSelectAgentTemplateShares.mockResolvedValue(mockSharedData); const result = await getSharedTemplatesForAccount("account-123"); diff --git a/lib/agentTemplates/getSharedTemplatesForAccount.ts b/lib/agentTemplates/getSharedTemplatesForAccount.ts index 5b2112d8..0d81fa7d 100644 --- a/lib/agentTemplates/getSharedTemplatesForAccount.ts +++ b/lib/agentTemplates/getSharedTemplatesForAccount.ts @@ -1,40 +1,30 @@ -import supabase from "@/lib/supabase/serverClient"; +import selectAgentTemplateShares, { + type AgentTemplateShareWithTemplate, +} from "@/lib/supabase/agent_template_shares/selectAgentTemplateShares"; import type { AgentTemplateRow } from "./types"; -interface SharedTemplateData { - templates: AgentTemplateRow | AgentTemplateRow[]; -} - export async function getSharedTemplatesForAccount( accountId: string, ): Promise { - const { data, error } = await supabase - .from("agent_template_shares") - .select( - ` - templates:agent_templates( - id, title, description, prompt, tags, creator, is_private, created_at, favorites_count, updated_at - ) - `, - ) - .eq("user_id", accountId); - - if (error) throw error; + const shares = (await selectAgentTemplateShares({ + userId: accountId, + includeTemplates: true, + })) as AgentTemplateShareWithTemplate[]; const templates: AgentTemplateRow[] = []; const processedIds = new Set(); - data?.forEach((share: SharedTemplateData) => { + shares?.forEach((share) => { if (!share || !share.templates) return; - // Handle both single template and array of templates + // Handle both single template and array of templates (Supabase can return either) const templateList = Array.isArray(share.templates) ? share.templates : [share.templates]; - templateList?.forEach((template: AgentTemplateRow) => { + templateList?.forEach((template) => { if (template && template.id && !processedIds.has(template.id)) { - templates.push(template); + templates.push(template as AgentTemplateRow); processedIds.add(template.id); } }); diff --git a/lib/supabase/agent_template_shares/__tests__/selectAgentTemplateShares.test.ts b/lib/supabase/agent_template_shares/__tests__/selectAgentTemplateShares.test.ts index f67772e7..74f08082 100644 --- a/lib/supabase/agent_template_shares/__tests__/selectAgentTemplateShares.test.ts +++ b/lib/supabase/agent_template_shares/__tests__/selectAgentTemplateShares.test.ts @@ -5,6 +5,7 @@ import selectAgentTemplateShares from "../selectAgentTemplateShares"; const mockFrom = vi.fn(); const mockSelect = vi.fn(); const mockIn = vi.fn(); +const mockEq = vi.fn(); vi.mock("@/lib/supabase/serverClient", () => ({ default: { @@ -16,7 +17,9 @@ describe("selectAgentTemplateShares", () => { beforeEach(() => { vi.clearAllMocks(); mockFrom.mockReturnValue({ select: mockSelect }); - mockSelect.mockReturnValue({ in: mockIn }); + mockSelect.mockReturnValue({ in: mockIn, eq: mockEq }); + mockIn.mockReturnValue({ eq: mockEq }); + mockEq.mockReturnValue({ in: mockIn }); }); it("returns empty array when templateIds is empty", async () => { @@ -67,4 +70,106 @@ describe("selectAgentTemplateShares", () => { expect(result).toEqual([]); }); + + describe("userId filtering", () => { + it("returns shares for given userId", async () => { + const mockShares = [ + { template_id: "tmpl-1", user_id: "user-1", created_at: "2024-01-01T00:00:00Z" }, + { template_id: "tmpl-2", user_id: "user-1", created_at: "2024-01-03T00:00:00Z" }, + ]; + mockEq.mockResolvedValue({ data: mockShares, error: null }); + + const result = await selectAgentTemplateShares({ + userId: "user-1", + }); + + expect(mockFrom).toHaveBeenCalledWith("agent_template_shares"); + expect(mockSelect).toHaveBeenCalledWith("template_id, user_id, created_at"); + expect(mockEq).toHaveBeenCalledWith("user_id", "user-1"); + expect(result).toEqual(mockShares); + }); + + it("returns empty array when userId is not provided and no templateIds", async () => { + const result = await selectAgentTemplateShares({}); + + expect(mockFrom).not.toHaveBeenCalled(); + expect(result).toEqual([]); + }); + + it("combines userId and templateIds filters", async () => { + const mockShares = [ + { template_id: "tmpl-1", user_id: "user-1", created_at: "2024-01-01T00:00:00Z" }, + ]; + mockEq.mockResolvedValue({ data: mockShares, error: null }); + + const result = await selectAgentTemplateShares({ + userId: "user-1", + templateIds: ["tmpl-1", "tmpl-2"], + }); + + expect(mockFrom).toHaveBeenCalledWith("agent_template_shares"); + expect(mockIn).toHaveBeenCalledWith("template_id", ["tmpl-1", "tmpl-2"]); + expect(mockEq).toHaveBeenCalledWith("user_id", "user-1"); + expect(result).toEqual(mockShares); + }); + + it("throws error when userId query fails", async () => { + const mockError = { message: "Database connection failed" }; + mockEq.mockResolvedValue({ data: null, error: mockError }); + + await expect( + selectAgentTemplateShares({ userId: "user-1" }) + ).rejects.toEqual(mockError); + }); + }); + + describe("includeTemplates option", () => { + it("returns shares with joined template data when includeTemplates is true", async () => { + const mockSharesWithTemplates = [ + { + template_id: "tmpl-1", + user_id: "user-1", + created_at: "2024-01-01T00:00:00Z", + templates: { + id: "tmpl-1", + title: "Template 1", + description: "Description 1", + prompt: "Prompt 1", + tags: ["tag1"], + creator: "creator-1", + is_private: true, + created_at: "2024-01-01T00:00:00Z", + favorites_count: 5, + updated_at: "2024-01-01T00:00:00Z", + }, + }, + ]; + mockEq.mockResolvedValue({ data: mockSharesWithTemplates, error: null }); + + const result = await selectAgentTemplateShares({ + userId: "user-1", + includeTemplates: true, + }); + + expect(mockFrom).toHaveBeenCalledWith("agent_template_shares"); + expect(mockSelect).toHaveBeenCalledWith( + expect.stringContaining("templates:agent_templates") + ); + expect(result).toEqual(mockSharesWithTemplates); + }); + + it("uses standard select when includeTemplates is false", async () => { + const mockShares = [ + { template_id: "tmpl-1", user_id: "user-1", created_at: "2024-01-01T00:00:00Z" }, + ]; + mockEq.mockResolvedValue({ data: mockShares, error: null }); + + await selectAgentTemplateShares({ + userId: "user-1", + includeTemplates: false, + }); + + expect(mockSelect).toHaveBeenCalledWith("template_id, user_id, created_at"); + }); + }); }); diff --git a/lib/supabase/agent_template_shares/selectAgentTemplateShares.ts b/lib/supabase/agent_template_shares/selectAgentTemplateShares.ts index 561f2ac5..f861ced1 100644 --- a/lib/supabase/agent_template_shares/selectAgentTemplateShares.ts +++ b/lib/supabase/agent_template_shares/selectAgentTemplateShares.ts @@ -6,30 +6,71 @@ export interface AgentTemplateShare { created_at: string; } +export interface AgentTemplateShareWithTemplate extends AgentTemplateShare { + templates: { + id: string; + title: string; + description: string; + prompt: string; + tags: string[] | null; + creator: string | null; + is_private: boolean; + created_at: string | null; + favorites_count: number | null; + updated_at: string | null; + } | null; +} + +const BASE_SELECT = "template_id, user_id, created_at"; +const TEMPLATE_JOIN_SELECT = ` + template_id, user_id, created_at, + templates:agent_templates( + id, title, description, prompt, tags, creator, is_private, created_at, favorites_count, updated_at + ) +`; + /** - * Select agent template shares by template IDs + * Select agent template shares by template IDs and/or user ID * * @param params - The parameters for the query * @param params.templateIds - Optional array of template IDs to get shares for + * @param params.userId - Optional user ID to filter shares by + * @param params.includeTemplates - Optional flag to include joined agent_templates data * @returns Array of share records */ export default async function selectAgentTemplateShares({ templateIds, + userId, + includeTemplates = false, }: { templateIds?: string[]; -}): Promise { - if (!Array.isArray(templateIds) || templateIds.length === 0) { + userId?: string; + includeTemplates?: boolean; +}): Promise { + const hasTemplateIds = Array.isArray(templateIds) && templateIds.length > 0; + const hasUserId = typeof userId === "string" && userId.length > 0; + + // If neither parameter is provided, return empty array + if (!hasTemplateIds && !hasUserId) { return []; } - const { data, error } = await supabase - .from("agent_template_shares") - .select("template_id, user_id, created_at") - .in("template_id", templateIds); + const selectFields = includeTemplates ? TEMPLATE_JOIN_SELECT : BASE_SELECT; + let query = supabase.from("agent_template_shares").select(selectFields); + + if (hasTemplateIds) { + query = query.in("template_id", templateIds); + } + + if (hasUserId) { + query = query.eq("user_id", userId); + } + + const { data, error } = await query; if (error) { throw error; } - return (data as AgentTemplateShare[]) || []; + return data || []; } From 7371e58bf3d1803b394931f9ed0fa2b4a77789a2 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Fri, 16 Jan 2026 14:32:37 -0500 Subject: [PATCH 13/23] refactor: rename getUserAccessibleTemplates to getAccountTemplates Renamed function and file for consistency with codebase 'Account' naming conventions. Updated handler and tests to use the new function. Co-Authored-By: Claude Opus 4.5 --- .../__tests__/getAccountTemplates.test.ts | 212 ++++++++++++++++++ .../getAgentTemplatesHandler.test.ts | 18 +- ...bleTemplates.ts => getAccountTemplates.ts} | 10 +- .../getAgentTemplatesHandler.ts | 6 +- 4 files changed, 229 insertions(+), 17 deletions(-) create mode 100644 lib/agentTemplates/__tests__/getAccountTemplates.test.ts rename lib/agentTemplates/{getUserAccessibleTemplates.ts => getAccountTemplates.ts} (77%) diff --git a/lib/agentTemplates/__tests__/getAccountTemplates.test.ts b/lib/agentTemplates/__tests__/getAccountTemplates.test.ts new file mode 100644 index 00000000..7479d458 --- /dev/null +++ b/lib/agentTemplates/__tests__/getAccountTemplates.test.ts @@ -0,0 +1,212 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { getAccountTemplates } from "../getAccountTemplates"; + +import { listAgentTemplatesForUser } from "../listAgentTemplatesForUser"; +import { getSharedTemplatesForAccount } from "../getSharedTemplatesForAccount"; +import { getUserTemplateFavorites } from "../getUserTemplateFavorites"; + +vi.mock("../listAgentTemplatesForUser", () => ({ + listAgentTemplatesForUser: vi.fn(), +})); + +vi.mock("../getSharedTemplatesForAccount", () => ({ + getSharedTemplatesForAccount: vi.fn(), +})); + +vi.mock("../getUserTemplateFavorites", () => ({ + getUserTemplateFavorites: vi.fn(), +})); + +describe("getAccountTemplates", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("with authenticated user", () => { + it("returns owned, public, and shared templates combined", async () => { + const ownedTemplates = [ + { + id: "template-1", + title: "Owned Template", + description: "My template", + prompt: "prompt", + tags: [], + creator: "user-123", + is_private: false, + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + favorites_count: 0, + }, + ]; + + const sharedTemplates = [ + { + id: "template-2", + title: "Shared Template", + description: "Shared with me", + prompt: "prompt", + tags: [], + creator: "other-user", + is_private: true, + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + favorites_count: 0, + }, + ]; + + vi.mocked(listAgentTemplatesForUser).mockResolvedValue(ownedTemplates); + vi.mocked(getSharedTemplatesForAccount).mockResolvedValue(sharedTemplates); + vi.mocked(getUserTemplateFavorites).mockResolvedValue( + new Set(["template-1"]), + ); + + const result = await getAccountTemplates("user-123"); + + expect(result).toHaveLength(2); + expect(result[0].id).toBe("template-1"); + expect(result[0].is_favourite).toBe(true); + expect(result[1].id).toBe("template-2"); + expect(result[1].is_favourite).toBe(false); + }); + + it("deduplicates templates that appear in both owned and shared", async () => { + const ownedTemplate = { + id: "template-1", + title: "Template", + description: "desc", + prompt: "prompt", + tags: [], + creator: "user-123", + is_private: false, + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + favorites_count: 0, + }; + + vi.mocked(listAgentTemplatesForUser).mockResolvedValue([ownedTemplate]); + vi.mocked(getSharedTemplatesForAccount).mockResolvedValue([ownedTemplate]); + vi.mocked(getUserTemplateFavorites).mockResolvedValue(new Set()); + + const result = await getAccountTemplates("user-123"); + + expect(result).toHaveLength(1); + }); + + it("marks favorited templates correctly", async () => { + const templates = [ + { + id: "template-1", + title: "Template 1", + description: "desc", + prompt: "prompt", + tags: [], + creator: "user-123", + is_private: false, + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + favorites_count: 5, + }, + { + id: "template-2", + title: "Template 2", + description: "desc", + prompt: "prompt", + tags: [], + creator: "user-123", + is_private: false, + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + favorites_count: 0, + }, + ]; + + vi.mocked(listAgentTemplatesForUser).mockResolvedValue(templates); + vi.mocked(getSharedTemplatesForAccount).mockResolvedValue([]); + vi.mocked(getUserTemplateFavorites).mockResolvedValue( + new Set(["template-1"]), + ); + + const result = await getAccountTemplates("user-123"); + + expect(result[0].is_favourite).toBe(true); + expect(result[1].is_favourite).toBe(false); + }); + }); + + describe("with anonymous user (null userId)", () => { + it("returns only public templates with is_favourite false", async () => { + const publicTemplates = [ + { + id: "public-1", + title: "Public Template", + description: "desc", + prompt: "prompt", + tags: [], + creator: "someone", + is_private: false, + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + favorites_count: 10, + }, + ]; + + vi.mocked(listAgentTemplatesForUser).mockResolvedValue(publicTemplates); + + const result = await getAccountTemplates(null); + + expect(result).toHaveLength(1); + expect(result[0].is_favourite).toBe(false); + expect(listAgentTemplatesForUser).toHaveBeenCalledWith(null); + expect(getSharedTemplatesForAccount).not.toHaveBeenCalled(); + expect(getUserTemplateFavorites).not.toHaveBeenCalled(); + }); + + it("returns only public templates when userId is undefined", async () => { + const publicTemplates = [ + { + id: "public-1", + title: "Public Template", + description: "desc", + prompt: "prompt", + tags: [], + creator: "someone", + is_private: false, + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + favorites_count: 10, + }, + ]; + + vi.mocked(listAgentTemplatesForUser).mockResolvedValue(publicTemplates); + + const result = await getAccountTemplates(undefined); + + expect(result).toHaveLength(1); + expect(result[0].is_favourite).toBe(false); + }); + + it("returns only public templates when userId is string 'undefined'", async () => { + const publicTemplates = [ + { + id: "public-1", + title: "Public Template", + description: "desc", + prompt: "prompt", + tags: [], + creator: "someone", + is_private: false, + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + favorites_count: 10, + }, + ]; + + vi.mocked(listAgentTemplatesForUser).mockResolvedValue(publicTemplates); + + const result = await getAccountTemplates("undefined"); + + expect(result).toHaveLength(1); + expect(result[0].is_favourite).toBe(false); + }); + }); +}); diff --git a/lib/agentTemplates/__tests__/getAgentTemplatesHandler.test.ts b/lib/agentTemplates/__tests__/getAgentTemplatesHandler.test.ts index 1631ae65..54b6dee1 100644 --- a/lib/agentTemplates/__tests__/getAgentTemplatesHandler.test.ts +++ b/lib/agentTemplates/__tests__/getAgentTemplatesHandler.test.ts @@ -3,7 +3,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getAgentTemplatesHandler } from "../getAgentTemplatesHandler"; import { getAuthenticatedAccountId } from "@/lib/auth/getAuthenticatedAccountId"; -import { getUserAccessibleTemplates } from "../getUserAccessibleTemplates"; +import { getAccountTemplates } from "../getAccountTemplates"; import { getSharedEmailsForTemplates } from "../getSharedEmailsForTemplates"; // Mock dependencies @@ -11,8 +11,8 @@ vi.mock("@/lib/auth/getAuthenticatedAccountId", () => ({ getAuthenticatedAccountId: vi.fn(), })); -vi.mock("../getUserAccessibleTemplates", () => ({ - getUserAccessibleTemplates: vi.fn(), +vi.mock("../getAccountTemplates", () => ({ + getAccountTemplates: vi.fn(), })); vi.mock("../getSharedEmailsForTemplates", () => ({ @@ -127,7 +127,7 @@ describe("getAgentTemplatesHandler", () => { })); vi.mocked(getAuthenticatedAccountId).mockResolvedValue("user-123"); - vi.mocked(getUserAccessibleTemplates).mockResolvedValue(mockTemplates); + vi.mocked(getAccountTemplates).mockResolvedValue(mockTemplates); vi.mocked(getSharedEmailsForTemplates).mockResolvedValue({}); const request = createMockBearerRequest("user-123"); @@ -137,7 +137,7 @@ describe("getAgentTemplatesHandler", () => { expect(response.status).toBe(200); expect(json.status).toBe("success"); expect(json.templates).toEqual(expectedTemplates); - expect(getUserAccessibleTemplates).toHaveBeenCalledWith("user-123"); + expect(getAccountTemplates).toHaveBeenCalledWith("user-123"); }); it("includes shared emails for private templates", async () => { @@ -162,7 +162,7 @@ describe("getAgentTemplatesHandler", () => { }; vi.mocked(getAuthenticatedAccountId).mockResolvedValue("user-123"); - vi.mocked(getUserAccessibleTemplates).mockResolvedValue(mockTemplates); + vi.mocked(getAccountTemplates).mockResolvedValue(mockTemplates); vi.mocked(getSharedEmailsForTemplates).mockResolvedValue(mockSharedEmails); const request = createMockBearerRequest("user-123"); @@ -195,7 +195,7 @@ describe("getAgentTemplatesHandler", () => { ]; vi.mocked(getAuthenticatedAccountId).mockResolvedValue("user-123"); - vi.mocked(getUserAccessibleTemplates).mockResolvedValue(mockTemplates); + vi.mocked(getAccountTemplates).mockResolvedValue(mockTemplates); const request = createMockBearerRequest("user-123"); const response = await getAgentTemplatesHandler(request); @@ -208,9 +208,9 @@ describe("getAgentTemplatesHandler", () => { }); describe("error handling", () => { - it("returns 500 when getUserAccessibleTemplates throws", async () => { + it("returns 500 when getAccountTemplates throws", async () => { vi.mocked(getAuthenticatedAccountId).mockResolvedValue("user-123"); - vi.mocked(getUserAccessibleTemplates).mockRejectedValue( + vi.mocked(getAccountTemplates).mockRejectedValue( new Error("Database error"), ); diff --git a/lib/agentTemplates/getUserAccessibleTemplates.ts b/lib/agentTemplates/getAccountTemplates.ts similarity index 77% rename from lib/agentTemplates/getUserAccessibleTemplates.ts rename to lib/agentTemplates/getAccountTemplates.ts index 4297aeef..94ba7aa9 100644 --- a/lib/agentTemplates/getUserAccessibleTemplates.ts +++ b/lib/agentTemplates/getAccountTemplates.ts @@ -3,13 +3,13 @@ import { listAgentTemplatesForUser } from "./listAgentTemplatesForUser"; import { getSharedTemplatesForAccount } from "./getSharedTemplatesForAccount"; import { getUserTemplateFavorites } from "./getUserTemplateFavorites"; -export async function getUserAccessibleTemplates(userId?: string | null) { - if (userId && userId !== "undefined") { +export async function getAccountTemplates(accountId?: string | null) { + if (accountId && accountId !== "undefined") { // Get owned and public templates - const ownedAndPublic = await listAgentTemplatesForUser(userId); + const ownedAndPublic = await listAgentTemplatesForUser(accountId); // Get shared templates using dedicated utility - const sharedTemplates = await getSharedTemplatesForAccount(userId); + const sharedTemplates = await getSharedTemplatesForAccount(accountId); // Combine templates and avoid duplicates const allTemplates = [...ownedAndPublic]; @@ -23,7 +23,7 @@ export async function getUserAccessibleTemplates(userId?: string | null) { }); // Get user's favorite templates - const favouriteIds = await getUserTemplateFavorites(userId); + const favouriteIds = await getUserTemplateFavorites(accountId); // Mark favorites return allTemplates.map((template: AgentTemplateRow) => ({ diff --git a/lib/agentTemplates/getAgentTemplatesHandler.ts b/lib/agentTemplates/getAgentTemplatesHandler.ts index a59e8bf0..df34067d 100644 --- a/lib/agentTemplates/getAgentTemplatesHandler.ts +++ b/lib/agentTemplates/getAgentTemplatesHandler.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { getAuthenticatedAccountId } from "@/lib/auth/getAuthenticatedAccountId"; -import { getUserAccessibleTemplates } from "./getUserAccessibleTemplates"; +import { getAccountTemplates } from "./getAccountTemplates"; import { getSharedEmailsForTemplates } from "./getSharedEmailsForTemplates"; /** @@ -31,8 +31,8 @@ export async function getAgentTemplatesHandler( const { searchParams } = new URL(request.url); const userId = searchParams.get("userId") || accountId; - // Fetch templates accessible to the user - const templates = await getUserAccessibleTemplates(userId); + // Fetch templates accessible to the account + const templates = await getAccountTemplates(userId); // Get shared emails for private templates const privateTemplateIds = templates From fa1b91dbb51979561a753f1b346bcd8012111b51 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Fri, 16 Jan 2026 14:36:24 -0500 Subject: [PATCH 14/23] refactor: move getUserTemplateFavorites to lib/supabase - Create selectAgentTemplateFavorites in lib/supabase/agent_template_favorites/ - Add 6 unit tests for new function - Update getAccountTemplates to use new function - Delete old getUserTemplateFavorites.ts Co-Authored-By: Claude Opus 4.5 --- .../__tests__/getAccountTemplates.test.ts | 14 ++-- lib/agentTemplates/getAccountTemplates.ts | 4 +- .../getUserTemplateFavorites.ts | 20 ----- .../selectAgentTemplateFavorites.test.ts | 76 +++++++++++++++++++ .../selectAgentTemplateFavorites.ts | 38 ++++++++++ 5 files changed, 123 insertions(+), 29 deletions(-) delete mode 100644 lib/agentTemplates/getUserTemplateFavorites.ts create mode 100644 lib/supabase/agent_template_favorites/__tests__/selectAgentTemplateFavorites.test.ts create mode 100644 lib/supabase/agent_template_favorites/selectAgentTemplateFavorites.ts diff --git a/lib/agentTemplates/__tests__/getAccountTemplates.test.ts b/lib/agentTemplates/__tests__/getAccountTemplates.test.ts index 7479d458..58b3acdc 100644 --- a/lib/agentTemplates/__tests__/getAccountTemplates.test.ts +++ b/lib/agentTemplates/__tests__/getAccountTemplates.test.ts @@ -3,7 +3,7 @@ import { getAccountTemplates } from "../getAccountTemplates"; import { listAgentTemplatesForUser } from "../listAgentTemplatesForUser"; import { getSharedTemplatesForAccount } from "../getSharedTemplatesForAccount"; -import { getUserTemplateFavorites } from "../getUserTemplateFavorites"; +import selectAgentTemplateFavorites from "@/lib/supabase/agent_template_favorites/selectAgentTemplateFavorites"; vi.mock("../listAgentTemplatesForUser", () => ({ listAgentTemplatesForUser: vi.fn(), @@ -13,8 +13,8 @@ vi.mock("../getSharedTemplatesForAccount", () => ({ getSharedTemplatesForAccount: vi.fn(), })); -vi.mock("../getUserTemplateFavorites", () => ({ - getUserTemplateFavorites: vi.fn(), +vi.mock("@/lib/supabase/agent_template_favorites/selectAgentTemplateFavorites", () => ({ + default: vi.fn(), })); describe("getAccountTemplates", () => { @@ -56,7 +56,7 @@ describe("getAccountTemplates", () => { vi.mocked(listAgentTemplatesForUser).mockResolvedValue(ownedTemplates); vi.mocked(getSharedTemplatesForAccount).mockResolvedValue(sharedTemplates); - vi.mocked(getUserTemplateFavorites).mockResolvedValue( + vi.mocked(selectAgentTemplateFavorites).mockResolvedValue( new Set(["template-1"]), ); @@ -85,7 +85,7 @@ describe("getAccountTemplates", () => { vi.mocked(listAgentTemplatesForUser).mockResolvedValue([ownedTemplate]); vi.mocked(getSharedTemplatesForAccount).mockResolvedValue([ownedTemplate]); - vi.mocked(getUserTemplateFavorites).mockResolvedValue(new Set()); + vi.mocked(selectAgentTemplateFavorites).mockResolvedValue(new Set()); const result = await getAccountTemplates("user-123"); @@ -122,7 +122,7 @@ describe("getAccountTemplates", () => { vi.mocked(listAgentTemplatesForUser).mockResolvedValue(templates); vi.mocked(getSharedTemplatesForAccount).mockResolvedValue([]); - vi.mocked(getUserTemplateFavorites).mockResolvedValue( + vi.mocked(selectAgentTemplateFavorites).mockResolvedValue( new Set(["template-1"]), ); @@ -158,7 +158,7 @@ describe("getAccountTemplates", () => { expect(result[0].is_favourite).toBe(false); expect(listAgentTemplatesForUser).toHaveBeenCalledWith(null); expect(getSharedTemplatesForAccount).not.toHaveBeenCalled(); - expect(getUserTemplateFavorites).not.toHaveBeenCalled(); + expect(selectAgentTemplateFavorites).not.toHaveBeenCalled(); }); it("returns only public templates when userId is undefined", async () => { diff --git a/lib/agentTemplates/getAccountTemplates.ts b/lib/agentTemplates/getAccountTemplates.ts index 94ba7aa9..b38b3462 100644 --- a/lib/agentTemplates/getAccountTemplates.ts +++ b/lib/agentTemplates/getAccountTemplates.ts @@ -1,7 +1,7 @@ import type { AgentTemplateRow } from "./types"; import { listAgentTemplatesForUser } from "./listAgentTemplatesForUser"; import { getSharedTemplatesForAccount } from "./getSharedTemplatesForAccount"; -import { getUserTemplateFavorites } from "./getUserTemplateFavorites"; +import selectAgentTemplateFavorites from "@/lib/supabase/agent_template_favorites/selectAgentTemplateFavorites"; export async function getAccountTemplates(accountId?: string | null) { if (accountId && accountId !== "undefined") { @@ -23,7 +23,7 @@ export async function getAccountTemplates(accountId?: string | null) { }); // Get user's favorite templates - const favouriteIds = await getUserTemplateFavorites(accountId); + const favouriteIds = await selectAgentTemplateFavorites({ userId: accountId }); // Mark favorites return allTemplates.map((template: AgentTemplateRow) => ({ diff --git a/lib/agentTemplates/getUserTemplateFavorites.ts b/lib/agentTemplates/getUserTemplateFavorites.ts deleted file mode 100644 index f47af1f3..00000000 --- a/lib/agentTemplates/getUserTemplateFavorites.ts +++ /dev/null @@ -1,20 +0,0 @@ -import supabase from "@/lib/supabase/serverClient"; - -interface TemplateFavorite { - template_id: string; -} - -export async function getUserTemplateFavorites( - userId: string, -): Promise> { - const { data, error } = await supabase - .from("agent_template_favorites") - .select("template_id") - .eq("user_id", userId); - - if (error) throw error; - - return new Set( - (data || []).map((f: TemplateFavorite) => f.template_id), - ); -} diff --git a/lib/supabase/agent_template_favorites/__tests__/selectAgentTemplateFavorites.test.ts b/lib/supabase/agent_template_favorites/__tests__/selectAgentTemplateFavorites.test.ts new file mode 100644 index 00000000..02c1a3c6 --- /dev/null +++ b/lib/supabase/agent_template_favorites/__tests__/selectAgentTemplateFavorites.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import selectAgentTemplateFavorites from "../selectAgentTemplateFavorites"; + +const mockFrom = vi.fn(); +const mockSelect = vi.fn(); +const mockEq = vi.fn(); + +vi.mock("@/lib/supabase/serverClient", () => ({ + default: { + from: (...args: unknown[]) => mockFrom(...args), + }, +})); + +describe("selectAgentTemplateFavorites", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockFrom.mockReturnValue({ select: mockSelect }); + mockSelect.mockReturnValue({ eq: mockEq }); + }); + + it("returns empty set when userId is not provided", async () => { + const result = await selectAgentTemplateFavorites({}); + + expect(mockFrom).not.toHaveBeenCalled(); + expect(result).toEqual(new Set()); + }); + + it("returns empty set when userId is empty string", async () => { + const result = await selectAgentTemplateFavorites({ userId: "" }); + + expect(mockFrom).not.toHaveBeenCalled(); + expect(result).toEqual(new Set()); + }); + + it("returns favorite template IDs as Set for given userId", async () => { + const mockFavorites = [ + { template_id: "tmpl-1" }, + { template_id: "tmpl-2" }, + { template_id: "tmpl-3" }, + ]; + mockEq.mockResolvedValue({ data: mockFavorites, error: null }); + + const result = await selectAgentTemplateFavorites({ userId: "user-1" }); + + expect(mockFrom).toHaveBeenCalledWith("agent_template_favorites"); + expect(mockSelect).toHaveBeenCalledWith("template_id"); + expect(mockEq).toHaveBeenCalledWith("user_id", "user-1"); + expect(result).toEqual(new Set(["tmpl-1", "tmpl-2", "tmpl-3"])); + }); + + it("throws error when database query fails", async () => { + const mockError = { message: "Database connection failed" }; + mockEq.mockResolvedValue({ data: null, error: mockError }); + + await expect( + selectAgentTemplateFavorites({ userId: "user-1" }) + ).rejects.toEqual(mockError); + }); + + it("returns empty set when data is null but no error", async () => { + mockEq.mockResolvedValue({ data: null, error: null }); + + const result = await selectAgentTemplateFavorites({ userId: "user-1" }); + + expect(result).toEqual(new Set()); + }); + + it("returns empty set when data is empty array", async () => { + mockEq.mockResolvedValue({ data: [], error: null }); + + const result = await selectAgentTemplateFavorites({ userId: "user-1" }); + + expect(result).toEqual(new Set()); + }); +}); diff --git a/lib/supabase/agent_template_favorites/selectAgentTemplateFavorites.ts b/lib/supabase/agent_template_favorites/selectAgentTemplateFavorites.ts new file mode 100644 index 00000000..8bfd3575 --- /dev/null +++ b/lib/supabase/agent_template_favorites/selectAgentTemplateFavorites.ts @@ -0,0 +1,38 @@ +import supabase from "@/lib/supabase/serverClient"; + +interface TemplateFavorite { + template_id: string; +} + +/** + * Select agent template favorites for a user + * + * @param params - The parameters for the query + * @param params.userId - The user ID to get favorites for + * @returns Set of favorite template IDs + */ +export default async function selectAgentTemplateFavorites({ + userId, +}: { + userId?: string; +}): Promise> { + const hasUserId = typeof userId === "string" && userId.length > 0; + + // If no userId is provided, return empty set + if (!hasUserId) { + return new Set(); + } + + const { data, error } = await supabase + .from("agent_template_favorites") + .select("template_id") + .eq("user_id", userId); + + if (error) { + throw error; + } + + return new Set( + (data || []).map((f: TemplateFavorite) => f.template_id) + ); +} From b818e584159548772695433418d0821661af2095 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Fri, 16 Jan 2026 14:39:58 -0500 Subject: [PATCH 15/23] refactor: move addAgentTemplateFavorite to lib/supabase Move addAgentTemplateFavorite to lib/supabase/agent_template_favorites/insertAgentTemplateFavorite.ts to follow the Supabase directory pattern established in the codebase. Added 5 unit tests for the new function. Co-Authored-By: Claude Opus 4.5 --- ...toggleAgentTemplateFavoriteHandler.test.ts | 16 +-- .../toggleAgentTemplateFavoriteHandler.ts | 4 +- .../insertAgentTemplateFavorite.test.ts | 100 ++++++++++++++++++ .../insertAgentTemplateFavorite.ts} | 18 ++-- 4 files changed, 121 insertions(+), 17 deletions(-) create mode 100644 lib/supabase/agent_template_favorites/__tests__/insertAgentTemplateFavorite.test.ts rename lib/{agentTemplates/addAgentTemplateFavorite.ts => supabase/agent_template_favorites/insertAgentTemplateFavorite.ts} (57%) diff --git a/lib/agentTemplates/__tests__/toggleAgentTemplateFavoriteHandler.test.ts b/lib/agentTemplates/__tests__/toggleAgentTemplateFavoriteHandler.test.ts index 1f4fcc4b..65ede12d 100644 --- a/lib/agentTemplates/__tests__/toggleAgentTemplateFavoriteHandler.test.ts +++ b/lib/agentTemplates/__tests__/toggleAgentTemplateFavoriteHandler.test.ts @@ -3,7 +3,7 @@ import { NextRequest, NextResponse } from "next/server"; import { toggleAgentTemplateFavoriteHandler } from "../toggleAgentTemplateFavoriteHandler"; import { getAuthenticatedAccountId } from "@/lib/auth/getAuthenticatedAccountId"; -import { addAgentTemplateFavorite } from "../addAgentTemplateFavorite"; +import insertAgentTemplateFavorite from "@/lib/supabase/agent_template_favorites/insertAgentTemplateFavorite"; import { removeAgentTemplateFavorite } from "../removeAgentTemplateFavorite"; // Mock dependencies @@ -11,8 +11,8 @@ vi.mock("@/lib/auth/getAuthenticatedAccountId", () => ({ getAuthenticatedAccountId: vi.fn(), })); -vi.mock("../addAgentTemplateFavorite", () => ({ - addAgentTemplateFavorite: vi.fn(), +vi.mock("@/lib/supabase/agent_template_favorites/insertAgentTemplateFavorite", () => ({ + default: vi.fn(), })); vi.mock("../removeAgentTemplateFavorite", () => ({ @@ -123,7 +123,7 @@ describe("toggleAgentTemplateFavoriteHandler", () => { describe("with valid authentication", () => { it("adds favorite when isFavourite is true", async () => { vi.mocked(getAuthenticatedAccountId).mockResolvedValue("user-123"); - vi.mocked(addAgentTemplateFavorite).mockResolvedValue({ success: true }); + vi.mocked(insertAgentTemplateFavorite).mockResolvedValue({ success: true }); const request = createMockBearerRequest({ templateId: "template-1", isFavourite: true }); const response = await toggleAgentTemplateFavoriteHandler(request); @@ -131,7 +131,7 @@ describe("toggleAgentTemplateFavoriteHandler", () => { expect(response.status).toBe(200); expect(json.success).toBe(true); - expect(addAgentTemplateFavorite).toHaveBeenCalledWith("template-1", "user-123"); + expect(insertAgentTemplateFavorite).toHaveBeenCalledWith({ templateId: "template-1", userId: "user-123" }); expect(removeAgentTemplateFavorite).not.toHaveBeenCalled(); }); @@ -146,14 +146,14 @@ describe("toggleAgentTemplateFavoriteHandler", () => { expect(response.status).toBe(200); expect(json.success).toBe(true); expect(removeAgentTemplateFavorite).toHaveBeenCalledWith("template-1", "user-123"); - expect(addAgentTemplateFavorite).not.toHaveBeenCalled(); + expect(insertAgentTemplateFavorite).not.toHaveBeenCalled(); }); }); describe("error handling", () => { - it("returns 500 when addAgentTemplateFavorite throws", async () => { + it("returns 500 when insertAgentTemplateFavorite throws", async () => { vi.mocked(getAuthenticatedAccountId).mockResolvedValue("user-123"); - vi.mocked(addAgentTemplateFavorite).mockRejectedValue(new Error("Database error")); + vi.mocked(insertAgentTemplateFavorite).mockRejectedValue(new Error("Database error")); const request = createMockBearerRequest({ templateId: "template-1", isFavourite: true }); const response = await toggleAgentTemplateFavoriteHandler(request); diff --git a/lib/agentTemplates/toggleAgentTemplateFavoriteHandler.ts b/lib/agentTemplates/toggleAgentTemplateFavoriteHandler.ts index 4110bdcb..e40d5c0c 100644 --- a/lib/agentTemplates/toggleAgentTemplateFavoriteHandler.ts +++ b/lib/agentTemplates/toggleAgentTemplateFavoriteHandler.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { getAuthenticatedAccountId } from "@/lib/auth/getAuthenticatedAccountId"; -import { addAgentTemplateFavorite } from "./addAgentTemplateFavorite"; +import insertAgentTemplateFavorite from "@/lib/supabase/agent_template_favorites/insertAgentTemplateFavorite"; import { removeAgentTemplateFavorite } from "./removeAgentTemplateFavorite"; interface ToggleFavoriteRequestBody { @@ -66,7 +66,7 @@ export async function toggleAgentTemplateFavoriteHandler( // Toggle favorite if (isFavourite) { - await addAgentTemplateFavorite(templateId, accountId); + await insertAgentTemplateFavorite({ templateId, userId: accountId }); } else { await removeAgentTemplateFavorite(templateId, accountId); } diff --git a/lib/supabase/agent_template_favorites/__tests__/insertAgentTemplateFavorite.test.ts b/lib/supabase/agent_template_favorites/__tests__/insertAgentTemplateFavorite.test.ts new file mode 100644 index 00000000..28e5ac52 --- /dev/null +++ b/lib/supabase/agent_template_favorites/__tests__/insertAgentTemplateFavorite.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import insertAgentTemplateFavorite from "../insertAgentTemplateFavorite"; + +const mockFrom = vi.fn(); +const mockInsert = vi.fn(); +const mockSelect = vi.fn(); +const mockMaybeSingle = vi.fn(); + +vi.mock("@/lib/supabase/serverClient", () => ({ + default: { + from: (...args: unknown[]) => mockFrom(...args), + }, +})); + +describe("insertAgentTemplateFavorite", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockFrom.mockReturnValue({ insert: mockInsert }); + mockInsert.mockReturnValue({ select: mockSelect }); + mockSelect.mockReturnValue({ maybeSingle: mockMaybeSingle }); + }); + + it("inserts a favorite and returns success", async () => { + mockMaybeSingle.mockResolvedValue({ + data: { template_id: "tmpl-1" }, + error: null, + }); + + const result = await insertAgentTemplateFavorite({ + templateId: "tmpl-1", + userId: "user-1", + }); + + expect(mockFrom).toHaveBeenCalledWith("agent_template_favorites"); + expect(mockInsert).toHaveBeenCalledWith({ + template_id: "tmpl-1", + user_id: "user-1", + }); + expect(mockSelect).toHaveBeenCalledWith("template_id"); + expect(mockMaybeSingle).toHaveBeenCalled(); + expect(result).toEqual({ success: true }); + }); + + it("returns success when favorite already exists (duplicate key error)", async () => { + mockMaybeSingle.mockResolvedValue({ + data: null, + error: { code: "23505", message: "duplicate key value violates unique constraint" }, + }); + + const result = await insertAgentTemplateFavorite({ + templateId: "tmpl-1", + userId: "user-1", + }); + + expect(result).toEqual({ success: true }); + }); + + it("throws error when database operation fails with non-duplicate error", async () => { + const mockError = { code: "42P01", message: "relation does not exist" }; + mockMaybeSingle.mockResolvedValue({ data: null, error: mockError }); + + await expect( + insertAgentTemplateFavorite({ + templateId: "tmpl-1", + userId: "user-1", + }) + ).rejects.toEqual(mockError); + }); + + it("throws error when database connection fails", async () => { + const mockError = { code: "08006", message: "connection_failure" }; + mockMaybeSingle.mockResolvedValue({ data: null, error: mockError }); + + await expect( + insertAgentTemplateFavorite({ + templateId: "tmpl-1", + userId: "user-1", + }) + ).rejects.toEqual(mockError); + }); + + it("handles different template and user IDs correctly", async () => { + mockMaybeSingle.mockResolvedValue({ + data: { template_id: "different-tmpl" }, + error: null, + }); + + const result = await insertAgentTemplateFavorite({ + templateId: "different-tmpl", + userId: "different-user", + }); + + expect(mockInsert).toHaveBeenCalledWith({ + template_id: "different-tmpl", + user_id: "different-user", + }); + expect(result).toEqual({ success: true }); + }); +}); diff --git a/lib/agentTemplates/addAgentTemplateFavorite.ts b/lib/supabase/agent_template_favorites/insertAgentTemplateFavorite.ts similarity index 57% rename from lib/agentTemplates/addAgentTemplateFavorite.ts rename to lib/supabase/agent_template_favorites/insertAgentTemplateFavorite.ts index bd2569d4..1c706571 100644 --- a/lib/agentTemplates/addAgentTemplateFavorite.ts +++ b/lib/supabase/agent_template_favorites/insertAgentTemplateFavorite.ts @@ -1,17 +1,21 @@ import supabase from "@/lib/supabase/serverClient"; /** - * Adds an agent template to a user's favorites. + * Insert an agent template favorite for a user * - * @param templateId - The ID of the template to favorite - * @param userId - The ID of the user adding the favorite + * @param params - The parameters for the insert + * @param params.templateId - The ID of the template to favorite + * @param params.userId - The ID of the user adding the favorite * @returns An object with success: true * @throws Error if the database operation fails (except for duplicate entries) */ -export async function addAgentTemplateFavorite( - templateId: string, - userId: string, -): Promise<{ success: true }> { +export default async function insertAgentTemplateFavorite({ + templateId, + userId, +}: { + templateId: string; + userId: string; +}): Promise<{ success: true }> { const { error } = await supabase .from("agent_template_favorites") .insert({ template_id: templateId, user_id: userId }) From ede2f061e9f9c2665967b1d1e347ef8da4d36222 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Fri, 16 Jan 2026 14:51:18 -0500 Subject: [PATCH 16/23] refactor: simplify selectAgentTemplateShares query and types (DRY) - Use select('*') instead of explicit column lists - Replace custom interfaces with Tables<'agent_template_shares'> - Replace AgentTemplateShareWithTemplate with database types - Update tests to verify new select patterns Co-Authored-By: Claude Opus 4.5 --- .../selectAgentTemplateShares.test.ts | 20 ++++++----- .../selectAgentTemplateShares.ts | 36 +++++-------------- 2 files changed, 19 insertions(+), 37 deletions(-) diff --git a/lib/supabase/agent_template_shares/__tests__/selectAgentTemplateShares.test.ts b/lib/supabase/agent_template_shares/__tests__/selectAgentTemplateShares.test.ts index 74f08082..ddff5e38 100644 --- a/lib/supabase/agent_template_shares/__tests__/selectAgentTemplateShares.test.ts +++ b/lib/supabase/agent_template_shares/__tests__/selectAgentTemplateShares.test.ts @@ -36,7 +36,7 @@ describe("selectAgentTemplateShares", () => { expect(result).toEqual([]); }); - it("returns shares for given template IDs", async () => { + it("returns shares for given template IDs using select('*')", async () => { const mockShares = [ { template_id: "tmpl-1", user_id: "user-1", created_at: "2024-01-01T00:00:00Z" }, { template_id: "tmpl-1", user_id: "user-2", created_at: "2024-01-02T00:00:00Z" }, @@ -49,7 +49,8 @@ describe("selectAgentTemplateShares", () => { }); expect(mockFrom).toHaveBeenCalledWith("agent_template_shares"); - expect(mockSelect).toHaveBeenCalledWith("template_id, user_id, created_at"); + // DRY: Use select('*') instead of explicit columns + expect(mockSelect).toHaveBeenCalledWith("*"); expect(mockIn).toHaveBeenCalledWith("template_id", ["tmpl-1", "tmpl-2"]); expect(result).toEqual(mockShares); }); @@ -72,7 +73,7 @@ describe("selectAgentTemplateShares", () => { }); describe("userId filtering", () => { - it("returns shares for given userId", async () => { + it("returns shares for given userId using select('*')", async () => { const mockShares = [ { template_id: "tmpl-1", user_id: "user-1", created_at: "2024-01-01T00:00:00Z" }, { template_id: "tmpl-2", user_id: "user-1", created_at: "2024-01-03T00:00:00Z" }, @@ -84,7 +85,8 @@ describe("selectAgentTemplateShares", () => { }); expect(mockFrom).toHaveBeenCalledWith("agent_template_shares"); - expect(mockSelect).toHaveBeenCalledWith("template_id, user_id, created_at"); + // DRY: Use select('*') instead of explicit columns + expect(mockSelect).toHaveBeenCalledWith("*"); expect(mockEq).toHaveBeenCalledWith("user_id", "user-1"); expect(result).toEqual(mockShares); }); @@ -152,13 +154,12 @@ describe("selectAgentTemplateShares", () => { }); expect(mockFrom).toHaveBeenCalledWith("agent_template_shares"); - expect(mockSelect).toHaveBeenCalledWith( - expect.stringContaining("templates:agent_templates") - ); + // DRY: Use *, templates:agent_templates(*) for joins + expect(mockSelect).toHaveBeenCalledWith("*, templates:agent_templates(*)"); expect(result).toEqual(mockSharesWithTemplates); }); - it("uses standard select when includeTemplates is false", async () => { + it("uses select('*') when includeTemplates is false", async () => { const mockShares = [ { template_id: "tmpl-1", user_id: "user-1", created_at: "2024-01-01T00:00:00Z" }, ]; @@ -169,7 +170,8 @@ describe("selectAgentTemplateShares", () => { includeTemplates: false, }); - expect(mockSelect).toHaveBeenCalledWith("template_id, user_id, created_at"); + // DRY: Use select('*') instead of explicit columns + expect(mockSelect).toHaveBeenCalledWith("*"); }); }); }); diff --git a/lib/supabase/agent_template_shares/selectAgentTemplateShares.ts b/lib/supabase/agent_template_shares/selectAgentTemplateShares.ts index f861ced1..7abf78de 100644 --- a/lib/supabase/agent_template_shares/selectAgentTemplateShares.ts +++ b/lib/supabase/agent_template_shares/selectAgentTemplateShares.ts @@ -1,33 +1,12 @@ import supabase from "@/lib/supabase/serverClient"; +import type { Tables } from "@/types/database.types"; -export interface AgentTemplateShare { - template_id: string; - user_id: string; - created_at: string; -} - -export interface AgentTemplateShareWithTemplate extends AgentTemplateShare { - templates: { - id: string; - title: string; - description: string; - prompt: string; - tags: string[] | null; - creator: string | null; - is_private: boolean; - created_at: string | null; - favorites_count: number | null; - updated_at: string | null; - } | null; -} +// DRY: Use database types instead of custom interfaces +export type AgentTemplateShare = Tables<"agent_template_shares">; -const BASE_SELECT = "template_id, user_id, created_at"; -const TEMPLATE_JOIN_SELECT = ` - template_id, user_id, created_at, - templates:agent_templates( - id, title, description, prompt, tags, creator, is_private, created_at, favorites_count, updated_at - ) -`; +export type AgentTemplateShareWithTemplate = AgentTemplateShare & { + templates: Tables<"agent_templates"> | null; +}; /** * Select agent template shares by template IDs and/or user ID @@ -55,7 +34,8 @@ export default async function selectAgentTemplateShares({ return []; } - const selectFields = includeTemplates ? TEMPLATE_JOIN_SELECT : BASE_SELECT; + // DRY: Use select('*') instead of explicit columns + const selectFields = includeTemplates ? "*, templates:agent_templates(*)" : "*"; let query = supabase.from("agent_template_shares").select(selectFields); if (hasTemplateIds) { From 3608c28244e03d807be5ecae8e2c4f0727a83b63 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Fri, 16 Jan 2026 14:55:20 -0500 Subject: [PATCH 17/23] refactor: simplify selectAgentTemplateFavorites query and types (DRY) - Use select('*') instead of explicit column selection - Replace custom TemplateFavorite interface with Tables<'agent_template_favorites'> - Return full records instead of Set - Update getAccountTemplates to convert records to Set for backward compatibility Co-Authored-By: Claude Opus 4.5 --- .../__tests__/getAccountTemplates.test.ts | 14 ++++----- lib/agentTemplates/getAccountTemplates.ts | 5 ++-- .../selectAgentTemplateFavorites.test.ts | 29 ++++++++++--------- .../selectAgentTemplateFavorites.ts | 21 +++++++------- 4 files changed, 35 insertions(+), 34 deletions(-) diff --git a/lib/agentTemplates/__tests__/getAccountTemplates.test.ts b/lib/agentTemplates/__tests__/getAccountTemplates.test.ts index 58b3acdc..033d93d3 100644 --- a/lib/agentTemplates/__tests__/getAccountTemplates.test.ts +++ b/lib/agentTemplates/__tests__/getAccountTemplates.test.ts @@ -56,9 +56,9 @@ describe("getAccountTemplates", () => { vi.mocked(listAgentTemplatesForUser).mockResolvedValue(ownedTemplates); vi.mocked(getSharedTemplatesForAccount).mockResolvedValue(sharedTemplates); - vi.mocked(selectAgentTemplateFavorites).mockResolvedValue( - new Set(["template-1"]), - ); + vi.mocked(selectAgentTemplateFavorites).mockResolvedValue([ + { template_id: "template-1", user_id: "user-123", created_at: null }, + ]); const result = await getAccountTemplates("user-123"); @@ -85,7 +85,7 @@ describe("getAccountTemplates", () => { vi.mocked(listAgentTemplatesForUser).mockResolvedValue([ownedTemplate]); vi.mocked(getSharedTemplatesForAccount).mockResolvedValue([ownedTemplate]); - vi.mocked(selectAgentTemplateFavorites).mockResolvedValue(new Set()); + vi.mocked(selectAgentTemplateFavorites).mockResolvedValue([]); const result = await getAccountTemplates("user-123"); @@ -122,9 +122,9 @@ describe("getAccountTemplates", () => { vi.mocked(listAgentTemplatesForUser).mockResolvedValue(templates); vi.mocked(getSharedTemplatesForAccount).mockResolvedValue([]); - vi.mocked(selectAgentTemplateFavorites).mockResolvedValue( - new Set(["template-1"]), - ); + vi.mocked(selectAgentTemplateFavorites).mockResolvedValue([ + { template_id: "template-1", user_id: "user-123", created_at: null }, + ]); const result = await getAccountTemplates("user-123"); diff --git a/lib/agentTemplates/getAccountTemplates.ts b/lib/agentTemplates/getAccountTemplates.ts index b38b3462..7a64a24f 100644 --- a/lib/agentTemplates/getAccountTemplates.ts +++ b/lib/agentTemplates/getAccountTemplates.ts @@ -22,8 +22,9 @@ export async function getAccountTemplates(accountId?: string | null) { } }); - // Get user's favorite templates - const favouriteIds = await selectAgentTemplateFavorites({ userId: accountId }); + // Get user's favorite templates and convert to Set of IDs + const favourites = await selectAgentTemplateFavorites({ userId: accountId }); + const favouriteIds = new Set(favourites.map((f) => f.template_id)); // Mark favorites return allTemplates.map((template: AgentTemplateRow) => ({ diff --git a/lib/supabase/agent_template_favorites/__tests__/selectAgentTemplateFavorites.test.ts b/lib/supabase/agent_template_favorites/__tests__/selectAgentTemplateFavorites.test.ts index 02c1a3c6..7e038b4f 100644 --- a/lib/supabase/agent_template_favorites/__tests__/selectAgentTemplateFavorites.test.ts +++ b/lib/supabase/agent_template_favorites/__tests__/selectAgentTemplateFavorites.test.ts @@ -19,34 +19,35 @@ describe("selectAgentTemplateFavorites", () => { mockSelect.mockReturnValue({ eq: mockEq }); }); - it("returns empty set when userId is not provided", async () => { + it("returns empty array when userId is not provided", async () => { const result = await selectAgentTemplateFavorites({}); expect(mockFrom).not.toHaveBeenCalled(); - expect(result).toEqual(new Set()); + expect(result).toEqual([]); }); - it("returns empty set when userId is empty string", async () => { + it("returns empty array when userId is empty string", async () => { const result = await selectAgentTemplateFavorites({ userId: "" }); expect(mockFrom).not.toHaveBeenCalled(); - expect(result).toEqual(new Set()); + expect(result).toEqual([]); }); - it("returns favorite template IDs as Set for given userId", async () => { + it("returns full favorite records for given userId", async () => { const mockFavorites = [ - { template_id: "tmpl-1" }, - { template_id: "tmpl-2" }, - { template_id: "tmpl-3" }, + { template_id: "tmpl-1", user_id: "user-1", created_at: "2026-01-01" }, + { template_id: "tmpl-2", user_id: "user-1", created_at: "2026-01-02" }, + { template_id: "tmpl-3", user_id: "user-1", created_at: "2026-01-03" }, ]; mockEq.mockResolvedValue({ data: mockFavorites, error: null }); const result = await selectAgentTemplateFavorites({ userId: "user-1" }); expect(mockFrom).toHaveBeenCalledWith("agent_template_favorites"); - expect(mockSelect).toHaveBeenCalledWith("template_id"); + // DRY: Use select('*') instead of explicit columns + expect(mockSelect).toHaveBeenCalledWith("*"); expect(mockEq).toHaveBeenCalledWith("user_id", "user-1"); - expect(result).toEqual(new Set(["tmpl-1", "tmpl-2", "tmpl-3"])); + expect(result).toEqual(mockFavorites); }); it("throws error when database query fails", async () => { @@ -58,19 +59,19 @@ describe("selectAgentTemplateFavorites", () => { ).rejects.toEqual(mockError); }); - it("returns empty set when data is null but no error", async () => { + it("returns empty array when data is null but no error", async () => { mockEq.mockResolvedValue({ data: null, error: null }); const result = await selectAgentTemplateFavorites({ userId: "user-1" }); - expect(result).toEqual(new Set()); + expect(result).toEqual([]); }); - it("returns empty set when data is empty array", async () => { + it("returns empty array when data is empty array", async () => { mockEq.mockResolvedValue({ data: [], error: null }); const result = await selectAgentTemplateFavorites({ userId: "user-1" }); - expect(result).toEqual(new Set()); + expect(result).toEqual([]); }); }); diff --git a/lib/supabase/agent_template_favorites/selectAgentTemplateFavorites.ts b/lib/supabase/agent_template_favorites/selectAgentTemplateFavorites.ts index 8bfd3575..f8b1e1c5 100644 --- a/lib/supabase/agent_template_favorites/selectAgentTemplateFavorites.ts +++ b/lib/supabase/agent_template_favorites/selectAgentTemplateFavorites.ts @@ -1,38 +1,37 @@ import supabase from "@/lib/supabase/serverClient"; +import type { Tables } from "@/types/database.types"; -interface TemplateFavorite { - template_id: string; -} +// DRY: Use database types instead of custom interfaces +export type AgentTemplateFavorite = Tables<"agent_template_favorites">; /** * Select agent template favorites for a user * * @param params - The parameters for the query * @param params.userId - The user ID to get favorites for - * @returns Set of favorite template IDs + * @returns Array of favorite records */ export default async function selectAgentTemplateFavorites({ userId, }: { userId?: string; -}): Promise> { +}): Promise { const hasUserId = typeof userId === "string" && userId.length > 0; - // If no userId is provided, return empty set + // If no userId is provided, return empty array if (!hasUserId) { - return new Set(); + return []; } + // DRY: Use select('*') instead of explicit columns const { data, error } = await supabase .from("agent_template_favorites") - .select("template_id") + .select("*") .eq("user_id", userId); if (error) { throw error; } - return new Set( - (data || []).map((f: TemplateFavorite) => f.template_id) - ); + return data || []; } From d72faf55c887b6cfb015cde5088a2d0baea649b8 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Fri, 16 Jan 2026 14:58:27 -0500 Subject: [PATCH 18/23] refactor: update insertAgentTemplateFavorite to return record with proper types (DRY) Applied DRY principle: - Changed from select('template_id') to select('*') - Return type changed from { success: true } to Tables<'agent_template_favorites'> | null - Export AgentTemplateFavorite type alias for consistency - Returns null for duplicate entries instead of success object Co-Authored-By: Claude Opus 4.5 --- .../insertAgentTemplateFavorite.test.ts | 34 ++++++++++++++----- .../insertAgentTemplateFavorite.ts | 16 ++++++--- 2 files changed, 36 insertions(+), 14 deletions(-) diff --git a/lib/supabase/agent_template_favorites/__tests__/insertAgentTemplateFavorite.test.ts b/lib/supabase/agent_template_favorites/__tests__/insertAgentTemplateFavorite.test.ts index 28e5ac52..b27f09a3 100644 --- a/lib/supabase/agent_template_favorites/__tests__/insertAgentTemplateFavorite.test.ts +++ b/lib/supabase/agent_template_favorites/__tests__/insertAgentTemplateFavorite.test.ts @@ -21,9 +21,15 @@ describe("insertAgentTemplateFavorite", () => { mockSelect.mockReturnValue({ maybeSingle: mockMaybeSingle }); }); - it("inserts a favorite and returns success", async () => { + // DRY: Updated to verify select('*') is used and full record is returned + it("inserts a favorite and returns the full record using select('*')", async () => { + const mockRecord = { + template_id: "tmpl-1", + user_id: "user-1", + created_at: "2026-01-16T12:00:00Z", + }; mockMaybeSingle.mockResolvedValue({ - data: { template_id: "tmpl-1" }, + data: mockRecord, error: null, }); @@ -37,12 +43,14 @@ describe("insertAgentTemplateFavorite", () => { template_id: "tmpl-1", user_id: "user-1", }); - expect(mockSelect).toHaveBeenCalledWith("template_id"); + // DRY: Verify select('*') is called instead of explicit columns + expect(mockSelect).toHaveBeenCalledWith("*"); expect(mockMaybeSingle).toHaveBeenCalled(); - expect(result).toEqual({ success: true }); + // DRY: Now returns the full record instead of just { success: true } + expect(result).toEqual(mockRecord); }); - it("returns success when favorite already exists (duplicate key error)", async () => { + it("returns null when favorite already exists (duplicate key error)", async () => { mockMaybeSingle.mockResolvedValue({ data: null, error: { code: "23505", message: "duplicate key value violates unique constraint" }, @@ -53,7 +61,8 @@ describe("insertAgentTemplateFavorite", () => { userId: "user-1", }); - expect(result).toEqual({ success: true }); + // DRY: Returns null for duplicate entries instead of { success: true } + expect(result).toBeNull(); }); it("throws error when database operation fails with non-duplicate error", async () => { @@ -80,9 +89,14 @@ describe("insertAgentTemplateFavorite", () => { ).rejects.toEqual(mockError); }); - it("handles different template and user IDs correctly", async () => { + it("returns the inserted record with all fields", async () => { + const mockRecord = { + template_id: "different-tmpl", + user_id: "different-user", + created_at: "2026-01-16T14:30:00Z", + }; mockMaybeSingle.mockResolvedValue({ - data: { template_id: "different-tmpl" }, + data: mockRecord, error: null, }); @@ -95,6 +109,8 @@ describe("insertAgentTemplateFavorite", () => { template_id: "different-tmpl", user_id: "different-user", }); - expect(result).toEqual({ success: true }); + // DRY: Verify full record is returned with all fields + expect(result).toEqual(mockRecord); + expect(result?.created_at).toBe("2026-01-16T14:30:00Z"); }); }); diff --git a/lib/supabase/agent_template_favorites/insertAgentTemplateFavorite.ts b/lib/supabase/agent_template_favorites/insertAgentTemplateFavorite.ts index 1c706571..cf14c0d7 100644 --- a/lib/supabase/agent_template_favorites/insertAgentTemplateFavorite.ts +++ b/lib/supabase/agent_template_favorites/insertAgentTemplateFavorite.ts @@ -1,4 +1,8 @@ import supabase from "@/lib/supabase/serverClient"; +import type { Tables } from "@/types/database.types"; + +// DRY: Use database types instead of custom interfaces +export type AgentTemplateFavorite = Tables<"agent_template_favorites">; /** * Insert an agent template favorite for a user @@ -6,7 +10,7 @@ import supabase from "@/lib/supabase/serverClient"; * @param params - The parameters for the insert * @param params.templateId - The ID of the template to favorite * @param params.userId - The ID of the user adding the favorite - * @returns An object with success: true + * @returns The inserted record, or null if it already exists * @throws Error if the database operation fails (except for duplicate entries) */ export default async function insertAgentTemplateFavorite({ @@ -15,11 +19,12 @@ export default async function insertAgentTemplateFavorite({ }: { templateId: string; userId: string; -}): Promise<{ success: true }> { - const { error } = await supabase +}): Promise { + // DRY: Use select('*') instead of explicit columns + const { data, error } = await supabase .from("agent_template_favorites") .insert({ template_id: templateId, user_id: userId }) - .select("template_id") + .select("*") .maybeSingle(); // Ignore unique violation (23505) - user already favorited this template @@ -27,5 +32,6 @@ export default async function insertAgentTemplateFavorite({ throw error; } - return { success: true }; + // Return the inserted record, or null if it was a duplicate + return data; } From 65ac49de4d7fc9819f0ce566cf6373e6c93b8b6d Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Fri, 16 Jan 2026 15:15:06 -0500 Subject: [PATCH 19/23] refactor: extract validation from toggleAgentTemplateFavoriteHandler (SRP) - Create validateToggleAgentTemplateFavoriteBody.ts with zod schema - Export ToggleAgentTemplateFavoriteBody type - Update handler to use new validation function - Add 12 unit tests for validation function - Update handler tests for new error message format Co-Authored-By: Claude Opus 4.5 --- ...toggleAgentTemplateFavoriteHandler.test.ts | 4 +- ...ateToggleAgentTemplateFavoriteBody.test.ts | 126 ++++++++++++++++++ .../toggleAgentTemplateFavoriteHandler.ts | 40 +----- ...validateToggleAgentTemplateFavoriteBody.ts | 44 ++++++ 4 files changed, 179 insertions(+), 35 deletions(-) create mode 100644 lib/agentTemplates/__tests__/validateToggleAgentTemplateFavoriteBody.test.ts create mode 100644 lib/agentTemplates/validateToggleAgentTemplateFavoriteBody.ts diff --git a/lib/agentTemplates/__tests__/toggleAgentTemplateFavoriteHandler.test.ts b/lib/agentTemplates/__tests__/toggleAgentTemplateFavoriteHandler.test.ts index 65ede12d..2ece474e 100644 --- a/lib/agentTemplates/__tests__/toggleAgentTemplateFavoriteHandler.test.ts +++ b/lib/agentTemplates/__tests__/toggleAgentTemplateFavoriteHandler.test.ts @@ -104,7 +104,7 @@ describe("toggleAgentTemplateFavoriteHandler", () => { expect(response.status).toBe(400); expect(json.status).toBe("error"); - expect(json.message).toBe("Missing templateId"); + expect(json.message).toBe("templateId is required"); }); it("returns 400 when isFavourite is missing", async () => { @@ -116,7 +116,7 @@ describe("toggleAgentTemplateFavoriteHandler", () => { expect(response.status).toBe(400); expect(json.status).toBe("error"); - expect(json.message).toBe("Missing isFavourite"); + expect(json.message).toBe("isFavourite is required"); }); }); diff --git a/lib/agentTemplates/__tests__/validateToggleAgentTemplateFavoriteBody.test.ts b/lib/agentTemplates/__tests__/validateToggleAgentTemplateFavoriteBody.test.ts new file mode 100644 index 00000000..d051520a --- /dev/null +++ b/lib/agentTemplates/__tests__/validateToggleAgentTemplateFavoriteBody.test.ts @@ -0,0 +1,126 @@ +import { describe, it, expect } from "vitest"; +import { NextResponse } from "next/server"; +import { + validateToggleAgentTemplateFavoriteBody, + toggleAgentTemplateFavoriteBodySchema, +} from "../validateToggleAgentTemplateFavoriteBody"; + +describe("validateToggleAgentTemplateFavoriteBody", () => { + describe("templateId validation", () => { + it("accepts valid templateId string", () => { + const result = validateToggleAgentTemplateFavoriteBody({ + templateId: "template-123", + isFavourite: true, + }); + + expect(result).not.toBeInstanceOf(NextResponse); + expect((result as any).templateId).toBe("template-123"); + }); + + it("rejects missing templateId", () => { + const result = validateToggleAgentTemplateFavoriteBody({ + isFavourite: true, + }); + + expect(result).toBeInstanceOf(NextResponse); + }); + + it("rejects empty templateId", () => { + const result = validateToggleAgentTemplateFavoriteBody({ + templateId: "", + isFavourite: true, + }); + + expect(result).toBeInstanceOf(NextResponse); + }); + }); + + describe("isFavourite validation", () => { + it("accepts isFavourite as true", () => { + const result = validateToggleAgentTemplateFavoriteBody({ + templateId: "template-123", + isFavourite: true, + }); + + expect(result).not.toBeInstanceOf(NextResponse); + expect((result as any).isFavourite).toBe(true); + }); + + it("accepts isFavourite as false", () => { + const result = validateToggleAgentTemplateFavoriteBody({ + templateId: "template-123", + isFavourite: false, + }); + + expect(result).not.toBeInstanceOf(NextResponse); + expect((result as any).isFavourite).toBe(false); + }); + + it("rejects missing isFavourite", () => { + const result = validateToggleAgentTemplateFavoriteBody({ + templateId: "template-123", + }); + + expect(result).toBeInstanceOf(NextResponse); + }); + + it("rejects non-boolean isFavourite", () => { + const result = validateToggleAgentTemplateFavoriteBody({ + templateId: "template-123", + isFavourite: "true", + }); + + expect(result).toBeInstanceOf(NextResponse); + }); + }); + + describe("error response format", () => { + it("returns 400 status for validation errors", async () => { + const result = validateToggleAgentTemplateFavoriteBody({}); + + expect(result).toBeInstanceOf(NextResponse); + expect((result as NextResponse).status).toBe(400); + }); + + it("returns error status and message in response body", async () => { + const result = validateToggleAgentTemplateFavoriteBody({}); + + expect(result).toBeInstanceOf(NextResponse); + const json = await (result as NextResponse).json(); + expect(json.status).toBe("error"); + expect(json.message).toBeDefined(); + }); + + it("returns 'templateId is required' when templateId is missing", async () => { + const result = validateToggleAgentTemplateFavoriteBody({ isFavourite: true }); + + expect(result).toBeInstanceOf(NextResponse); + const json = await (result as NextResponse).json(); + expect(json.message).toBe("templateId is required"); + }); + + it("returns 'isFavourite is required' when isFavourite is missing", async () => { + const result = validateToggleAgentTemplateFavoriteBody({ templateId: "template-1" }); + + expect(result).toBeInstanceOf(NextResponse); + const json = await (result as NextResponse).json(); + expect(json.message).toBe("isFavourite is required"); + }); + }); + + describe("schema type inference", () => { + it("schema should validate complete valid body", () => { + const validBody = { + templateId: "template-123", + isFavourite: true, + }; + + const result = toggleAgentTemplateFavoriteBodySchema.safeParse(validBody); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.templateId).toBe("template-123"); + expect(result.data.isFavourite).toBe(true); + } + }); + }); +}); diff --git a/lib/agentTemplates/toggleAgentTemplateFavoriteHandler.ts b/lib/agentTemplates/toggleAgentTemplateFavoriteHandler.ts index e40d5c0c..3fcfeb1a 100644 --- a/lib/agentTemplates/toggleAgentTemplateFavoriteHandler.ts +++ b/lib/agentTemplates/toggleAgentTemplateFavoriteHandler.ts @@ -3,11 +3,7 @@ import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { getAuthenticatedAccountId } from "@/lib/auth/getAuthenticatedAccountId"; import insertAgentTemplateFavorite from "@/lib/supabase/agent_template_favorites/insertAgentTemplateFavorite"; import { removeAgentTemplateFavorite } from "./removeAgentTemplateFavorite"; - -interface ToggleFavoriteRequestBody { - templateId?: string; - isFavourite?: boolean; -} +import { validateToggleAgentTemplateFavoriteBody } from "./validateToggleAgentTemplateFavoriteBody"; /** * Handler for toggling agent template favorites. @@ -33,36 +29,14 @@ export async function toggleAgentTemplateFavoriteHandler( const accountId = accountIdOrError; - // Parse request body - const body: ToggleFavoriteRequestBody = await request.json(); - const { templateId, isFavourite } = body; - - // Validate required fields - if (!templateId) { - return NextResponse.json( - { - status: "error", - message: "Missing templateId", - }, - { - status: 400, - headers: getCorsHeaders(), - }, - ); + // Parse and validate request body + const body = await request.json(); + const validatedBodyOrError = validateToggleAgentTemplateFavoriteBody(body); + if (validatedBodyOrError instanceof NextResponse) { + return validatedBodyOrError; } - if (typeof isFavourite !== "boolean") { - return NextResponse.json( - { - status: "error", - message: "Missing isFavourite", - }, - { - status: 400, - headers: getCorsHeaders(), - }, - ); - } + const { templateId, isFavourite } = validatedBodyOrError; // Toggle favorite if (isFavourite) { diff --git a/lib/agentTemplates/validateToggleAgentTemplateFavoriteBody.ts b/lib/agentTemplates/validateToggleAgentTemplateFavoriteBody.ts new file mode 100644 index 00000000..aadd019b --- /dev/null +++ b/lib/agentTemplates/validateToggleAgentTemplateFavoriteBody.ts @@ -0,0 +1,44 @@ +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { z } from "zod"; + +export const toggleAgentTemplateFavoriteBodySchema = z.object({ + templateId: z.string({ + error: "templateId is required", + }).min(1, "templateId is required"), + isFavourite: z.boolean({ + error: "isFavourite is required", + }), +}); + +export type ToggleAgentTemplateFavoriteBody = z.infer< + typeof toggleAgentTemplateFavoriteBodySchema +>; + +/** + * Validates request body for POST /api/agent-templates/favorites. + * + * @param body - The request body + * @returns A NextResponse with an error if validation fails, or the validated body if validation passes. + */ +export function validateToggleAgentTemplateFavoriteBody( + body: unknown, +): NextResponse | ToggleAgentTemplateFavoriteBody { + const result = toggleAgentTemplateFavoriteBodySchema.safeParse(body); + + if (!result.success) { + const firstError = result.error.issues[0]; + return NextResponse.json( + { + status: "error", + message: firstError.message, + }, + { + status: 400, + headers: getCorsHeaders(), + }, + ); + } + + return result.data; +} From c6f1dfb49d46bec0738fbb4e1b4c81af0475afa9 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Fri, 16 Jan 2026 15:18:25 -0500 Subject: [PATCH 20/23] refactor: move ADMIN_EMAILS from lib/admin.ts to lib/const.ts Consolidate admin configuration into shared constants file: - Move ADMIN_EMAILS constant to lib/const.ts - Update imports in getAgentCreatorHandler - Add unit tests for ADMIN_EMAILS in const.test.ts - Delete now-empty lib/admin.ts Co-Authored-By: Claude Opus 4.5 --- lib/__tests__/const.test.ts | 21 +++++++++++++++++++ lib/admin.ts | 5 ----- .../__tests__/getAgentCreatorHandler.test.ts | 12 +++++++---- lib/agentCreator/getAgentCreatorHandler.ts | 2 +- lib/const.ts | 6 ++++++ 5 files changed, 36 insertions(+), 10 deletions(-) create mode 100644 lib/__tests__/const.test.ts delete mode 100644 lib/admin.ts diff --git a/lib/__tests__/const.test.ts b/lib/__tests__/const.test.ts new file mode 100644 index 00000000..e5cb09be --- /dev/null +++ b/lib/__tests__/const.test.ts @@ -0,0 +1,21 @@ +import { describe, it, expect } from "vitest"; +import { ADMIN_EMAILS } from "@/lib/const"; + +describe("lib/const", () => { + describe("ADMIN_EMAILS", () => { + it("should export ADMIN_EMAILS as an array", () => { + expect(Array.isArray(ADMIN_EMAILS)).toBe(true); + }); + + it("should contain at least one admin email", () => { + expect(ADMIN_EMAILS.length).toBeGreaterThan(0); + }); + + it("should contain valid email strings", () => { + for (const email of ADMIN_EMAILS) { + expect(typeof email).toBe("string"); + expect(email).toMatch(/@/); + } + }); + }); +}); diff --git a/lib/admin.ts b/lib/admin.ts deleted file mode 100644 index dd057266..00000000 --- a/lib/admin.ts +++ /dev/null @@ -1,5 +0,0 @@ -/** - * List of admin email addresses. - * Users with these emails are identified as admins in the agent creator endpoint. - */ -export const ADMIN_EMAILS: string[] = ["sidney+1@recoupable.com"]; diff --git a/lib/agentCreator/__tests__/getAgentCreatorHandler.test.ts b/lib/agentCreator/__tests__/getAgentCreatorHandler.test.ts index 0cc8aadf..431ac41f 100644 --- a/lib/agentCreator/__tests__/getAgentCreatorHandler.test.ts +++ b/lib/agentCreator/__tests__/getAgentCreatorHandler.test.ts @@ -3,16 +3,20 @@ import { NextRequest } from "next/server"; import { getAgentCreatorHandler } from "../getAgentCreatorHandler"; import { getAccountWithDetails } from "@/lib/supabase/accounts/getAccountWithDetails"; -import { ADMIN_EMAILS } from "@/lib/admin"; +import { ADMIN_EMAILS } from "@/lib/const"; // Mock dependencies vi.mock("@/lib/supabase/accounts/getAccountWithDetails", () => ({ getAccountWithDetails: vi.fn(), })); -vi.mock("@/lib/admin", () => ({ - ADMIN_EMAILS: ["admin@example.com"], -})); +vi.mock("@/lib/const", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + ADMIN_EMAILS: ["admin@example.com"], + }; +}); vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), diff --git a/lib/agentCreator/getAgentCreatorHandler.ts b/lib/agentCreator/getAgentCreatorHandler.ts index d2711ddb..480050bd 100644 --- a/lib/agentCreator/getAgentCreatorHandler.ts +++ b/lib/agentCreator/getAgentCreatorHandler.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { getAccountWithDetails } from "@/lib/supabase/accounts/getAccountWithDetails"; -import { ADMIN_EMAILS } from "@/lib/admin"; +import { ADMIN_EMAILS } from "@/lib/const"; /** * Handler for fetching agent creator information. diff --git a/lib/const.ts b/lib/const.ts index bd1e73a2..e42128af 100644 --- a/lib/const.ts +++ b/lib/const.ts @@ -28,3 +28,9 @@ export const SUPABASE_STORAGE_BUCKET = "user-files"; * API keys from this org have universal access and can specify any accountId. */ export const RECOUP_ORG_ID = "04e3aba9-c130-4fb8-8b92-34e95d43e66b"; + +/** + * List of admin email addresses. + * Users with these emails are identified as admins in the agent creator endpoint. + */ +export const ADMIN_EMAILS: string[] = ["sidney+1@recoupable.com"]; From ed3759c9a244e22ab3a8a5fedffca1c7a8b85f2b Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Fri, 16 Jan 2026 15:22:27 -0500 Subject: [PATCH 21/23] refactor: move listAgentTemplatesForUser to lib/supabase - Create selectAgentTemplates.ts in lib/supabase/agent_templates/ - Use Tables<'agent_templates'> type and select('*') for DRY - Update getAccountTemplates.ts to import from new location - Add 7 unit tests for selectAgentTemplates - Delete lib/agentTemplates/listAgentTemplatesForUser.ts Co-Authored-By: Claude Opus 4.5 --- .../__tests__/getAccountTemplates.test.ts | 20 +-- lib/agentTemplates/getAccountTemplates.ts | 6 +- .../listAgentTemplatesForUser.ts | 25 --- .../__tests__/selectAgentTemplates.test.ts | 169 ++++++++++++++++++ .../agent_templates/selectAgentTemplates.ts | 45 +++++ 5 files changed, 227 insertions(+), 38 deletions(-) delete mode 100644 lib/agentTemplates/listAgentTemplatesForUser.ts create mode 100644 lib/supabase/agent_templates/__tests__/selectAgentTemplates.test.ts create mode 100644 lib/supabase/agent_templates/selectAgentTemplates.ts diff --git a/lib/agentTemplates/__tests__/getAccountTemplates.test.ts b/lib/agentTemplates/__tests__/getAccountTemplates.test.ts index 033d93d3..fb636519 100644 --- a/lib/agentTemplates/__tests__/getAccountTemplates.test.ts +++ b/lib/agentTemplates/__tests__/getAccountTemplates.test.ts @@ -1,12 +1,12 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { getAccountTemplates } from "../getAccountTemplates"; -import { listAgentTemplatesForUser } from "../listAgentTemplatesForUser"; import { getSharedTemplatesForAccount } from "../getSharedTemplatesForAccount"; import selectAgentTemplateFavorites from "@/lib/supabase/agent_template_favorites/selectAgentTemplateFavorites"; +import selectAgentTemplates from "@/lib/supabase/agent_templates/selectAgentTemplates"; -vi.mock("../listAgentTemplatesForUser", () => ({ - listAgentTemplatesForUser: vi.fn(), +vi.mock("@/lib/supabase/agent_templates/selectAgentTemplates", () => ({ + default: vi.fn(), })); vi.mock("../getSharedTemplatesForAccount", () => ({ @@ -54,7 +54,7 @@ describe("getAccountTemplates", () => { }, ]; - vi.mocked(listAgentTemplatesForUser).mockResolvedValue(ownedTemplates); + vi.mocked(selectAgentTemplates).mockResolvedValue(ownedTemplates); vi.mocked(getSharedTemplatesForAccount).mockResolvedValue(sharedTemplates); vi.mocked(selectAgentTemplateFavorites).mockResolvedValue([ { template_id: "template-1", user_id: "user-123", created_at: null }, @@ -83,7 +83,7 @@ describe("getAccountTemplates", () => { favorites_count: 0, }; - vi.mocked(listAgentTemplatesForUser).mockResolvedValue([ownedTemplate]); + vi.mocked(selectAgentTemplates).mockResolvedValue([ownedTemplate]); vi.mocked(getSharedTemplatesForAccount).mockResolvedValue([ownedTemplate]); vi.mocked(selectAgentTemplateFavorites).mockResolvedValue([]); @@ -120,7 +120,7 @@ describe("getAccountTemplates", () => { }, ]; - vi.mocked(listAgentTemplatesForUser).mockResolvedValue(templates); + vi.mocked(selectAgentTemplates).mockResolvedValue(templates); vi.mocked(getSharedTemplatesForAccount).mockResolvedValue([]); vi.mocked(selectAgentTemplateFavorites).mockResolvedValue([ { template_id: "template-1", user_id: "user-123", created_at: null }, @@ -150,13 +150,13 @@ describe("getAccountTemplates", () => { }, ]; - vi.mocked(listAgentTemplatesForUser).mockResolvedValue(publicTemplates); + vi.mocked(selectAgentTemplates).mockResolvedValue(publicTemplates); const result = await getAccountTemplates(null); expect(result).toHaveLength(1); expect(result[0].is_favourite).toBe(false); - expect(listAgentTemplatesForUser).toHaveBeenCalledWith(null); + expect(selectAgentTemplates).toHaveBeenCalledWith({ userId: null }); expect(getSharedTemplatesForAccount).not.toHaveBeenCalled(); expect(selectAgentTemplateFavorites).not.toHaveBeenCalled(); }); @@ -177,7 +177,7 @@ describe("getAccountTemplates", () => { }, ]; - vi.mocked(listAgentTemplatesForUser).mockResolvedValue(publicTemplates); + vi.mocked(selectAgentTemplates).mockResolvedValue(publicTemplates); const result = await getAccountTemplates(undefined); @@ -201,7 +201,7 @@ describe("getAccountTemplates", () => { }, ]; - vi.mocked(listAgentTemplatesForUser).mockResolvedValue(publicTemplates); + vi.mocked(selectAgentTemplates).mockResolvedValue(publicTemplates); const result = await getAccountTemplates("undefined"); diff --git a/lib/agentTemplates/getAccountTemplates.ts b/lib/agentTemplates/getAccountTemplates.ts index 7a64a24f..182f4adf 100644 --- a/lib/agentTemplates/getAccountTemplates.ts +++ b/lib/agentTemplates/getAccountTemplates.ts @@ -1,12 +1,12 @@ import type { AgentTemplateRow } from "./types"; -import { listAgentTemplatesForUser } from "./listAgentTemplatesForUser"; import { getSharedTemplatesForAccount } from "./getSharedTemplatesForAccount"; import selectAgentTemplateFavorites from "@/lib/supabase/agent_template_favorites/selectAgentTemplateFavorites"; +import selectAgentTemplates from "@/lib/supabase/agent_templates/selectAgentTemplates"; export async function getAccountTemplates(accountId?: string | null) { if (accountId && accountId !== "undefined") { // Get owned and public templates - const ownedAndPublic = await listAgentTemplatesForUser(accountId); + const ownedAndPublic = await selectAgentTemplates({ userId: accountId }); // Get shared templates using dedicated utility const sharedTemplates = await getSharedTemplatesForAccount(accountId); @@ -34,7 +34,7 @@ export async function getAccountTemplates(accountId?: string | null) { } // For anonymous users, return public templates only - const publicTemplates = await listAgentTemplatesForUser(null); + const publicTemplates = await selectAgentTemplates({ userId: null }); return publicTemplates.map((template: AgentTemplateRow) => ({ ...template, is_favourite: false, diff --git a/lib/agentTemplates/listAgentTemplatesForUser.ts b/lib/agentTemplates/listAgentTemplatesForUser.ts deleted file mode 100644 index 9067ec0d..00000000 --- a/lib/agentTemplates/listAgentTemplatesForUser.ts +++ /dev/null @@ -1,25 +0,0 @@ -import supabase from "@/lib/supabase/serverClient"; - -export async function listAgentTemplatesForUser(userId?: string | null) { - if (userId && userId !== "undefined") { - const { data, error } = await supabase - .from("agent_templates") - .select( - "id, title, description, prompt, tags, creator, is_private, created_at, favorites_count, updated_at", - ) - .or(`creator.eq.${userId},is_private.eq.false`) - .order("title"); - if (error) throw error; - return data ?? []; - } - - const { data, error } = await supabase - .from("agent_templates") - .select( - "id, title, description, prompt, tags, creator, is_private, created_at, favorites_count, updated_at", - ) - .eq("is_private", false) - .order("title"); - if (error) throw error; - return data ?? []; -} diff --git a/lib/supabase/agent_templates/__tests__/selectAgentTemplates.test.ts b/lib/supabase/agent_templates/__tests__/selectAgentTemplates.test.ts new file mode 100644 index 00000000..9f38ba17 --- /dev/null +++ b/lib/supabase/agent_templates/__tests__/selectAgentTemplates.test.ts @@ -0,0 +1,169 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import selectAgentTemplates from "../selectAgentTemplates"; + +const mockFrom = vi.fn(); +const mockSelect = vi.fn(); +const mockOr = vi.fn(); +const mockEq = vi.fn(); +const mockOrder = vi.fn(); + +vi.mock("@/lib/supabase/serverClient", () => ({ + default: { + from: (...args: unknown[]) => mockFrom(...args), + }, +})); + +describe("selectAgentTemplates", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockFrom.mockReturnValue({ select: mockSelect }); + mockSelect.mockReturnValue({ or: mockOr, eq: mockEq }); + mockOr.mockReturnValue({ order: mockOrder }); + mockEq.mockReturnValue({ order: mockOrder }); + }); + + describe("with userId provided", () => { + it("returns templates owned by user OR public templates", async () => { + const mockTemplates = [ + { + id: "tmpl-1", + title: "My Template", + description: "desc", + prompt: "prompt", + tags: [], + creator: "user-1", + is_private: true, + created_at: "2026-01-01", + updated_at: "2026-01-01", + favorites_count: 5, + }, + { + id: "tmpl-2", + title: "Public Template", + description: "desc", + prompt: "prompt", + tags: [], + creator: "other-user", + is_private: false, + created_at: "2026-01-01", + updated_at: "2026-01-01", + favorites_count: 10, + }, + ]; + mockOrder.mockResolvedValue({ data: mockTemplates, error: null }); + + const result = await selectAgentTemplates({ userId: "user-1" }); + + expect(mockFrom).toHaveBeenCalledWith("agent_templates"); + expect(mockSelect).toHaveBeenCalledWith("*"); + expect(mockOr).toHaveBeenCalledWith("creator.eq.user-1,is_private.eq.false"); + expect(mockOrder).toHaveBeenCalledWith("title"); + expect(result).toEqual(mockTemplates); + }); + + it("filters out 'undefined' string as userId (treats as anonymous)", async () => { + const mockTemplates = [ + { + id: "tmpl-1", + title: "Public Template", + description: "desc", + prompt: "prompt", + tags: [], + creator: "someone", + is_private: false, + created_at: "2026-01-01", + updated_at: "2026-01-01", + favorites_count: 0, + }, + ]; + mockOrder.mockResolvedValue({ data: mockTemplates, error: null }); + + const result = await selectAgentTemplates({ userId: "undefined" }); + + expect(mockOr).not.toHaveBeenCalled(); + expect(mockEq).toHaveBeenCalledWith("is_private", false); + expect(result).toEqual(mockTemplates); + }); + }); + + describe("without userId (anonymous)", () => { + it("returns only public templates when userId is null", async () => { + const mockTemplates = [ + { + id: "tmpl-1", + title: "Public Template", + description: "desc", + prompt: "prompt", + tags: [], + creator: "someone", + is_private: false, + created_at: "2026-01-01", + updated_at: "2026-01-01", + favorites_count: 10, + }, + ]; + mockOrder.mockResolvedValue({ data: mockTemplates, error: null }); + + const result = await selectAgentTemplates({ userId: null }); + + expect(mockFrom).toHaveBeenCalledWith("agent_templates"); + expect(mockSelect).toHaveBeenCalledWith("*"); + expect(mockOr).not.toHaveBeenCalled(); + expect(mockEq).toHaveBeenCalledWith("is_private", false); + expect(mockOrder).toHaveBeenCalledWith("title"); + expect(result).toEqual(mockTemplates); + }); + + it("returns only public templates when userId is undefined", async () => { + const mockTemplates = [ + { + id: "tmpl-1", + title: "Public Template", + description: "desc", + prompt: "prompt", + tags: [], + creator: "someone", + is_private: false, + created_at: "2026-01-01", + updated_at: "2026-01-01", + favorites_count: 10, + }, + ]; + mockOrder.mockResolvedValue({ data: mockTemplates, error: null }); + + const result = await selectAgentTemplates({}); + + expect(mockOr).not.toHaveBeenCalled(); + expect(mockEq).toHaveBeenCalledWith("is_private", false); + expect(result).toEqual(mockTemplates); + }); + }); + + describe("error handling", () => { + it("throws error when database query fails", async () => { + const mockError = { message: "Database connection failed" }; + mockOrder.mockResolvedValue({ data: null, error: mockError }); + + await expect( + selectAgentTemplates({ userId: "user-1" }) + ).rejects.toEqual(mockError); + }); + + it("returns empty array when data is null but no error", async () => { + mockOrder.mockResolvedValue({ data: null, error: null }); + + const result = await selectAgentTemplates({ userId: "user-1" }); + + expect(result).toEqual([]); + }); + + it("returns empty array when data is empty array", async () => { + mockOrder.mockResolvedValue({ data: [], error: null }); + + const result = await selectAgentTemplates({ userId: "user-1" }); + + expect(result).toEqual([]); + }); + }); +}); diff --git a/lib/supabase/agent_templates/selectAgentTemplates.ts b/lib/supabase/agent_templates/selectAgentTemplates.ts new file mode 100644 index 00000000..035e1383 --- /dev/null +++ b/lib/supabase/agent_templates/selectAgentTemplates.ts @@ -0,0 +1,45 @@ +import supabase from "@/lib/supabase/serverClient"; +import type { Tables } from "@/types/database.types"; + +// DRY: Use database types instead of custom interfaces +export type AgentTemplate = Tables<"agent_templates">; + +/** + * Select agent templates based on user access. + * - With userId: Returns templates owned by the user OR public templates + * - Without userId: Returns only public templates + * + * @param params - The parameters for the query + * @param params.userId - Optional user ID to filter templates for + * @returns Array of agent template records ordered by title + */ +export default async function selectAgentTemplates({ + userId, +}: { + userId?: string | null; +}): Promise { + const hasValidUserId = + typeof userId === "string" && userId.length > 0 && userId !== "undefined"; + + if (hasValidUserId) { + // Return templates owned by user OR public templates + const { data, error } = await supabase + .from("agent_templates") + .select("*") + .or(`creator.eq.${userId},is_private.eq.false`) + .order("title"); + + if (error) throw error; + return data ?? []; + } + + // Return only public templates for anonymous users + const { data, error } = await supabase + .from("agent_templates") + .select("*") + .eq("is_private", false) + .order("title"); + + if (error) throw error; + return data ?? []; +} From b118128532f349127261950ed695c9995c68b412 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Fri, 16 Jan 2026 15:27:48 -0500 Subject: [PATCH 22/23] refactor: extract Supabase queries from getArtistAgents to lib/supabase (SRP) Follow SRP by extracting 2 Supabase queries into separate files: - selectAgentStatusBySocialIds: agent_status query with joined agents - selectAgentsWithStatusAndSocials: agents query with joined agent_status and socials Updated getArtistAgents.ts to use the new functions. Added 21 unit tests (6 for selectAgentStatusBySocialIds, 6 for selectAgentsWithStatusAndSocials, 9 for getArtistAgents). Co-Authored-By: Claude Opus 4.5 --- .../__tests__/getArtistAgents.test.ts | 385 ++++++++++++++++++ lib/artistAgents/getArtistAgents.ts | 32 +- .../selectAgentStatusBySocialIds.test.ts | 111 +++++ .../selectAgentStatusBySocialIds.ts | 41 ++ .../selectAgentsWithStatusAndSocials.test.ts | 151 +++++++ .../selectAgentsWithStatusAndSocials.ts | 46 +++ 6 files changed, 750 insertions(+), 16 deletions(-) create mode 100644 lib/artistAgents/__tests__/getArtistAgents.test.ts create mode 100644 lib/supabase/agent_status/__tests__/selectAgentStatusBySocialIds.test.ts create mode 100644 lib/supabase/agent_status/selectAgentStatusBySocialIds.ts create mode 100644 lib/supabase/agents/__tests__/selectAgentsWithStatusAndSocials.test.ts create mode 100644 lib/supabase/agents/selectAgentsWithStatusAndSocials.ts diff --git a/lib/artistAgents/__tests__/getArtistAgents.test.ts b/lib/artistAgents/__tests__/getArtistAgents.test.ts new file mode 100644 index 00000000..660dffcd --- /dev/null +++ b/lib/artistAgents/__tests__/getArtistAgents.test.ts @@ -0,0 +1,385 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import { getArtistAgents } from "../getArtistAgents"; +import selectAgentStatusBySocialIds from "@/lib/supabase/agent_status/selectAgentStatusBySocialIds"; +import selectAgentsWithStatusAndSocials from "@/lib/supabase/agents/selectAgentsWithStatusAndSocials"; +import { getSocialPlatformByLink } from "@/lib/artists/getSocialPlatformByLink"; + +vi.mock("@/lib/supabase/agent_status/selectAgentStatusBySocialIds", () => ({ + default: vi.fn(), +})); + +vi.mock("@/lib/supabase/agents/selectAgentsWithStatusAndSocials", () => ({ + default: vi.fn(), +})); + +vi.mock("@/lib/artists/getSocialPlatformByLink", () => ({ + getSocialPlatformByLink: vi.fn(), +})); + +describe("getArtistAgents", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns empty array when selectAgentStatusBySocialIds returns empty", async () => { + vi.mocked(selectAgentStatusBySocialIds).mockResolvedValue([]); + + const result = await getArtistAgents(["social-1"]); + + expect(selectAgentStatusBySocialIds).toHaveBeenCalledWith({ socialIds: ["social-1"] }); + expect(selectAgentsWithStatusAndSocials).not.toHaveBeenCalled(); + expect(result).toEqual([]); + }); + + it("returns empty array when selectAgentStatusBySocialIds throws", async () => { + vi.mocked(selectAgentStatusBySocialIds).mockRejectedValue(new Error("Database error")); + + const result = await getArtistAgents(["social-1"]); + + expect(result).toEqual([]); + }); + + it("returns empty array when selectAgentsWithStatusAndSocials throws", async () => { + vi.mocked(selectAgentStatusBySocialIds).mockResolvedValue([ + { + id: "status-1", + agent_id: "agent-1", + social_id: "social-1", + status: 1, + progress: 100, + updated_at: "2024-01-01T00:00:00Z", + agent: { id: "agent-1", updated_at: "2024-01-01T00:00:00Z" }, + }, + ]); + vi.mocked(selectAgentsWithStatusAndSocials).mockRejectedValue(new Error("Database error")); + + const result = await getArtistAgents(["social-1"]); + + expect(result).toEqual([]); + }); + + it("returns empty array when selectAgentsWithStatusAndSocials returns empty", async () => { + vi.mocked(selectAgentStatusBySocialIds).mockResolvedValue([ + { + id: "status-1", + agent_id: "agent-1", + social_id: "social-1", + status: 1, + progress: 100, + updated_at: "2024-01-01T00:00:00Z", + agent: { id: "agent-1", updated_at: "2024-01-01T00:00:00Z" }, + }, + ]); + vi.mocked(selectAgentsWithStatusAndSocials).mockResolvedValue([]); + + const result = await getArtistAgents(["social-1"]); + + expect(result).toEqual([]); + }); + + it("returns transformed agents for single-platform agents", async () => { + vi.mocked(selectAgentStatusBySocialIds).mockResolvedValue([ + { + id: "status-1", + agent_id: "agent-1", + social_id: "social-1", + status: 1, + progress: 100, + updated_at: "2024-01-01T00:00:00Z", + agent: { id: "agent-1", updated_at: "2024-01-01T00:00:00Z" }, + }, + ]); + vi.mocked(selectAgentsWithStatusAndSocials).mockResolvedValue([ + { + id: "agent-1", + updated_at: "2024-01-01T00:00:00Z", + agent_status: [ + { + id: "status-1", + agent_id: "agent-1", + social_id: "social-1", + status: 1, + progress: 100, + updated_at: "2024-01-01T00:00:00Z", + social: { + id: "social-1", + profile_url: "https://twitter.com/artist1", + username: "artist1", + avatar: null, + bio: null, + followerCount: null, + followingCount: null, + region: null, + updated_at: "2024-01-01T00:00:00Z", + }, + }, + ], + }, + ]); + vi.mocked(getSocialPlatformByLink).mockReturnValue("Twitter"); + + const result = await getArtistAgents(["social-1"]); + + expect(selectAgentStatusBySocialIds).toHaveBeenCalledWith({ socialIds: ["social-1"] }); + expect(selectAgentsWithStatusAndSocials).toHaveBeenCalledWith({ agentIds: ["agent-1"] }); + expect(getSocialPlatformByLink).toHaveBeenCalledWith("https://twitter.com/artist1"); + expect(result).toEqual([ + { + type: "twitter", + agentId: "agent-1", + updated_at: "2024-01-01T00:00:00Z", + }, + ]); + }); + + it("returns 'wrapped' type for multi-platform agents", async () => { + vi.mocked(selectAgentStatusBySocialIds).mockResolvedValue([ + { + id: "status-1", + agent_id: "agent-1", + social_id: "social-1", + status: 1, + progress: 100, + updated_at: "2024-01-01T00:00:00Z", + agent: { id: "agent-1", updated_at: "2024-01-01T00:00:00Z" }, + }, + ]); + vi.mocked(selectAgentsWithStatusAndSocials).mockResolvedValue([ + { + id: "agent-1", + updated_at: "2024-01-01T00:00:00Z", + agent_status: [ + { + id: "status-1", + agent_id: "agent-1", + social_id: "social-1", + status: 1, + progress: 100, + updated_at: "2024-01-01T00:00:00Z", + social: { + id: "social-1", + profile_url: "https://twitter.com/artist1", + username: "artist1", + avatar: null, + bio: null, + followerCount: null, + followingCount: null, + region: null, + updated_at: "2024-01-01T00:00:00Z", + }, + }, + { + id: "status-2", + agent_id: "agent-1", + social_id: "social-2", + status: 1, + progress: 100, + updated_at: "2024-01-01T00:00:00Z", + social: { + id: "social-2", + profile_url: "https://instagram.com/artist1", + username: "artist1", + avatar: null, + bio: null, + followerCount: null, + followingCount: null, + region: null, + updated_at: "2024-01-01T00:00:00Z", + }, + }, + ], + }, + ]); + + const result = await getArtistAgents(["social-1"]); + + expect(getSocialPlatformByLink).not.toHaveBeenCalled(); + expect(result).toEqual([ + { + type: "wrapped", + agentId: "agent-1", + updated_at: "2024-01-01T00:00:00Z", + }, + ]); + }); + + it("aggregates agents by type (last one wins)", async () => { + vi.mocked(selectAgentStatusBySocialIds).mockResolvedValue([ + { + id: "status-1", + agent_id: "agent-1", + social_id: "social-1", + status: 1, + progress: 100, + updated_at: "2024-01-01T00:00:00Z", + agent: { id: "agent-1", updated_at: "2024-01-01T00:00:00Z" }, + }, + { + id: "status-2", + agent_id: "agent-2", + social_id: "social-2", + status: 1, + progress: 100, + updated_at: "2024-01-02T00:00:00Z", + agent: { id: "agent-2", updated_at: "2024-01-02T00:00:00Z" }, + }, + ]); + vi.mocked(selectAgentsWithStatusAndSocials).mockResolvedValue([ + { + id: "agent-1", + updated_at: "2024-01-01T00:00:00Z", + agent_status: [ + { + id: "status-1", + agent_id: "agent-1", + social_id: "social-1", + status: 1, + progress: 100, + updated_at: "2024-01-01T00:00:00Z", + social: { + id: "social-1", + profile_url: "https://twitter.com/artist1", + username: "artist1", + avatar: null, + bio: null, + followerCount: null, + followingCount: null, + region: null, + updated_at: "2024-01-01T00:00:00Z", + }, + }, + ], + }, + { + id: "agent-2", + updated_at: "2024-01-02T00:00:00Z", + agent_status: [ + { + id: "status-2", + agent_id: "agent-2", + social_id: "social-2", + status: 1, + progress: 100, + updated_at: "2024-01-02T00:00:00Z", + social: { + id: "social-2", + profile_url: "https://twitter.com/artist2", + username: "artist2", + avatar: null, + bio: null, + followerCount: null, + followingCount: null, + region: null, + updated_at: "2024-01-02T00:00:00Z", + }, + }, + ], + }, + ]); + vi.mocked(getSocialPlatformByLink).mockReturnValue("Twitter"); + + const result = await getArtistAgents(["social-1", "social-2"]); + + // Both agents are Twitter type, so only the last one should be returned + expect(result).toHaveLength(1); + expect(result[0].agentId).toBe("agent-2"); + expect(result[0].type).toBe("twitter"); + }); + + it("filters out agent status entries with null agent", async () => { + vi.mocked(selectAgentStatusBySocialIds).mockResolvedValue([ + { + id: "status-1", + agent_id: "agent-1", + social_id: "social-1", + status: 1, + progress: 100, + updated_at: "2024-01-01T00:00:00Z", + agent: null, + }, + { + id: "status-2", + agent_id: "agent-2", + social_id: "social-2", + status: 1, + progress: 100, + updated_at: "2024-01-02T00:00:00Z", + agent: { id: "agent-2", updated_at: "2024-01-02T00:00:00Z" }, + }, + ]); + vi.mocked(selectAgentsWithStatusAndSocials).mockResolvedValue([ + { + id: "agent-2", + updated_at: "2024-01-02T00:00:00Z", + agent_status: [ + { + id: "status-2", + agent_id: "agent-2", + social_id: "social-2", + status: 1, + progress: 100, + updated_at: "2024-01-02T00:00:00Z", + social: { + id: "social-2", + profile_url: "https://twitter.com/artist2", + username: "artist2", + avatar: null, + bio: null, + followerCount: null, + followingCount: null, + region: null, + updated_at: "2024-01-02T00:00:00Z", + }, + }, + ], + }, + ]); + vi.mocked(getSocialPlatformByLink).mockReturnValue("Twitter"); + + const result = await getArtistAgents(["social-1", "social-2"]); + + // Should only query for agent-2 since agent-1's agent is null + expect(selectAgentsWithStatusAndSocials).toHaveBeenCalledWith({ agentIds: ["agent-2"] }); + expect(result).toHaveLength(1); + expect(result[0].agentId).toBe("agent-2"); + }); + + it("handles social with null profile_url gracefully", async () => { + vi.mocked(selectAgentStatusBySocialIds).mockResolvedValue([ + { + id: "status-1", + agent_id: "agent-1", + social_id: "social-1", + status: 1, + progress: 100, + updated_at: "2024-01-01T00:00:00Z", + agent: { id: "agent-1", updated_at: "2024-01-01T00:00:00Z" }, + }, + ]); + vi.mocked(selectAgentsWithStatusAndSocials).mockResolvedValue([ + { + id: "agent-1", + updated_at: "2024-01-01T00:00:00Z", + agent_status: [ + { + id: "status-1", + agent_id: "agent-1", + social_id: "social-1", + status: 1, + progress: 100, + updated_at: "2024-01-01T00:00:00Z", + social: null, + }, + ], + }, + ]); + vi.mocked(getSocialPlatformByLink).mockReturnValue("Unknown"); + + const result = await getArtistAgents(["social-1"]); + + expect(getSocialPlatformByLink).toHaveBeenCalledWith(""); + expect(result).toHaveLength(1); + expect(result[0].type).toBe("unknown"); + }); +}); diff --git a/lib/artistAgents/getArtistAgents.ts b/lib/artistAgents/getArtistAgents.ts index 0fafa037..1bbf2dc8 100644 --- a/lib/artistAgents/getArtistAgents.ts +++ b/lib/artistAgents/getArtistAgents.ts @@ -1,5 +1,6 @@ -import supabase from "@/lib/supabase/serverClient"; import { getSocialPlatformByLink } from "@/lib/artists/getSocialPlatformByLink"; +import selectAgentStatusBySocialIds from "@/lib/supabase/agent_status/selectAgentStatusBySocialIds"; +import selectAgentsWithStatusAndSocials from "@/lib/supabase/agents/selectAgentsWithStatusAndSocials"; export interface ArtistAgent { type: string; @@ -17,32 +18,31 @@ export interface ArtistAgent { * @returns Array of ArtistAgent objects, aggregated by type */ export async function getArtistAgents(artistSocialIds: string[]): Promise { - const { data, error } = await supabase - .from("agent_status") - .select("*, agent:agents(*)") - .in("social_id", artistSocialIds); - - if (error) { - console.error("Error fetching artist agents:", error); + let agentStatusData; + try { + agentStatusData = await selectAgentStatusBySocialIds({ socialIds: artistSocialIds }); + } catch { return []; } - if (!data) return []; + if (!agentStatusData.length) return []; - const agentIds = [...new Set(data.map(ele => ele.agent.id))]; + const agentIds = [...new Set(agentStatusData.map(ele => ele.agent?.id).filter(Boolean))] as string[]; - const { data: agents } = await supabase - .from("agents") - .select("*, agent_status(*, social:socials(*))") - .in("id", agentIds); + let agents; + try { + agents = await selectAgentsWithStatusAndSocials({ agentIds }); + } catch { + return []; + } - if (!agents) return []; + if (!agents.length) return []; const transformedAgents = agents.map(agent => ({ type: new String( agent.agent_status.length > 1 ? "wrapped" - : getSocialPlatformByLink(agent.agent_status[0].social.profile_url), + : getSocialPlatformByLink(agent.agent_status[0].social?.profile_url ?? ""), ).toLowerCase(), agentId: agent.id, updated_at: agent.updated_at, diff --git a/lib/supabase/agent_status/__tests__/selectAgentStatusBySocialIds.test.ts b/lib/supabase/agent_status/__tests__/selectAgentStatusBySocialIds.test.ts new file mode 100644 index 00000000..5de2070b --- /dev/null +++ b/lib/supabase/agent_status/__tests__/selectAgentStatusBySocialIds.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import selectAgentStatusBySocialIds from "../selectAgentStatusBySocialIds"; + +const mockFrom = vi.fn(); +const mockSelect = vi.fn(); +const mockIn = vi.fn(); + +vi.mock("@/lib/supabase/serverClient", () => ({ + default: { + from: (...args: unknown[]) => mockFrom(...args), + }, +})); + +describe("selectAgentStatusBySocialIds", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockFrom.mockReturnValue({ select: mockSelect }); + mockSelect.mockReturnValue({ in: mockIn }); + }); + + it("returns empty array when socialIds is empty", async () => { + const result = await selectAgentStatusBySocialIds({ socialIds: [] }); + + expect(mockFrom).not.toHaveBeenCalled(); + expect(result).toEqual([]); + }); + + it("returns empty array when socialIds is not provided", async () => { + const result = await selectAgentStatusBySocialIds({}); + + expect(mockFrom).not.toHaveBeenCalled(); + expect(result).toEqual([]); + }); + + it("returns agent_status records for given social IDs using select('*')", async () => { + const mockAgentStatus = [ + { + id: "status-1", + agent_id: "agent-1", + social_id: "social-1", + status: 1, + progress: 100, + updated_at: "2024-01-01T00:00:00Z", + }, + { + id: "status-2", + agent_id: "agent-2", + social_id: "social-2", + status: 1, + progress: 50, + updated_at: "2024-01-02T00:00:00Z", + }, + ]; + mockIn.mockResolvedValue({ data: mockAgentStatus, error: null }); + + const result = await selectAgentStatusBySocialIds({ + socialIds: ["social-1", "social-2"], + }); + + expect(mockFrom).toHaveBeenCalledWith("agent_status"); + expect(mockSelect).toHaveBeenCalledWith("*, agent:agents(*)"); + expect(mockIn).toHaveBeenCalledWith("social_id", ["social-1", "social-2"]); + expect(result).toEqual(mockAgentStatus); + }); + + it("throws error when database query fails", async () => { + const mockError = { message: "Database connection failed" }; + mockIn.mockResolvedValue({ data: null, error: mockError }); + + await expect( + selectAgentStatusBySocialIds({ socialIds: ["social-1"] }) + ).rejects.toEqual(mockError); + }); + + it("returns empty array when data is null but no error", async () => { + mockIn.mockResolvedValue({ data: null, error: null }); + + const result = await selectAgentStatusBySocialIds({ + socialIds: ["social-1"], + }); + + expect(result).toEqual([]); + }); + + it("returns agent_status with joined agent data", async () => { + const mockAgentStatusWithAgent = [ + { + id: "status-1", + agent_id: "agent-1", + social_id: "social-1", + status: 1, + progress: 100, + updated_at: "2024-01-01T00:00:00Z", + agent: { + id: "agent-1", + updated_at: "2024-01-01T00:00:00Z", + }, + }, + ]; + mockIn.mockResolvedValue({ data: mockAgentStatusWithAgent, error: null }); + + const result = await selectAgentStatusBySocialIds({ + socialIds: ["social-1"], + }); + + expect(result).toEqual(mockAgentStatusWithAgent); + expect(result[0]).toHaveProperty("agent"); + expect(result[0].agent).toHaveProperty("id", "agent-1"); + }); +}); diff --git a/lib/supabase/agent_status/selectAgentStatusBySocialIds.ts b/lib/supabase/agent_status/selectAgentStatusBySocialIds.ts new file mode 100644 index 00000000..1ad12f1b --- /dev/null +++ b/lib/supabase/agent_status/selectAgentStatusBySocialIds.ts @@ -0,0 +1,41 @@ +import supabase from "@/lib/supabase/serverClient"; +import type { Tables } from "@/types/database.types"; + +// DRY: Use database types instead of custom interfaces +export type AgentStatus = Tables<"agent_status">; + +export type AgentStatusWithAgent = AgentStatus & { + agent: Tables<"agents"> | null; +}; + +/** + * Select agent_status records by social IDs with joined agent data + * + * @param params - The parameters for the query + * @param params.socialIds - Array of social IDs to filter by + * @returns Array of agent_status records with joined agent data + */ +export default async function selectAgentStatusBySocialIds({ + socialIds, +}: { + socialIds?: string[]; +}): Promise { + const hasSocialIds = Array.isArray(socialIds) && socialIds.length > 0; + + // If no socialIds provided, return empty array + if (!hasSocialIds) { + return []; + } + + // DRY: Use select('*') with join syntax + const { data, error } = await supabase + .from("agent_status") + .select("*, agent:agents(*)") + .in("social_id", socialIds); + + if (error) { + throw error; + } + + return data || []; +} diff --git a/lib/supabase/agents/__tests__/selectAgentsWithStatusAndSocials.test.ts b/lib/supabase/agents/__tests__/selectAgentsWithStatusAndSocials.test.ts new file mode 100644 index 00000000..f1895b07 --- /dev/null +++ b/lib/supabase/agents/__tests__/selectAgentsWithStatusAndSocials.test.ts @@ -0,0 +1,151 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import selectAgentsWithStatusAndSocials from "../selectAgentsWithStatusAndSocials"; + +const mockFrom = vi.fn(); +const mockSelect = vi.fn(); +const mockIn = vi.fn(); + +vi.mock("@/lib/supabase/serverClient", () => ({ + default: { + from: (...args: unknown[]) => mockFrom(...args), + }, +})); + +describe("selectAgentsWithStatusAndSocials", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockFrom.mockReturnValue({ select: mockSelect }); + mockSelect.mockReturnValue({ in: mockIn }); + }); + + it("returns empty array when agentIds is empty", async () => { + const result = await selectAgentsWithStatusAndSocials({ agentIds: [] }); + + expect(mockFrom).not.toHaveBeenCalled(); + expect(result).toEqual([]); + }); + + it("returns empty array when agentIds is not provided", async () => { + const result = await selectAgentsWithStatusAndSocials({}); + + expect(mockFrom).not.toHaveBeenCalled(); + expect(result).toEqual([]); + }); + + it("returns agents with agent_status and socials for given agent IDs", async () => { + const mockAgents = [ + { + id: "agent-1", + updated_at: "2024-01-01T00:00:00Z", + agent_status: [ + { + id: "status-1", + agent_id: "agent-1", + social_id: "social-1", + status: 1, + progress: 100, + updated_at: "2024-01-01T00:00:00Z", + social: { + id: "social-1", + profile_url: "https://twitter.com/artist1", + username: "artist1", + avatar: null, + bio: null, + followerCount: null, + followingCount: null, + region: null, + updated_at: "2024-01-01T00:00:00Z", + }, + }, + ], + }, + ]; + mockIn.mockResolvedValue({ data: mockAgents, error: null }); + + const result = await selectAgentsWithStatusAndSocials({ + agentIds: ["agent-1"], + }); + + expect(mockFrom).toHaveBeenCalledWith("agents"); + expect(mockSelect).toHaveBeenCalledWith("*, agent_status(*, social:socials(*))"); + expect(mockIn).toHaveBeenCalledWith("id", ["agent-1"]); + expect(result).toEqual(mockAgents); + }); + + it("throws error when database query fails", async () => { + const mockError = { message: "Database connection failed" }; + mockIn.mockResolvedValue({ data: null, error: mockError }); + + await expect( + selectAgentsWithStatusAndSocials({ agentIds: ["agent-1"] }) + ).rejects.toEqual(mockError); + }); + + it("returns empty array when data is null but no error", async () => { + mockIn.mockResolvedValue({ data: null, error: null }); + + const result = await selectAgentsWithStatusAndSocials({ + agentIds: ["agent-1"], + }); + + expect(result).toEqual([]); + }); + + it("returns agent with multiple agent_status entries (wrapped agent)", async () => { + const mockWrappedAgent = [ + { + id: "agent-1", + updated_at: "2024-01-01T00:00:00Z", + agent_status: [ + { + id: "status-1", + agent_id: "agent-1", + social_id: "social-1", + status: 1, + progress: 100, + updated_at: "2024-01-01T00:00:00Z", + social: { + id: "social-1", + profile_url: "https://twitter.com/artist1", + username: "artist1", + avatar: null, + bio: null, + followerCount: null, + followingCount: null, + region: null, + updated_at: "2024-01-01T00:00:00Z", + }, + }, + { + id: "status-2", + agent_id: "agent-1", + social_id: "social-2", + status: 1, + progress: 100, + updated_at: "2024-01-01T00:00:00Z", + social: { + id: "social-2", + profile_url: "https://instagram.com/artist1", + username: "artist1", + avatar: null, + bio: null, + followerCount: null, + followingCount: null, + region: null, + updated_at: "2024-01-01T00:00:00Z", + }, + }, + ], + }, + ]; + mockIn.mockResolvedValue({ data: mockWrappedAgent, error: null }); + + const result = await selectAgentsWithStatusAndSocials({ + agentIds: ["agent-1"], + }); + + expect(result).toEqual(mockWrappedAgent); + expect(result[0].agent_status).toHaveLength(2); + }); +}); diff --git a/lib/supabase/agents/selectAgentsWithStatusAndSocials.ts b/lib/supabase/agents/selectAgentsWithStatusAndSocials.ts new file mode 100644 index 00000000..411c8d6b --- /dev/null +++ b/lib/supabase/agents/selectAgentsWithStatusAndSocials.ts @@ -0,0 +1,46 @@ +import supabase from "@/lib/supabase/serverClient"; +import type { Tables } from "@/types/database.types"; + +// DRY: Use database types instead of custom interfaces +export type Agent = Tables<"agents">; +export type Social = Tables<"socials">; + +export type AgentStatusWithSocial = Tables<"agent_status"> & { + social: Social | null; +}; + +export type AgentWithStatusAndSocials = Agent & { + agent_status: AgentStatusWithSocial[]; +}; + +/** + * Select agents by IDs with joined agent_status and socials data + * + * @param params - The parameters for the query + * @param params.agentIds - Array of agent IDs to filter by + * @returns Array of agent records with joined agent_status and socials data + */ +export default async function selectAgentsWithStatusAndSocials({ + agentIds, +}: { + agentIds?: string[]; +}): Promise { + const hasAgentIds = Array.isArray(agentIds) && agentIds.length > 0; + + // If no agentIds provided, return empty array + if (!hasAgentIds) { + return []; + } + + // DRY: Use select('*') with nested join syntax + const { data, error } = await supabase + .from("agents") + .select("*, agent_status(*, social:socials(*))") + .in("id", agentIds); + + if (error) { + throw error; + } + + return data || []; +} From 283132e4ce30340a1965729343157e7eebadbfd1 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Fri, 16 Jan 2026 15:32:34 -0500 Subject: [PATCH 23/23] feat: add GET /api/ai/models endpoint Add new public endpoint to fetch available AI models from Vercel AI Gateway. - Create getAiModelsHandler with 4 unit tests - Returns models filtered to exclude embed models - Public endpoint, no auth required - Follows established handler/route pattern Co-Authored-By: Claude Opus 4.5 --- app/api/ai/models/route.ts | 34 ++++++++++ .../__tests__/getAiModelsHandler.test.ts | 68 +++++++++++++++++++ lib/aiModels/getAiModelsHandler.ts | 34 ++++++++++ 3 files changed, 136 insertions(+) create mode 100644 app/api/ai/models/route.ts create mode 100644 lib/aiModels/__tests__/getAiModelsHandler.test.ts create mode 100644 lib/aiModels/getAiModelsHandler.ts diff --git a/app/api/ai/models/route.ts b/app/api/ai/models/route.ts new file mode 100644 index 00000000..ad11e30d --- /dev/null +++ b/app/api/ai/models/route.ts @@ -0,0 +1,34 @@ +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getAiModelsHandler } from "@/lib/aiModels/getAiModelsHandler"; + +/** + * OPTIONS handler for CORS preflight requests. + * + * @returns A NextResponse with CORS headers. + */ +export async function OPTIONS() { + return new NextResponse(null, { + status: 200, + headers: getCorsHeaders(), + }); +} + +/** + * GET /api/ai/models + * + * Fetch the list of available AI models from the Vercel AI Gateway. + * + * This is a public endpoint that does not require authentication. + * It returns models suitable for chat, filtering out embed models. + * + * @returns A NextResponse with { models: GatewayLanguageModelEntry[] } or an error + */ +export async function GET(): Promise { + return getAiModelsHandler(); +} + +// Disable caching to always serve the latest model list. +export const dynamic = "force-dynamic"; +export const fetchCache = "force-no-store"; +export const revalidate = 0; diff --git a/lib/aiModels/__tests__/getAiModelsHandler.test.ts b/lib/aiModels/__tests__/getAiModelsHandler.test.ts new file mode 100644 index 00000000..e8f153f2 --- /dev/null +++ b/lib/aiModels/__tests__/getAiModelsHandler.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { getAiModelsHandler } from "../getAiModelsHandler"; + +import { getAvailableModels } from "@/lib/ai/getAvailableModels"; + +// Mock dependencies +vi.mock("@/lib/ai/getAvailableModels", () => ({ + getAvailableModels: vi.fn(), +})); + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +describe("getAiModelsHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("successful responses", () => { + it("returns models from getAvailableModels", async () => { + const mockModels = [ + { id: "gpt-4", name: "GPT-4", pricing: { input: "0.00003", output: "0.00006" } }, + { id: "claude-3-opus", name: "Claude 3 Opus", pricing: { input: "0.00001", output: "0.00003" } }, + ]; + vi.mocked(getAvailableModels).mockResolvedValue(mockModels as any); + + const response = await getAiModelsHandler(); + const json = await response.json(); + + expect(response.status).toBe(200); + expect(json.models).toEqual(mockModels); + expect(getAvailableModels).toHaveBeenCalledOnce(); + }); + + it("returns empty array when no models available", async () => { + vi.mocked(getAvailableModels).mockResolvedValue([]); + + const response = await getAiModelsHandler(); + const json = await response.json(); + + expect(response.status).toBe(200); + expect(json.models).toEqual([]); + }); + }); + + describe("error handling", () => { + it("returns 500 when getAvailableModels throws", async () => { + vi.mocked(getAvailableModels).mockRejectedValue(new Error("Gateway error")); + + const response = await getAiModelsHandler(); + const json = await response.json(); + + expect(response.status).toBe(500); + expect(json.message).toBe("Gateway error"); + }); + + it("returns generic message when error has no message", async () => { + vi.mocked(getAvailableModels).mockRejectedValue("unknown error"); + + const response = await getAiModelsHandler(); + const json = await response.json(); + + expect(response.status).toBe(500); + expect(json.message).toBe("failed"); + }); + }); +}); diff --git a/lib/aiModels/getAiModelsHandler.ts b/lib/aiModels/getAiModelsHandler.ts new file mode 100644 index 00000000..949a6801 --- /dev/null +++ b/lib/aiModels/getAiModelsHandler.ts @@ -0,0 +1,34 @@ +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getAvailableModels } from "@/lib/ai/getAvailableModels"; + +/** + * Handler for fetching available AI models. + * + * This is a public endpoint that returns the list of available LLMs + * from the Vercel AI Gateway. It filters out embed models that are + * not suitable for chat. + * + * @returns A NextResponse with the models array or an error + */ +export async function getAiModelsHandler(): Promise { + try { + const models = await getAvailableModels(); + return NextResponse.json( + { models }, + { + status: 200, + headers: getCorsHeaders(), + }, + ); + } catch (error) { + const message = error instanceof Error ? error.message : "failed"; + return NextResponse.json( + { message }, + { + status: 500, + headers: getCorsHeaders(), + }, + ); + } +}