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/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/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/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/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/__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/agentCreator/__tests__/getAgentCreatorHandler.test.ts b/lib/agentCreator/__tests__/getAgentCreatorHandler.test.ts new file mode 100644 index 00000000..431ac41f --- /dev/null +++ b/lib/agentCreator/__tests__/getAgentCreatorHandler.test.ts @@ -0,0 +1,153 @@ +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/const"; + +// Mock dependencies +vi.mock("@/lib/supabase/accounts/getAccountWithDetails", () => ({ + getAccountWithDetails: vi.fn(), +})); + +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": "*" })), +})); + +/** + * 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..480050bd --- /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/const"; + +/** + * 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(), + }, + ); + } +} diff --git a/lib/agentTemplates/__tests__/getAccountTemplates.test.ts b/lib/agentTemplates/__tests__/getAccountTemplates.test.ts new file mode 100644 index 00000000..fb636519 --- /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 { getSharedTemplatesForAccount } from "../getSharedTemplatesForAccount"; +import selectAgentTemplateFavorites from "@/lib/supabase/agent_template_favorites/selectAgentTemplateFavorites"; +import selectAgentTemplates from "@/lib/supabase/agent_templates/selectAgentTemplates"; + +vi.mock("@/lib/supabase/agent_templates/selectAgentTemplates", () => ({ + default: vi.fn(), +})); + +vi.mock("../getSharedTemplatesForAccount", () => ({ + getSharedTemplatesForAccount: vi.fn(), +})); + +vi.mock("@/lib/supabase/agent_template_favorites/selectAgentTemplateFavorites", () => ({ + default: 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(selectAgentTemplates).mockResolvedValue(ownedTemplates); + vi.mocked(getSharedTemplatesForAccount).mockResolvedValue(sharedTemplates); + vi.mocked(selectAgentTemplateFavorites).mockResolvedValue([ + { template_id: "template-1", user_id: "user-123", created_at: null }, + ]); + + 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(selectAgentTemplates).mockResolvedValue([ownedTemplate]); + vi.mocked(getSharedTemplatesForAccount).mockResolvedValue([ownedTemplate]); + vi.mocked(selectAgentTemplateFavorites).mockResolvedValue([]); + + 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(selectAgentTemplates).mockResolvedValue(templates); + vi.mocked(getSharedTemplatesForAccount).mockResolvedValue([]); + vi.mocked(selectAgentTemplateFavorites).mockResolvedValue([ + { template_id: "template-1", user_id: "user-123", created_at: null }, + ]); + + 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(selectAgentTemplates).mockResolvedValue(publicTemplates); + + const result = await getAccountTemplates(null); + + expect(result).toHaveLength(1); + expect(result[0].is_favourite).toBe(false); + expect(selectAgentTemplates).toHaveBeenCalledWith({ userId: null }); + expect(getSharedTemplatesForAccount).not.toHaveBeenCalled(); + expect(selectAgentTemplateFavorites).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(selectAgentTemplates).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(selectAgentTemplates).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 new file mode 100644 index 00000000..54b6dee1 --- /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 { getAccountTemplates } from "../getAccountTemplates"; +import { getSharedEmailsForTemplates } from "../getSharedEmailsForTemplates"; + +// Mock dependencies +vi.mock("@/lib/auth/getAuthenticatedAccountId", () => ({ + getAuthenticatedAccountId: vi.fn(), +})); + +vi.mock("../getAccountTemplates", () => ({ + getAccountTemplates: 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(getAccountTemplates).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(getAccountTemplates).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(getAccountTemplates).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(getAccountTemplates).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 getAccountTemplates throws", async () => { + vi.mocked(getAuthenticatedAccountId).mockResolvedValue("user-123"); + vi.mocked(getAccountTemplates).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/__tests__/getSharedTemplatesForAccount.test.ts b/lib/agentTemplates/__tests__/getSharedTemplatesForAccount.test.ts new file mode 100644 index 00000000..50d144b8 --- /dev/null +++ b/lib/agentTemplates/__tests__/getSharedTemplatesForAccount.test.ts @@ -0,0 +1,203 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import { getSharedTemplatesForAccount } from "../getSharedTemplatesForAccount"; + +const mockSelectAgentTemplateShares = vi.fn(); + +vi.mock("@/lib/supabase/agent_template_shares/selectAgentTemplateShares", () => ({ + default: (...args: unknown[]) => mockSelectAgentTemplateShares(...args), +})); + +describe("getSharedTemplatesForAccount", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns empty array when no shared templates exist", async () => { + mockSelectAgentTemplateShares.mockResolvedValue([]); + + const result = await getSharedTemplatesForAccount("account-123"); + + 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", + 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, + }, + }, + ]; + mockSelectAgentTemplateShares.mockResolvedValue(mockSharedData); + + const result = await getSharedTemplatesForAccount("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"); + }); + + 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", + 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, + }, + }, + { + template_id: "tmpl-1", + user_id: "account-123", + created_at: "2024-01-01T00:00:00Z", + 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, + }, + }, + { + template_id: "tmpl-2", + user_id: "account-123", + created_at: "2024-01-02T00:00:00Z", + 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, + }, + }, + ]; + mockSelectAgentTemplateShares.mockResolvedValue(mockSharedData); + + 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 = [ + { + template_id: "tmpl-1", + user_id: "account-123", + created_at: "2024-01-01T00:00:00Z", + 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, + }, + ], + }, + ]; + mockSelectAgentTemplateShares.mockResolvedValue(mockSharedData); + + const result = await getSharedTemplatesForAccount("account-123"); + + expect(result).toHaveLength(2); + }); + + it("throws error when selectAgentTemplateShares fails", async () => { + const mockError = { message: "Database connection failed" }; + mockSelectAgentTemplateShares.mockRejectedValue(mockError); + + await expect(getSharedTemplatesForAccount("account-123")).rejects.toEqual( + mockError, + ); + }); + + it("skips null or undefined share entries", async () => { + const mockSharedData = [ + null, + undefined, + { 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", + 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, + }, + }, + ]; + mockSelectAgentTemplateShares.mockResolvedValue(mockSharedData); + + const result = await getSharedTemplatesForAccount("account-123"); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe("tmpl-1"); + }); +}); diff --git a/lib/agentTemplates/__tests__/toggleAgentTemplateFavoriteHandler.test.ts b/lib/agentTemplates/__tests__/toggleAgentTemplateFavoriteHandler.test.ts new file mode 100644 index 00000000..2ece474e --- /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 insertAgentTemplateFavorite from "@/lib/supabase/agent_template_favorites/insertAgentTemplateFavorite"; +import { removeAgentTemplateFavorite } from "../removeAgentTemplateFavorite"; + +// Mock dependencies +vi.mock("@/lib/auth/getAuthenticatedAccountId", () => ({ + getAuthenticatedAccountId: vi.fn(), +})); + +vi.mock("@/lib/supabase/agent_template_favorites/insertAgentTemplateFavorite", () => ({ + default: 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("templateId is required"); + }); + + 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("isFavourite is required"); + }); + }); + + describe("with valid authentication", () => { + it("adds favorite when isFavourite is true", async () => { + vi.mocked(getAuthenticatedAccountId).mockResolvedValue("user-123"); + vi.mocked(insertAgentTemplateFavorite).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(insertAgentTemplateFavorite).toHaveBeenCalledWith({ templateId: "template-1", userId: "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(insertAgentTemplateFavorite).not.toHaveBeenCalled(); + }); + }); + + describe("error handling", () => { + it("returns 500 when insertAgentTemplateFavorite throws", async () => { + vi.mocked(getAuthenticatedAccountId).mockResolvedValue("user-123"); + vi.mocked(insertAgentTemplateFavorite).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/__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/getAccountTemplates.ts b/lib/agentTemplates/getAccountTemplates.ts new file mode 100644 index 00000000..182f4adf --- /dev/null +++ b/lib/agentTemplates/getAccountTemplates.ts @@ -0,0 +1,42 @@ +import type { AgentTemplateRow } from "./types"; +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 selectAgentTemplates({ userId: accountId }); + + // Get shared templates using dedicated utility + const sharedTemplates = await getSharedTemplatesForAccount(accountId); + + // 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 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) => ({ + ...template, + is_favourite: favouriteIds.has(template.id), + })); + } + + // For anonymous users, return public templates only + const publicTemplates = await selectAgentTemplates({ userId: null }); + return publicTemplates.map((template: AgentTemplateRow) => ({ + ...template, + is_favourite: false, + })); +} diff --git a/lib/agentTemplates/getAgentTemplatesHandler.ts b/lib/agentTemplates/getAgentTemplatesHandler.ts new file mode 100644 index 00000000..df34067d --- /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 { getAccountTemplates } from "./getAccountTemplates"; +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 account + const templates = await getAccountTemplates(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..6622c5ef --- /dev/null +++ b/lib/agentTemplates/getSharedEmailsForTemplates.ts @@ -0,0 +1,54 @@ +import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmails"; +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 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))]; + + // 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/getSharedTemplatesForAccount.ts b/lib/agentTemplates/getSharedTemplatesForAccount.ts new file mode 100644 index 00000000..0d81fa7d --- /dev/null +++ b/lib/agentTemplates/getSharedTemplatesForAccount.ts @@ -0,0 +1,34 @@ +import selectAgentTemplateShares, { + type AgentTemplateShareWithTemplate, +} from "@/lib/supabase/agent_template_shares/selectAgentTemplateShares"; +import type { AgentTemplateRow } from "./types"; + +export async function getSharedTemplatesForAccount( + accountId: string, +): Promise { + const shares = (await selectAgentTemplateShares({ + userId: accountId, + includeTemplates: true, + })) as AgentTemplateShareWithTemplate[]; + + const templates: AgentTemplateRow[] = []; + const processedIds = new Set(); + + shares?.forEach((share) => { + if (!share || !share.templates) return; + + // 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) => { + if (template && template.id && !processedIds.has(template.id)) { + templates.push(template as AgentTemplateRow); + processedIds.add(template.id); + } + }); + }); + + return templates; +} 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..3fcfeb1a --- /dev/null +++ b/lib/agentTemplates/toggleAgentTemplateFavoriteHandler.ts @@ -0,0 +1,70 @@ +import { NextRequest, NextResponse } from "next/server"; +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"; +import { validateToggleAgentTemplateFavoriteBody } from "./validateToggleAgentTemplateFavoriteBody"; + +/** + * 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 and validate request body + const body = await request.json(); + const validatedBodyOrError = validateToggleAgentTemplateFavoriteBody(body); + if (validatedBodyOrError instanceof NextResponse) { + return validatedBodyOrError; + } + + const { templateId, isFavourite } = validatedBodyOrError; + + // Toggle favorite + if (isFavourite) { + await insertAgentTemplateFavorite({ templateId, userId: 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(), + }, + ); + } +} diff --git a/lib/agentTemplates/types.ts b/lib/agentTemplates/types.ts new file mode 100644 index 00000000..edb7c241 --- /dev/null +++ b/lib/agentTemplates/types.ts @@ -0,0 +1,16 @@ +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[]; +} 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; +} 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(), + }, + ); + } +} 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/__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..1bbf2dc8 --- /dev/null +++ b/lib/artistAgents/getArtistAgents.ts @@ -0,0 +1,60 @@ +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; + 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 { + let agentStatusData; + try { + agentStatusData = await selectAgentStatusBySocialIds({ socialIds: artistSocialIds }); + } catch { + return []; + } + + if (!agentStatusData.length) return []; + + const agentIds = [...new Set(agentStatusData.map(ele => ele.agent?.id).filter(Boolean))] as string[]; + + let agents; + try { + agents = await selectAgentsWithStatusAndSocials({ agentIds }); + } catch { + 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 ?? ""), + ).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/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"]; 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/agent_template_favorites/__tests__/insertAgentTemplateFavorite.test.ts b/lib/supabase/agent_template_favorites/__tests__/insertAgentTemplateFavorite.test.ts new file mode 100644 index 00000000..b27f09a3 --- /dev/null +++ b/lib/supabase/agent_template_favorites/__tests__/insertAgentTemplateFavorite.test.ts @@ -0,0 +1,116 @@ +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 }); + }); + + // 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: mockRecord, + 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", + }); + // DRY: Verify select('*') is called instead of explicit columns + expect(mockSelect).toHaveBeenCalledWith("*"); + expect(mockMaybeSingle).toHaveBeenCalled(); + // DRY: Now returns the full record instead of just { success: true } + expect(result).toEqual(mockRecord); + }); + + 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" }, + }); + + const result = await insertAgentTemplateFavorite({ + templateId: "tmpl-1", + userId: "user-1", + }); + + // 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 () => { + 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("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: mockRecord, + error: null, + }); + + const result = await insertAgentTemplateFavorite({ + templateId: "different-tmpl", + userId: "different-user", + }); + + expect(mockInsert).toHaveBeenCalledWith({ + template_id: "different-tmpl", + user_id: "different-user", + }); + // 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/__tests__/selectAgentTemplateFavorites.test.ts b/lib/supabase/agent_template_favorites/__tests__/selectAgentTemplateFavorites.test.ts new file mode 100644 index 00000000..7e038b4f --- /dev/null +++ b/lib/supabase/agent_template_favorites/__tests__/selectAgentTemplateFavorites.test.ts @@ -0,0 +1,77 @@ +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 array when userId is not provided", async () => { + const result = await selectAgentTemplateFavorites({}); + + expect(mockFrom).not.toHaveBeenCalled(); + expect(result).toEqual([]); + }); + + it("returns empty array when userId is empty string", async () => { + const result = await selectAgentTemplateFavorites({ userId: "" }); + + expect(mockFrom).not.toHaveBeenCalled(); + expect(result).toEqual([]); + }); + + it("returns full favorite records for given userId", async () => { + const mockFavorites = [ + { 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"); + // DRY: Use select('*') instead of explicit columns + expect(mockSelect).toHaveBeenCalledWith("*"); + expect(mockEq).toHaveBeenCalledWith("user_id", "user-1"); + expect(result).toEqual(mockFavorites); + }); + + 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 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([]); + }); + + 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([]); + }); +}); diff --git a/lib/supabase/agent_template_favorites/insertAgentTemplateFavorite.ts b/lib/supabase/agent_template_favorites/insertAgentTemplateFavorite.ts new file mode 100644 index 00000000..cf14c0d7 --- /dev/null +++ b/lib/supabase/agent_template_favorites/insertAgentTemplateFavorite.ts @@ -0,0 +1,37 @@ +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 + * + * @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 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({ + templateId, + userId, +}: { + templateId: string; + userId: string; +}): 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("*") + .maybeSingle(); + + // Ignore unique violation (23505) - user already favorited this template + if (error && error.code !== "23505") { + throw error; + } + + // Return the inserted record, or null if it was a duplicate + return data; +} diff --git a/lib/supabase/agent_template_favorites/selectAgentTemplateFavorites.ts b/lib/supabase/agent_template_favorites/selectAgentTemplateFavorites.ts new file mode 100644 index 00000000..f8b1e1c5 --- /dev/null +++ b/lib/supabase/agent_template_favorites/selectAgentTemplateFavorites.ts @@ -0,0 +1,37 @@ +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">; + +/** + * 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 Array of favorite records + */ +export default async function selectAgentTemplateFavorites({ + userId, +}: { + userId?: string; +}): Promise { + const hasUserId = typeof userId === "string" && userId.length > 0; + + // If no userId is provided, return empty array + if (!hasUserId) { + return []; + } + + // DRY: Use select('*') instead of explicit columns + const { data, error } = await supabase + .from("agent_template_favorites") + .select("*") + .eq("user_id", userId); + + if (error) { + throw error; + } + + return data || []; +} 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..ddff5e38 --- /dev/null +++ b/lib/supabase/agent_template_shares/__tests__/selectAgentTemplateShares.test.ts @@ -0,0 +1,177 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +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: { + from: (...args: unknown[]) => mockFrom(...args), + }, +})); + +describe("selectAgentTemplateShares", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockFrom.mockReturnValue({ select: mockSelect }); + mockSelect.mockReturnValue({ in: mockIn, eq: mockEq }); + mockIn.mockReturnValue({ eq: mockEq }); + mockEq.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 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" }, + { 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"); + // DRY: Use select('*') instead of explicit columns + expect(mockSelect).toHaveBeenCalledWith("*"); + 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([]); + }); + + describe("userId filtering", () => { + 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" }, + ]; + mockEq.mockResolvedValue({ data: mockShares, error: null }); + + const result = await selectAgentTemplateShares({ + userId: "user-1", + }); + + expect(mockFrom).toHaveBeenCalledWith("agent_template_shares"); + // DRY: Use select('*') instead of explicit columns + expect(mockSelect).toHaveBeenCalledWith("*"); + 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"); + // DRY: Use *, templates:agent_templates(*) for joins + expect(mockSelect).toHaveBeenCalledWith("*, templates:agent_templates(*)"); + expect(result).toEqual(mockSharesWithTemplates); + }); + + 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" }, + ]; + mockEq.mockResolvedValue({ data: mockShares, error: null }); + + await selectAgentTemplateShares({ + userId: "user-1", + includeTemplates: false, + }); + + // 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 new file mode 100644 index 00000000..7abf78de --- /dev/null +++ b/lib/supabase/agent_template_shares/selectAgentTemplateShares.ts @@ -0,0 +1,56 @@ +import supabase from "@/lib/supabase/serverClient"; +import type { Tables } from "@/types/database.types"; + +// DRY: Use database types instead of custom interfaces +export type AgentTemplateShare = Tables<"agent_template_shares">; + +export type AgentTemplateShareWithTemplate = AgentTemplateShare & { + templates: Tables<"agent_templates"> | null; +}; + +/** + * 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[]; + 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 []; + } + + // DRY: Use select('*') instead of explicit columns + const selectFields = includeTemplates ? "*, templates:agent_templates(*)" : "*"; + 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 || []; +} 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 ?? []; +} 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 || []; +}