From 29c4b7289cbe73db4cc6f8d7a27bd4db3fa1f9f0 Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Sat, 9 May 2026 23:08:06 +0530 Subject: [PATCH 01/32] feat(api): migrate agent templates management endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds 5 dedicated endpoints for the chat → api migration (Group 7): - GET /api/agent-templates - POST /api/agent-templates - PATCH /api/agent-templates/{id} - DELETE /api/agent-templates/{id} - PUT /api/agent-templates/{id}/favorite Auth via validateAuthContext (x-api-key or Bearer). Ownership enforced on PATCH/DELETE. GET list embeds the creator block (id/name/image/is_admin) so callers no longer need a per-card lookup against the old /api/agent-creator chat route. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../[id]/__tests__/route.test.ts | 50 +++++ .../[id]/__tests__/routeTestMocks.ts | 13 ++ .../[id]/favorite/__tests__/route.test.ts | 35 +++ .../[id]/favorite/__tests__/routeTestMocks.ts | 9 + .../agent-templates/[id]/favorite/route.ts | 37 ++++ app/api/agent-templates/[id]/route.ts | 55 +++++ .../agent-templates/__tests__/route.test.ts | 41 ++++ .../__tests__/routeTestMocks.ts | 13 ++ app/api/agent-templates/route.ts | 48 ++++ .../createAgentTemplateHandler.test.ts | 107 +++++++++ .../deleteAgentTemplateHandler.test.ts | 56 +++++ .../listAgentTemplatesHandler.test.ts | 63 ++++++ ...toggleAgentTemplateFavoriteHandler.test.ts | 89 ++++++++ .../updateAgentTemplateHandler.test.ts | 89 ++++++++ .../createAgentTemplateHandler.ts | 67 ++++++ .../deleteAgentTemplateHandler.ts | 44 ++++ .../listAgentTemplatesHandler.ts | 37 ++++ .../toggleAgentTemplateFavoriteHandler.ts | 51 +++++ .../updateAgentTemplateHandler.ts | 72 ++++++ .../validateCreateAgentTemplateBody.ts | 51 +++++ .../validateDeleteAgentTemplateRequest.ts | 49 ++++ .../validateToggleFavoriteRequest.ts | 57 +++++ .../validateUpdateAgentTemplateRequest.ts | 81 +++++++ lib/const.ts | 8 + .../deleteAgentTemplateFavorite.ts | 26 +++ .../insertAgentTemplateFavorite.ts | 27 +++ .../selectAgentTemplateFavorites.ts | 21 ++ .../deleteAgentTemplateShares.ts | 21 ++ .../insertAgentTemplateShares.ts | 39 ++++ .../selectAgentTemplateShares.ts | 26 +++ .../agent_templates/deleteAgentTemplate.ts | 19 ++ .../getAccessibleAgentTemplates.ts | 209 ++++++++++++++++++ .../getAgentTemplateWithDetails.ts | 122 ++++++++++ .../agent_templates/insertAgentTemplate.ts | 21 ++ .../agent_templates/selectAgentTemplate.ts | 23 ++ .../agent_templates/updateAgentTemplate.ts | 31 +++ 36 files changed, 1807 insertions(+) create mode 100644 app/api/agent-templates/[id]/__tests__/route.test.ts create mode 100644 app/api/agent-templates/[id]/__tests__/routeTestMocks.ts create mode 100644 app/api/agent-templates/[id]/favorite/__tests__/route.test.ts create mode 100644 app/api/agent-templates/[id]/favorite/__tests__/routeTestMocks.ts create mode 100644 app/api/agent-templates/[id]/favorite/route.ts create mode 100644 app/api/agent-templates/[id]/route.ts create mode 100644 app/api/agent-templates/__tests__/route.test.ts create mode 100644 app/api/agent-templates/__tests__/routeTestMocks.ts create mode 100644 app/api/agent-templates/route.ts create mode 100644 lib/agent_templates/__tests__/createAgentTemplateHandler.test.ts create mode 100644 lib/agent_templates/__tests__/deleteAgentTemplateHandler.test.ts create mode 100644 lib/agent_templates/__tests__/listAgentTemplatesHandler.test.ts create mode 100644 lib/agent_templates/__tests__/toggleAgentTemplateFavoriteHandler.test.ts create mode 100644 lib/agent_templates/__tests__/updateAgentTemplateHandler.test.ts create mode 100644 lib/agent_templates/createAgentTemplateHandler.ts create mode 100644 lib/agent_templates/deleteAgentTemplateHandler.ts create mode 100644 lib/agent_templates/listAgentTemplatesHandler.ts create mode 100644 lib/agent_templates/toggleAgentTemplateFavoriteHandler.ts create mode 100644 lib/agent_templates/updateAgentTemplateHandler.ts create mode 100644 lib/agent_templates/validateCreateAgentTemplateBody.ts create mode 100644 lib/agent_templates/validateDeleteAgentTemplateRequest.ts create mode 100644 lib/agent_templates/validateToggleFavoriteRequest.ts create mode 100644 lib/agent_templates/validateUpdateAgentTemplateRequest.ts create mode 100644 lib/supabase/agent_template_favorites/deleteAgentTemplateFavorite.ts create mode 100644 lib/supabase/agent_template_favorites/insertAgentTemplateFavorite.ts create mode 100644 lib/supabase/agent_template_favorites/selectAgentTemplateFavorites.ts create mode 100644 lib/supabase/agent_template_shares/deleteAgentTemplateShares.ts create mode 100644 lib/supabase/agent_template_shares/insertAgentTemplateShares.ts create mode 100644 lib/supabase/agent_template_shares/selectAgentTemplateShares.ts create mode 100644 lib/supabase/agent_templates/deleteAgentTemplate.ts create mode 100644 lib/supabase/agent_templates/getAccessibleAgentTemplates.ts create mode 100644 lib/supabase/agent_templates/getAgentTemplateWithDetails.ts create mode 100644 lib/supabase/agent_templates/insertAgentTemplate.ts create mode 100644 lib/supabase/agent_templates/selectAgentTemplate.ts create mode 100644 lib/supabase/agent_templates/updateAgentTemplate.ts diff --git a/app/api/agent-templates/[id]/__tests__/route.test.ts b/app/api/agent-templates/[id]/__tests__/route.test.ts new file mode 100644 index 000000000..742004665 --- /dev/null +++ b/app/api/agent-templates/[id]/__tests__/route.test.ts @@ -0,0 +1,50 @@ +import "./routeTestMocks"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { updateAgentTemplateHandler } from "@/lib/agent_templates/updateAgentTemplateHandler"; +import { deleteAgentTemplateHandler } from "@/lib/agent_templates/deleteAgentTemplateHandler"; + +const { PATCH, DELETE, OPTIONS } = await import("../route"); + +const TEMPLATE_ID = "22222222-2222-2222-2222-222222222222"; + +describe("app/api/agent-templates/[id]/route", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("OPTIONS returns 200 with CORS headers", async () => { + const res = await OPTIONS(); + expect(res.status).toBe(200); + expect(getCorsHeaders).toHaveBeenCalled(); + }); + + it("PATCH delegates to updateAgentTemplateHandler with the path params", async () => { + const handlerRes = NextResponse.json({ status: "success" }); + vi.mocked(updateAgentTemplateHandler).mockResolvedValue(handlerRes); + + const req = new NextRequest(`http://localhost/api/agent-templates/${TEMPLATE_ID}`, { + method: "PATCH", + }); + const params = Promise.resolve({ id: TEMPLATE_ID }); + const res = await PATCH(req, { params }); + + expect(updateAgentTemplateHandler).toHaveBeenCalledWith(req, params); + expect(res).toBe(handlerRes); + }); + + it("DELETE delegates to deleteAgentTemplateHandler with the path params", async () => { + const handlerRes = NextResponse.json({ status: "success" }); + vi.mocked(deleteAgentTemplateHandler).mockResolvedValue(handlerRes); + + const req = new NextRequest(`http://localhost/api/agent-templates/${TEMPLATE_ID}`, { + method: "DELETE", + }); + const params = Promise.resolve({ id: TEMPLATE_ID }); + const res = await DELETE(req, { params }); + + expect(deleteAgentTemplateHandler).toHaveBeenCalledWith(req, params); + expect(res).toBe(handlerRes); + }); +}); diff --git a/app/api/agent-templates/[id]/__tests__/routeTestMocks.ts b/app/api/agent-templates/[id]/__tests__/routeTestMocks.ts new file mode 100644 index 000000000..040eaae58 --- /dev/null +++ b/app/api/agent-templates/[id]/__tests__/routeTestMocks.ts @@ -0,0 +1,13 @@ +import { vi } from "vitest"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("@/lib/agent_templates/updateAgentTemplateHandler", () => ({ + updateAgentTemplateHandler: vi.fn(), +})); + +vi.mock("@/lib/agent_templates/deleteAgentTemplateHandler", () => ({ + deleteAgentTemplateHandler: vi.fn(), +})); diff --git a/app/api/agent-templates/[id]/favorite/__tests__/route.test.ts b/app/api/agent-templates/[id]/favorite/__tests__/route.test.ts new file mode 100644 index 000000000..a5a74fc1b --- /dev/null +++ b/app/api/agent-templates/[id]/favorite/__tests__/route.test.ts @@ -0,0 +1,35 @@ +import "./routeTestMocks"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { toggleAgentTemplateFavoriteHandler } from "@/lib/agent_templates/toggleAgentTemplateFavoriteHandler"; + +const { PUT, OPTIONS } = await import("../route"); + +const TEMPLATE_ID = "22222222-2222-2222-2222-222222222222"; + +describe("app/api/agent-templates/[id]/favorite/route", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("OPTIONS returns 200 with CORS headers", async () => { + const res = await OPTIONS(); + expect(res.status).toBe(200); + expect(getCorsHeaders).toHaveBeenCalled(); + }); + + it("PUT delegates to toggleAgentTemplateFavoriteHandler", async () => { + const handlerRes = NextResponse.json({ status: "success" }); + vi.mocked(toggleAgentTemplateFavoriteHandler).mockResolvedValue(handlerRes); + + const req = new NextRequest(`http://localhost/api/agent-templates/${TEMPLATE_ID}/favorite`, { + method: "PUT", + }); + const params = Promise.resolve({ id: TEMPLATE_ID }); + const res = await PUT(req, { params }); + + expect(toggleAgentTemplateFavoriteHandler).toHaveBeenCalledWith(req, params); + expect(res).toBe(handlerRes); + }); +}); diff --git a/app/api/agent-templates/[id]/favorite/__tests__/routeTestMocks.ts b/app/api/agent-templates/[id]/favorite/__tests__/routeTestMocks.ts new file mode 100644 index 000000000..d2b20b75b --- /dev/null +++ b/app/api/agent-templates/[id]/favorite/__tests__/routeTestMocks.ts @@ -0,0 +1,9 @@ +import { vi } from "vitest"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("@/lib/agent_templates/toggleAgentTemplateFavoriteHandler", () => ({ + toggleAgentTemplateFavoriteHandler: vi.fn(), +})); diff --git a/app/api/agent-templates/[id]/favorite/route.ts b/app/api/agent-templates/[id]/favorite/route.ts new file mode 100644 index 000000000..31d9f40c2 --- /dev/null +++ b/app/api/agent-templates/[id]/favorite/route.ts @@ -0,0 +1,37 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { toggleAgentTemplateFavoriteHandler } from "@/lib/agent_templates/toggleAgentTemplateFavoriteHandler"; + +/** + * OPTIONS handler for CORS preflight requests. + * + * @returns A 200 NextResponse carrying the CORS headers. + */ +export async function OPTIONS() { + return new NextResponse(null, { + status: 200, + headers: getCorsHeaders(), + }); +} + +/** + * PUT /api/agent-templates/{id}/favorite + * + * Idempotently sets whether the authenticated account has favorited the + * template: `{ is_favourite: true }` upserts a row, `false` deletes it. + * + * @param request - Incoming request; body is JSON-encoded. + * @param context - Route context. + * @param context.params - Promise resolving to `{ id }`, the template UUID. + * @returns A 200 NextResponse with `{ status: "success" }` on success. + */ +export async function PUT( + request: NextRequest, + context: { params: Promise<{ id: string }> }, +): Promise { + return toggleAgentTemplateFavoriteHandler(request, context.params); +} + +export const dynamic = "force-dynamic"; +export const fetchCache = "force-no-store"; +export const revalidate = 0; diff --git a/app/api/agent-templates/[id]/route.ts b/app/api/agent-templates/[id]/route.ts new file mode 100644 index 000000000..f4c342576 --- /dev/null +++ b/app/api/agent-templates/[id]/route.ts @@ -0,0 +1,55 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { updateAgentTemplateHandler } from "@/lib/agent_templates/updateAgentTemplateHandler"; +import { deleteAgentTemplateHandler } from "@/lib/agent_templates/deleteAgentTemplateHandler"; + +/** + * OPTIONS handler for CORS preflight requests. + * + * @returns A 200 NextResponse carrying the CORS headers. + */ +export async function OPTIONS() { + return new NextResponse(null, { + status: 200, + headers: getCorsHeaders(), + }); +} + +/** + * PATCH /api/agent-templates/{id} + * + * Updates one or more fields on an agent template the authenticated account + * owns. Supplying `share_emails` replaces existing shares. + * + * @param request - Incoming request; body is JSON-encoded. + * @param context - Route context. + * @param context.params - Promise resolving to `{ id }`, the template UUID. + * @returns A 200 NextResponse with `{ status, template }` on success. + */ +export async function PATCH( + request: NextRequest, + context: { params: Promise<{ id: string }> }, +): Promise { + return updateAgentTemplateHandler(request, context.params); +} + +/** + * DELETE /api/agent-templates/{id} + * + * Permanently removes an agent template the authenticated account owns. + * + * @param request - Incoming request; auth is read from headers. + * @param context - Route context. + * @param context.params - Promise resolving to `{ id }`, the template UUID. + * @returns A 200 NextResponse with `{ status: "success" }` on success. + */ +export async function DELETE( + request: NextRequest, + context: { params: Promise<{ id: string }> }, +): Promise { + return deleteAgentTemplateHandler(request, context.params); +} + +export const dynamic = "force-dynamic"; +export const fetchCache = "force-no-store"; +export const revalidate = 0; diff --git a/app/api/agent-templates/__tests__/route.test.ts b/app/api/agent-templates/__tests__/route.test.ts new file mode 100644 index 000000000..e909a201e --- /dev/null +++ b/app/api/agent-templates/__tests__/route.test.ts @@ -0,0 +1,41 @@ +import "./routeTestMocks"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { listAgentTemplatesHandler } from "@/lib/agent_templates/listAgentTemplatesHandler"; +import { createAgentTemplateHandler } from "@/lib/agent_templates/createAgentTemplateHandler"; + +const { GET, POST, OPTIONS } = await import("../route"); + +describe("app/api/agent-templates/route", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("OPTIONS returns 200 with CORS headers", async () => { + const res = await OPTIONS(); + expect(res.status).toBe(200); + expect(getCorsHeaders).toHaveBeenCalled(); + expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*"); + }); + + it("GET delegates to listAgentTemplatesHandler", async () => { + const handlerRes = NextResponse.json({ status: "success", templates: [] }); + vi.mocked(listAgentTemplatesHandler).mockResolvedValue(handlerRes); + + const req = new NextRequest("http://localhost/api/agent-templates"); + const res = await GET(req); + expect(listAgentTemplatesHandler).toHaveBeenCalledWith(req); + expect(res).toBe(handlerRes); + }); + + it("POST delegates to createAgentTemplateHandler", async () => { + const handlerRes = NextResponse.json({ status: "success" }, { status: 201 }); + vi.mocked(createAgentTemplateHandler).mockResolvedValue(handlerRes); + + const req = new NextRequest("http://localhost/api/agent-templates", { method: "POST" }); + const res = await POST(req); + expect(createAgentTemplateHandler).toHaveBeenCalledWith(req); + expect(res).toBe(handlerRes); + }); +}); diff --git a/app/api/agent-templates/__tests__/routeTestMocks.ts b/app/api/agent-templates/__tests__/routeTestMocks.ts new file mode 100644 index 000000000..566e2bb27 --- /dev/null +++ b/app/api/agent-templates/__tests__/routeTestMocks.ts @@ -0,0 +1,13 @@ +import { vi } from "vitest"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("@/lib/agent_templates/listAgentTemplatesHandler", () => ({ + listAgentTemplatesHandler: vi.fn(), +})); + +vi.mock("@/lib/agent_templates/createAgentTemplateHandler", () => ({ + createAgentTemplateHandler: vi.fn(), +})); diff --git a/app/api/agent-templates/route.ts b/app/api/agent-templates/route.ts new file mode 100644 index 000000000..6192eb4eb --- /dev/null +++ b/app/api/agent-templates/route.ts @@ -0,0 +1,48 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { listAgentTemplatesHandler } from "@/lib/agent_templates/listAgentTemplatesHandler"; +import { createAgentTemplateHandler } from "@/lib/agent_templates/createAgentTemplateHandler"; + +/** + * OPTIONS handler for CORS preflight requests. + * + * @returns A 200 NextResponse carrying the CORS headers. + */ +export async function OPTIONS() { + return new NextResponse(null, { + status: 200, + headers: getCorsHeaders(), + }); +} + +/** + * GET /api/agent-templates + * + * Returns every agent template visible to the authenticated account (own, + * public, and shared) with an embedded creator block (id/name/image/is_admin), + * the caller's `is_favourite` flag, and `shared_emails` for private templates. + * + * @param request - Incoming request; auth is read from headers. + * @returns A 200 NextResponse with `{ status, templates }`. + */ +export async function GET(request: NextRequest): Promise { + return listAgentTemplatesHandler(request); +} + +/** + * POST /api/agent-templates + * + * Creates a new agent template owned by the authenticated account. When + * `is_private=true`, `share_emails` recipients are upserted into the shares + * table. + * + * @param request - Incoming request; body is JSON-encoded. + * @returns A 201 NextResponse with `{ status, template }` on success. + */ +export async function POST(request: NextRequest): Promise { + return createAgentTemplateHandler(request); +} + +export const dynamic = "force-dynamic"; +export const fetchCache = "force-no-store"; +export const revalidate = 0; diff --git a/lib/agent_templates/__tests__/createAgentTemplateHandler.test.ts b/lib/agent_templates/__tests__/createAgentTemplateHandler.test.ts new file mode 100644 index 000000000..1f2a2565f --- /dev/null +++ b/lib/agent_templates/__tests__/createAgentTemplateHandler.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), +})); + +vi.mock("@/lib/supabase/agent_templates/insertAgentTemplate", () => ({ + insertAgentTemplate: vi.fn(), +})); + +vi.mock("@/lib/supabase/agent_template_shares/insertAgentTemplateShares", () => ({ + insertAgentTemplateShares: vi.fn(), +})); + +vi.mock("@/lib/supabase/agent_templates/getAgentTemplateWithDetails", () => ({ + getAgentTemplateWithDetails: vi.fn(), +})); + +const { createAgentTemplateHandler } = await import("../createAgentTemplateHandler"); +const { validateAuthContext } = await import("@/lib/auth/validateAuthContext"); +const { insertAgentTemplate } = await import("@/lib/supabase/agent_templates/insertAgentTemplate"); +const { insertAgentTemplateShares } = await import( + "@/lib/supabase/agent_template_shares/insertAgentTemplateShares" +); +const { getAgentTemplateWithDetails } = await import( + "@/lib/supabase/agent_templates/getAgentTemplateWithDetails" +); + +const ACCOUNT_ID = "11111111-1111-1111-1111-111111111111"; +const TEMPLATE_ID = "22222222-2222-2222-2222-222222222222"; + +function makeRequest(body: unknown) { + return new NextRequest("http://localhost/api/agent-templates", { + method: "POST", + headers: { "x-api-key": "k", "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +describe("createAgentTemplateHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("creates a template and shares emails when private", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: ACCOUNT_ID, + orgId: null, + authToken: "k", + }); + + vi.mocked(insertAgentTemplate).mockResolvedValue({ id: TEMPLATE_ID } as any); + vi.mocked(insertAgentTemplateShares).mockResolvedValue(1); + vi.mocked(getAgentTemplateWithDetails).mockResolvedValue({ + id: TEMPLATE_ID, + title: "Hello world title", + } as any); + + const req = makeRequest({ + title: "My Template", + description: "A useful description", + prompt: "This is the prompt content for the template", + tags: ["a", "b"], + is_private: true, + share_emails: ["a@x.com"], + }); + + const res = await createAgentTemplateHandler(req); + expect(res.status).toBe(201); + const body = await res.json(); + expect(body.status).toBe("success"); + expect(body.template.id).toBe(TEMPLATE_ID); + expect(insertAgentTemplate).toHaveBeenCalledWith( + expect.objectContaining({ creator: ACCOUNT_ID, is_private: true }), + ); + expect(insertAgentTemplateShares).toHaveBeenCalledWith(TEMPLATE_ID, ["a@x.com"]); + }); + + it("returns 400 when validation fails", async () => { + const req = makeRequest({ title: "no" }); + const res = await createAgentTemplateHandler(req); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.status).toBe("error"); + expect(validateAuthContext).not.toHaveBeenCalled(); + }); + + it("returns 401 when auth fails", async () => { + const failure = NextResponse.json({ status: "error", error: "Unauthorized" }, { status: 401 }); + vi.mocked(validateAuthContext).mockResolvedValue(failure); + const req = makeRequest({ + title: "Valid title", + description: "valid description", + prompt: "Valid prompt content for template", + tags: [], + is_private: false, + share_emails: [], + }); + const res = await createAgentTemplateHandler(req); + expect(res).toBe(failure); + }); +}); diff --git a/lib/agent_templates/__tests__/deleteAgentTemplateHandler.test.ts b/lib/agent_templates/__tests__/deleteAgentTemplateHandler.test.ts new file mode 100644 index 000000000..fe90b09e5 --- /dev/null +++ b/lib/agent_templates/__tests__/deleteAgentTemplateHandler.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("@/lib/agent_templates/validateDeleteAgentTemplateRequest", () => ({ + validateDeleteAgentTemplateRequest: vi.fn(), +})); + +vi.mock("@/lib/supabase/agent_templates/deleteAgentTemplate", () => ({ + deleteAgentTemplate: vi.fn(), +})); + +const { deleteAgentTemplateHandler } = await import("../deleteAgentTemplateHandler"); +const { validateDeleteAgentTemplateRequest } = await import( + "@/lib/agent_templates/validateDeleteAgentTemplateRequest" +); +const { deleteAgentTemplate } = await import("@/lib/supabase/agent_templates/deleteAgentTemplate"); + +const ACCOUNT_ID = "11111111-1111-1111-1111-111111111111"; +const TEMPLATE_ID = "22222222-2222-2222-2222-222222222222"; + +describe("deleteAgentTemplateHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns success when delete succeeds", async () => { + vi.mocked(validateDeleteAgentTemplateRequest).mockResolvedValue({ + templateId: TEMPLATE_ID, + accountId: ACCOUNT_ID, + }); + vi.mocked(deleteAgentTemplate).mockResolvedValue(true); + + const req = new NextRequest(`http://localhost/api/agent-templates/${TEMPLATE_ID}`, { + method: "DELETE", + }); + const res = await deleteAgentTemplateHandler(req, Promise.resolve({ id: TEMPLATE_ID })); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ status: "success" }); + }); + + it("returns the validator error response when ownership fails", async () => { + const failure = NextResponse.json({ status: "error", error: "Forbidden" }, { status: 403 }); + vi.mocked(validateDeleteAgentTemplateRequest).mockResolvedValue(failure); + + const req = new NextRequest(`http://localhost/api/agent-templates/${TEMPLATE_ID}`, { + method: "DELETE", + }); + const res = await deleteAgentTemplateHandler(req, Promise.resolve({ id: TEMPLATE_ID })); + expect(res).toBe(failure); + expect(deleteAgentTemplate).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/agent_templates/__tests__/listAgentTemplatesHandler.test.ts b/lib/agent_templates/__tests__/listAgentTemplatesHandler.test.ts new file mode 100644 index 000000000..1af5cfb8f --- /dev/null +++ b/lib/agent_templates/__tests__/listAgentTemplatesHandler.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), +})); + +vi.mock("@/lib/supabase/agent_templates/getAccessibleAgentTemplates", () => ({ + getAccessibleAgentTemplates: vi.fn(), +})); + +const { listAgentTemplatesHandler } = await import("../listAgentTemplatesHandler"); +const { validateAuthContext } = await import("@/lib/auth/validateAuthContext"); +const { getAccessibleAgentTemplates } = await import( + "@/lib/supabase/agent_templates/getAccessibleAgentTemplates" +); + +const ACCOUNT_ID = "11111111-1111-1111-1111-111111111111"; + +describe("listAgentTemplatesHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns templates for the authenticated account", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: ACCOUNT_ID, + orgId: null, + authToken: "k", + }); + vi.mocked(getAccessibleAgentTemplates).mockResolvedValue([ + // Cast to match the enriched shape we expect downstream. + + { id: "t1", title: "T", shared_emails: [] } as any, + ]); + + const req = new NextRequest("http://localhost/api/agent-templates", { + headers: { "x-api-key": "k" }, + }); + const res = await listAgentTemplatesHandler(req); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.status).toBe("success"); + expect(body.templates).toHaveLength(1); + expect(getAccessibleAgentTemplates).toHaveBeenCalledWith(ACCOUNT_ID); + }); + + it("returns the auth NextResponse when authentication fails", async () => { + const failure = NextResponse.json({ status: "error", error: "Unauthorized" }, { status: 401 }); + vi.mocked(validateAuthContext).mockResolvedValue(failure); + + const req = new NextRequest("http://localhost/api/agent-templates"); + const res = await listAgentTemplatesHandler(req); + + expect(res).toBe(failure); + expect(getAccessibleAgentTemplates).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/agent_templates/__tests__/toggleAgentTemplateFavoriteHandler.test.ts b/lib/agent_templates/__tests__/toggleAgentTemplateFavoriteHandler.test.ts new file mode 100644 index 000000000..02bd55c74 --- /dev/null +++ b/lib/agent_templates/__tests__/toggleAgentTemplateFavoriteHandler.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("@/lib/agent_templates/validateToggleFavoriteRequest", () => ({ + validateToggleFavoriteRequest: vi.fn(), +})); + +vi.mock("@/lib/supabase/agent_template_favorites/insertAgentTemplateFavorite", () => ({ + insertAgentTemplateFavorite: vi.fn(), +})); + +vi.mock("@/lib/supabase/agent_template_favorites/deleteAgentTemplateFavorite", () => ({ + deleteAgentTemplateFavorite: vi.fn(), +})); + +const { toggleAgentTemplateFavoriteHandler } = await import( + "../toggleAgentTemplateFavoriteHandler" +); +const { validateToggleFavoriteRequest } = await import( + "@/lib/agent_templates/validateToggleFavoriteRequest" +); +const { insertAgentTemplateFavorite } = await import( + "@/lib/supabase/agent_template_favorites/insertAgentTemplateFavorite" +); +const { deleteAgentTemplateFavorite } = await import( + "@/lib/supabase/agent_template_favorites/deleteAgentTemplateFavorite" +); + +const ACCOUNT_ID = "11111111-1111-1111-1111-111111111111"; +const TEMPLATE_ID = "22222222-2222-2222-2222-222222222222"; + +describe("toggleAgentTemplateFavoriteHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("inserts a favorite when is_favourite is true", async () => { + vi.mocked(validateToggleFavoriteRequest).mockResolvedValue({ + templateId: TEMPLATE_ID, + accountId: ACCOUNT_ID, + isFavourite: true, + }); + vi.mocked(insertAgentTemplateFavorite).mockResolvedValue(true); + + const req = new NextRequest(`http://localhost/api/agent-templates/${TEMPLATE_ID}/favorite`, { + method: "PUT", + }); + const res = await toggleAgentTemplateFavoriteHandler(req, Promise.resolve({ id: TEMPLATE_ID })); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ status: "success" }); + expect(insertAgentTemplateFavorite).toHaveBeenCalledWith(TEMPLATE_ID, ACCOUNT_ID); + expect(deleteAgentTemplateFavorite).not.toHaveBeenCalled(); + }); + + it("deletes a favorite when is_favourite is false", async () => { + vi.mocked(validateToggleFavoriteRequest).mockResolvedValue({ + templateId: TEMPLATE_ID, + accountId: ACCOUNT_ID, + isFavourite: false, + }); + vi.mocked(deleteAgentTemplateFavorite).mockResolvedValue(true); + + const req = new NextRequest(`http://localhost/api/agent-templates/${TEMPLATE_ID}/favorite`, { + method: "PUT", + }); + const res = await toggleAgentTemplateFavoriteHandler(req, Promise.resolve({ id: TEMPLATE_ID })); + expect(res.status).toBe(200); + expect(deleteAgentTemplateFavorite).toHaveBeenCalledWith(TEMPLATE_ID, ACCOUNT_ID); + expect(insertAgentTemplateFavorite).not.toHaveBeenCalled(); + }); + + it("returns the validator error response on validation failure", async () => { + const failure = NextResponse.json( + { status: "error", error: "is_favourite is required" }, + { status: 400 }, + ); + vi.mocked(validateToggleFavoriteRequest).mockResolvedValue(failure); + + const req = new NextRequest(`http://localhost/api/agent-templates/${TEMPLATE_ID}/favorite`, { + method: "PUT", + }); + const res = await toggleAgentTemplateFavoriteHandler(req, Promise.resolve({ id: TEMPLATE_ID })); + expect(res).toBe(failure); + }); +}); diff --git a/lib/agent_templates/__tests__/updateAgentTemplateHandler.test.ts b/lib/agent_templates/__tests__/updateAgentTemplateHandler.test.ts new file mode 100644 index 000000000..daad238c8 --- /dev/null +++ b/lib/agent_templates/__tests__/updateAgentTemplateHandler.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("@/lib/agent_templates/validateUpdateAgentTemplateRequest", () => ({ + validateUpdateAgentTemplateRequest: vi.fn(), +})); + +vi.mock("@/lib/supabase/agent_templates/updateAgentTemplate", () => ({ + updateAgentTemplate: vi.fn(), +})); + +vi.mock("@/lib/supabase/agent_template_shares/deleteAgentTemplateShares", () => ({ + deleteAgentTemplateShares: vi.fn(), +})); + +vi.mock("@/lib/supabase/agent_template_shares/insertAgentTemplateShares", () => ({ + insertAgentTemplateShares: vi.fn(), +})); + +vi.mock("@/lib/supabase/agent_templates/getAgentTemplateWithDetails", () => ({ + getAgentTemplateWithDetails: vi.fn(), +})); + +const { updateAgentTemplateHandler } = await import("../updateAgentTemplateHandler"); +const { validateUpdateAgentTemplateRequest } = await import( + "@/lib/agent_templates/validateUpdateAgentTemplateRequest" +); +const { updateAgentTemplate } = await import("@/lib/supabase/agent_templates/updateAgentTemplate"); +const { deleteAgentTemplateShares } = await import( + "@/lib/supabase/agent_template_shares/deleteAgentTemplateShares" +); +const { insertAgentTemplateShares } = await import( + "@/lib/supabase/agent_template_shares/insertAgentTemplateShares" +); +const { getAgentTemplateWithDetails } = await import( + "@/lib/supabase/agent_templates/getAgentTemplateWithDetails" +); + +const ACCOUNT_ID = "11111111-1111-1111-1111-111111111111"; +const TEMPLATE_ID = "22222222-2222-2222-2222-222222222222"; + +describe("updateAgentTemplateHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("updates the template and replaces shares when share_emails provided", async () => { + vi.mocked(validateUpdateAgentTemplateRequest).mockResolvedValue({ + templateId: TEMPLATE_ID, + accountId: ACCOUNT_ID, + body: { title: "New Title", share_emails: ["x@y.com"] }, + }); + + vi.mocked(updateAgentTemplate).mockResolvedValue({ id: TEMPLATE_ID } as any); + vi.mocked(deleteAgentTemplateShares).mockResolvedValue(true); + vi.mocked(insertAgentTemplateShares).mockResolvedValue(1); + + vi.mocked(getAgentTemplateWithDetails).mockResolvedValue({ id: TEMPLATE_ID } as any); + + const req = new NextRequest(`http://localhost/api/agent-templates/${TEMPLATE_ID}`, { + method: "PATCH", + }); + const res = await updateAgentTemplateHandler(req, Promise.resolve({ id: TEMPLATE_ID })); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.status).toBe("success"); + expect(updateAgentTemplate).toHaveBeenCalledWith(TEMPLATE_ID, { title: "New Title" }); + expect(deleteAgentTemplateShares).toHaveBeenCalledWith(TEMPLATE_ID); + expect(insertAgentTemplateShares).toHaveBeenCalledWith(TEMPLATE_ID, ["x@y.com"]); + }); + + it("returns the validator error response when validation fails", async () => { + const failure = NextResponse.json({ status: "error", error: "Forbidden" }, { status: 403 }); + vi.mocked(validateUpdateAgentTemplateRequest).mockResolvedValue(failure); + + const req = new NextRequest(`http://localhost/api/agent-templates/${TEMPLATE_ID}`, { + method: "PATCH", + }); + const res = await updateAgentTemplateHandler(req, Promise.resolve({ id: TEMPLATE_ID })); + + expect(res).toBe(failure); + expect(updateAgentTemplate).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/agent_templates/createAgentTemplateHandler.ts b/lib/agent_templates/createAgentTemplateHandler.ts new file mode 100644 index 000000000..9c494e91b --- /dev/null +++ b/lib/agent_templates/createAgentTemplateHandler.ts @@ -0,0 +1,67 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { safeParseJson } from "@/lib/networking/safeParseJson"; +import { validateCreateAgentTemplateBody } from "@/lib/agent_templates/validateCreateAgentTemplateBody"; +import { insertAgentTemplate } from "@/lib/supabase/agent_templates/insertAgentTemplate"; +import { insertAgentTemplateShares } from "@/lib/supabase/agent_template_shares/insertAgentTemplateShares"; +import { getAgentTemplateWithDetails } from "@/lib/supabase/agent_templates/getAgentTemplateWithDetails"; + +/** + * Handler for POST /api/agent-templates. + * + * Creates an agent template owned by the authenticated account. When + * `is_private=true`, the supplied `share_emails` are resolved to accounts and + * upserted into `agent_template_shares`. + * + * @param request - The incoming request + * @returns A 201 NextResponse with `{ status, template }`, or an error. + */ +export async function createAgentTemplateHandler(request: NextRequest): Promise { + try { + const body = await safeParseJson(request); + const parsedBody = validateCreateAgentTemplateBody(body); + if (parsedBody instanceof NextResponse) return parsedBody; + + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) return authResult; + + const accountId = authResult.accountId; + + const inserted = await insertAgentTemplate({ + title: parsedBody.title, + description: parsedBody.description, + prompt: parsedBody.prompt, + tags: parsedBody.tags, + is_private: parsedBody.is_private, + creator: accountId, + }); + + if (!inserted) { + return NextResponse.json( + { status: "error", error: "Failed to create agent template" }, + { status: 500, headers: getCorsHeaders() }, + ); + } + + if (parsedBody.is_private && parsedBody.share_emails.length > 0) { + await insertAgentTemplateShares(inserted.id, parsedBody.share_emails); + } + + const template = await getAgentTemplateWithDetails(inserted.id, accountId); + + return NextResponse.json( + { status: "success", template }, + { status: 201, headers: getCorsHeaders() }, + ); + } catch (error) { + console.error("[ERROR] createAgentTemplateHandler:", error); + return NextResponse.json( + { + status: "error", + error: error instanceof Error ? error.message : "Internal server error", + }, + { status: 500, headers: getCorsHeaders() }, + ); + } +} diff --git a/lib/agent_templates/deleteAgentTemplateHandler.ts b/lib/agent_templates/deleteAgentTemplateHandler.ts new file mode 100644 index 000000000..031b56a90 --- /dev/null +++ b/lib/agent_templates/deleteAgentTemplateHandler.ts @@ -0,0 +1,44 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateDeleteAgentTemplateRequest } from "@/lib/agent_templates/validateDeleteAgentTemplateRequest"; +import { deleteAgentTemplate } from "@/lib/supabase/agent_templates/deleteAgentTemplate"; + +/** + * Handler for DELETE /api/agent-templates/{id}. + * + * Permanently removes the agent template. Caller must be the template's + * creator. + * + * @param request - The incoming request + * @param params - Route params containing the template id + * @returns A 200 NextResponse with `{ status: "success" }`, or an error. + */ +export async function deleteAgentTemplateHandler( + request: NextRequest, + params: Promise<{ id: string }>, +): Promise { + try { + const { id } = await params; + const validated = await validateDeleteAgentTemplateRequest(request, id); + if (validated instanceof NextResponse) return validated; + + const ok = await deleteAgentTemplate(validated.templateId); + if (!ok) { + return NextResponse.json( + { status: "error", error: "Failed to delete agent template" }, + { status: 500, headers: getCorsHeaders() }, + ); + } + + return NextResponse.json({ status: "success" }, { status: 200, headers: getCorsHeaders() }); + } catch (error) { + console.error("[ERROR] deleteAgentTemplateHandler:", error); + return NextResponse.json( + { + status: "error", + error: error instanceof Error ? error.message : "Internal server error", + }, + { status: 500, headers: getCorsHeaders() }, + ); + } +} diff --git a/lib/agent_templates/listAgentTemplatesHandler.ts b/lib/agent_templates/listAgentTemplatesHandler.ts new file mode 100644 index 000000000..e3dbce4a5 --- /dev/null +++ b/lib/agent_templates/listAgentTemplatesHandler.ts @@ -0,0 +1,37 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { getAccessibleAgentTemplates } from "@/lib/supabase/agent_templates/getAccessibleAgentTemplates"; + +/** + * Handler for GET /api/agent-templates. + * + * Returns every agent template the authenticated account can see (own + public + * + shared) with the creator block, `is_favourite`, and `shared_emails` + * embedded. + * + * @param request - The incoming request + * @returns A 200 NextResponse with `{ status, templates }`, or an error. + */ +export async function listAgentTemplatesHandler(request: NextRequest): Promise { + try { + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) return authResult; + + const templates = await getAccessibleAgentTemplates(authResult.accountId); + + return NextResponse.json( + { status: "success", templates }, + { status: 200, headers: getCorsHeaders() }, + ); + } catch (error) { + console.error("[ERROR] listAgentTemplatesHandler:", error); + return NextResponse.json( + { + status: "error", + error: error instanceof Error ? error.message : "Internal server error", + }, + { status: 500, headers: getCorsHeaders() }, + ); + } +} diff --git a/lib/agent_templates/toggleAgentTemplateFavoriteHandler.ts b/lib/agent_templates/toggleAgentTemplateFavoriteHandler.ts new file mode 100644 index 000000000..2dd0bb086 --- /dev/null +++ b/lib/agent_templates/toggleAgentTemplateFavoriteHandler.ts @@ -0,0 +1,51 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateToggleFavoriteRequest } from "@/lib/agent_templates/validateToggleFavoriteRequest"; +import { insertAgentTemplateFavorite } from "@/lib/supabase/agent_template_favorites/insertAgentTemplateFavorite"; +import { deleteAgentTemplateFavorite } from "@/lib/supabase/agent_template_favorites/deleteAgentTemplateFavorite"; + +/** + * Handler for PUT /api/agent-templates/{id}/favorite. + * + * Idempotently toggles the caller's favorite status for the template: + * - `is_favourite=true` upserts the favorite row + * - `is_favourite=false` deletes it + * + * @param request - The incoming request + * @param params - Route params containing the template id + * @returns A 200 NextResponse with `{ status: "success" }`, or an error. + */ +export async function toggleAgentTemplateFavoriteHandler( + request: NextRequest, + params: Promise<{ id: string }>, +): Promise { + try { + const { id } = await params; + const validated = await validateToggleFavoriteRequest(request, id); + if (validated instanceof NextResponse) return validated; + + const { templateId, accountId, isFavourite } = validated; + + const ok = isFavourite + ? await insertAgentTemplateFavorite(templateId, accountId) + : await deleteAgentTemplateFavorite(templateId, accountId); + + if (!ok) { + return NextResponse.json( + { status: "error", error: "Failed to toggle favorite" }, + { status: 500, headers: getCorsHeaders() }, + ); + } + + return NextResponse.json({ status: "success" }, { status: 200, headers: getCorsHeaders() }); + } catch (error) { + console.error("[ERROR] toggleAgentTemplateFavoriteHandler:", error); + return NextResponse.json( + { + status: "error", + error: error instanceof Error ? error.message : "Internal server error", + }, + { status: 500, headers: getCorsHeaders() }, + ); + } +} diff --git a/lib/agent_templates/updateAgentTemplateHandler.ts b/lib/agent_templates/updateAgentTemplateHandler.ts new file mode 100644 index 000000000..3737831fc --- /dev/null +++ b/lib/agent_templates/updateAgentTemplateHandler.ts @@ -0,0 +1,72 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateUpdateAgentTemplateRequest } from "@/lib/agent_templates/validateUpdateAgentTemplateRequest"; +import { updateAgentTemplate } from "@/lib/supabase/agent_templates/updateAgentTemplate"; +import { deleteAgentTemplateShares } from "@/lib/supabase/agent_template_shares/deleteAgentTemplateShares"; +import { insertAgentTemplateShares } from "@/lib/supabase/agent_template_shares/insertAgentTemplateShares"; +import { getAgentTemplateWithDetails } from "@/lib/supabase/agent_templates/getAgentTemplateWithDetails"; +import type { TablesUpdate } from "@/types/database.types"; + +/** + * Handler for PATCH /api/agent-templates/{id}. + * + * Applies a partial update to an agent template the caller owns. When + * `share_emails` is provided, existing shares are wiped and re-inserted from + * the resolved emails. + * + * @param request - The incoming request + * @param params - Route params containing the template id + * @returns A 200 NextResponse with `{ status, template }`, or an error. + */ +export async function updateAgentTemplateHandler( + request: NextRequest, + params: Promise<{ id: string }>, +): Promise { + try { + const { id } = await params; + const validated = await validateUpdateAgentTemplateRequest(request, id); + if (validated instanceof NextResponse) return validated; + + const { templateId, accountId, body } = validated; + + const updates: TablesUpdate<"agent_templates"> = {}; + if (typeof body.title !== "undefined") updates.title = body.title; + if (typeof body.description !== "undefined") updates.description = body.description; + if (typeof body.prompt !== "undefined") updates.prompt = body.prompt; + if (typeof body.tags !== "undefined") updates.tags = body.tags; + if (typeof body.is_private !== "undefined") updates.is_private = body.is_private; + + if (Object.keys(updates).length > 0) { + const updated = await updateAgentTemplate(templateId, updates); + if (!updated) { + return NextResponse.json( + { status: "error", error: "Failed to update agent template" }, + { status: 500, headers: getCorsHeaders() }, + ); + } + } + + if (typeof body.share_emails !== "undefined") { + await deleteAgentTemplateShares(templateId); + if (body.share_emails.length > 0) { + await insertAgentTemplateShares(templateId, body.share_emails); + } + } + + const template = await getAgentTemplateWithDetails(templateId, accountId); + + return NextResponse.json( + { status: "success", template }, + { status: 200, headers: getCorsHeaders() }, + ); + } catch (error) { + console.error("[ERROR] updateAgentTemplateHandler:", error); + return NextResponse.json( + { + status: "error", + error: error instanceof Error ? error.message : "Internal server error", + }, + { status: 500, headers: getCorsHeaders() }, + ); + } +} diff --git a/lib/agent_templates/validateCreateAgentTemplateBody.ts b/lib/agent_templates/validateCreateAgentTemplateBody.ts new file mode 100644 index 000000000..fcccf2f4b --- /dev/null +++ b/lib/agent_templates/validateCreateAgentTemplateBody.ts @@ -0,0 +1,51 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; + +export const createAgentTemplateBodySchema = z.object({ + title: z + .string({ message: "title is required" }) + .min(3, "title must be at least 3 characters") + .max(50, "title must be at most 50 characters"), + description: z + .string({ message: "description is required" }) + .min(10, "description must be at least 10 characters") + .max(200, "description must be at most 200 characters"), + prompt: z + .string({ message: "prompt is required" }) + .min(20, "prompt must be at least 20 characters") + .max(10000, "prompt must be at most 10000 characters"), + tags: z.array(z.string(), { message: "tags must be an array of strings" }), + is_private: z.boolean({ message: "is_private is required" }), + share_emails: z + .array(z.string().email("share_emails must contain valid email addresses")) + .default([]), +}); + +export type CreateAgentTemplateBody = z.infer; + +/** + * Validates the JSON body for POST /api/agent-templates. + * + * @param body - The raw JSON body + * @returns A NextResponse with a 400 error or the parsed body on success. + */ +export function validateCreateAgentTemplateBody( + body: unknown, +): NextResponse | CreateAgentTemplateBody { + const result = createAgentTemplateBodySchema.safeParse(body); + + if (!result.success) { + const firstError = result.error.issues[0]; + return NextResponse.json( + { + status: "error", + missing_fields: firstError.path, + error: firstError.message, + }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + return result.data; +} diff --git a/lib/agent_templates/validateDeleteAgentTemplateRequest.ts b/lib/agent_templates/validateDeleteAgentTemplateRequest.ts new file mode 100644 index 000000000..4111f7b46 --- /dev/null +++ b/lib/agent_templates/validateDeleteAgentTemplateRequest.ts @@ -0,0 +1,49 @@ +import { NextRequest, NextResponse } from "next/server"; +import { validateAccountParams } from "@/lib/accounts/validateAccountParams"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { selectAgentTemplate } from "@/lib/supabase/agent_templates/selectAgentTemplate"; + +export interface ValidatedDeleteAgentTemplateRequest { + templateId: string; + accountId: string; +} + +/** + * Validates DELETE /api/agent-templates/{id}: id format, auth, and that the + * caller is the template's creator. + * + * @param request - The incoming request + * @param id - The template id from the route + * @returns Validated payload, or a NextResponse error. + */ +export async function validateDeleteAgentTemplateRequest( + request: NextRequest, + id: string, +): Promise { + const validatedParams = validateAccountParams(id); + if (validatedParams instanceof NextResponse) return validatedParams; + + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) return authResult; + + const templateId = validatedParams.id; + const accountId = authResult.accountId; + + const existing = await selectAgentTemplate(templateId); + if (!existing) { + return NextResponse.json( + { status: "error", error: "Agent template not found" }, + { status: 404, headers: getCorsHeaders() }, + ); + } + + if (existing.creator !== accountId) { + return NextResponse.json( + { status: "error", error: "Forbidden" }, + { status: 403, headers: getCorsHeaders() }, + ); + } + + return { templateId, accountId }; +} diff --git a/lib/agent_templates/validateToggleFavoriteRequest.ts b/lib/agent_templates/validateToggleFavoriteRequest.ts new file mode 100644 index 000000000..5810bae07 --- /dev/null +++ b/lib/agent_templates/validateToggleFavoriteRequest.ts @@ -0,0 +1,57 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { validateAccountParams } from "@/lib/accounts/validateAccountParams"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { safeParseJson } from "@/lib/networking/safeParseJson"; + +export const toggleFavoriteBodySchema = z.object({ + is_favourite: z.boolean({ message: "is_favourite is required" }), +}); + +export type ToggleFavoriteBody = z.infer; + +export interface ValidatedToggleFavoriteRequest { + templateId: string; + accountId: string; + isFavourite: boolean; +} + +/** + * Validates PUT /api/agent-templates/{id}/favorite: id format, auth, and the + * `{ is_favourite: boolean }` body. + * + * @param request - The incoming request + * @param id - The template id from the route + * @returns Validated payload, or a NextResponse error. + */ +export async function validateToggleFavoriteRequest( + request: NextRequest, + id: string, +): Promise { + const validatedParams = validateAccountParams(id); + if (validatedParams instanceof NextResponse) return validatedParams; + + const body = await safeParseJson(request); + const parsedBody = toggleFavoriteBodySchema.safeParse(body); + if (!parsedBody.success) { + const firstError = parsedBody.error.issues[0]; + return NextResponse.json( + { + status: "error", + missing_fields: firstError.path, + error: firstError.message, + }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) return authResult; + + return { + templateId: validatedParams.id, + accountId: authResult.accountId, + isFavourite: parsedBody.data.is_favourite, + }; +} diff --git a/lib/agent_templates/validateUpdateAgentTemplateRequest.ts b/lib/agent_templates/validateUpdateAgentTemplateRequest.ts new file mode 100644 index 000000000..b0f2bddb4 --- /dev/null +++ b/lib/agent_templates/validateUpdateAgentTemplateRequest.ts @@ -0,0 +1,81 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { validateAccountParams } from "@/lib/accounts/validateAccountParams"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { safeParseJson } from "@/lib/networking/safeParseJson"; +import { selectAgentTemplate } from "@/lib/supabase/agent_templates/selectAgentTemplate"; + +export const updateAgentTemplateBodySchema = z + .object({ + title: z.string().min(3).max(50).optional(), + description: z.string().min(10).max(200).optional(), + prompt: z.string().min(20).max(10000).optional(), + tags: z.array(z.string()).optional(), + is_private: z.boolean().optional(), + share_emails: z.array(z.string().email()).optional(), + }) + .refine(data => Object.values(data).some(value => value !== undefined), { + message: "At least one field to update must be provided", + }); + +export type UpdateAgentTemplateBody = z.infer; + +export interface ValidatedUpdateAgentTemplateRequest { + templateId: string; + accountId: string; + body: UpdateAgentTemplateBody; +} + +/** + * Validates PATCH /api/agent-templates/{id}: id format, body, auth, and that + * the caller is the template's creator. + * + * @param request - The incoming request + * @param id - The template id from the route + * @returns Validated payload, or a NextResponse error. + */ +export async function validateUpdateAgentTemplateRequest( + request: NextRequest, + id: string, +): Promise { + const validatedParams = validateAccountParams(id); + if (validatedParams instanceof NextResponse) return validatedParams; + + const body = await safeParseJson(request); + const parsedBody = updateAgentTemplateBodySchema.safeParse(body); + if (!parsedBody.success) { + const firstError = parsedBody.error.issues[0]; + return NextResponse.json( + { + status: "error", + missing_fields: firstError.path, + error: firstError.message, + }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) return authResult; + + const templateId = validatedParams.id; + const accountId = authResult.accountId; + + const existing = await selectAgentTemplate(templateId); + if (!existing) { + return NextResponse.json( + { status: "error", error: "Agent template not found" }, + { status: 404, headers: getCorsHeaders() }, + ); + } + + if (existing.creator !== accountId) { + return NextResponse.json( + { status: "error", error: "Forbidden" }, + { status: 403, headers: getCorsHeaders() }, + ); + } + + return { templateId, accountId, body: parsedBody.data }; +} diff --git a/lib/const.ts b/lib/const.ts index 2362805ef..1cc738f8b 100644 --- a/lib/const.ts +++ b/lib/const.ts @@ -48,6 +48,14 @@ export const FLAMINGO_GENERATE_URL = /** Snapshot expiration duration (7 days) */ export const SNAPSHOT_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000; +/** + * Email addresses with platform-admin privileges. + * + * Surfaced as `creator.is_admin` in `/api/agent-templates` so clients can flag + * official Recoup templates. Mirrors `chat/lib/admin.ts`. + */ +export const ADMIN_EMAILS: string[] = ["sidney+1@recoupable.com"]; + // EVALS export const EVAL_ACCOUNT_ID = "fb678396-a68f-4294-ae50-b8cacf9ce77b"; export const EVAL_ACCESS_TOKEN = process.env.EVAL_ACCESS_TOKEN || ""; diff --git a/lib/supabase/agent_template_favorites/deleteAgentTemplateFavorite.ts b/lib/supabase/agent_template_favorites/deleteAgentTemplateFavorite.ts new file mode 100644 index 000000000..ecc8f491a --- /dev/null +++ b/lib/supabase/agent_template_favorites/deleteAgentTemplateFavorite.ts @@ -0,0 +1,26 @@ +import supabase from "@/lib/supabase/serverClient"; + +/** + * Removes the favorite row for `(template_id, user_id)`. Idempotent. + * + * @param templateId - The agent template UUID + * @param userId - The account UUID whose favorite is being removed + * @returns True on success, false on database error. + */ +export async function deleteAgentTemplateFavorite( + templateId: string, + userId: string, +): Promise { + const { error } = await supabase + .from("agent_template_favorites") + .delete() + .eq("template_id", templateId) + .eq("user_id", userId); + + if (error) { + console.error("Error deleting agent_template_favorite:", error); + return false; + } + + return true; +} diff --git a/lib/supabase/agent_template_favorites/insertAgentTemplateFavorite.ts b/lib/supabase/agent_template_favorites/insertAgentTemplateFavorite.ts new file mode 100644 index 000000000..549b64f09 --- /dev/null +++ b/lib/supabase/agent_template_favorites/insertAgentTemplateFavorite.ts @@ -0,0 +1,27 @@ +import supabase from "@/lib/supabase/serverClient"; + +/** + * Inserts a favorite row for `(template_id, user_id)`. Idempotent — a + * pre-existing row (Postgres unique-violation 23505) is treated as success. + * + * @param templateId - The agent template UUID + * @param userId - The favoriting account UUID + * @returns True if the favorite exists after the call, false on unexpected error. + */ +export async function insertAgentTemplateFavorite( + templateId: string, + userId: string, +): Promise { + const { error } = await supabase + .from("agent_template_favorites") + .insert({ template_id: templateId, user_id: userId }) + .select("template_id") + .maybeSingle(); + + if (error && error.code !== "23505") { + console.error("Error inserting agent_template_favorite:", error); + return false; + } + + return true; +} diff --git a/lib/supabase/agent_template_favorites/selectAgentTemplateFavorites.ts b/lib/supabase/agent_template_favorites/selectAgentTemplateFavorites.ts new file mode 100644 index 000000000..f369c551f --- /dev/null +++ b/lib/supabase/agent_template_favorites/selectAgentTemplateFavorites.ts @@ -0,0 +1,21 @@ +import supabase from "@/lib/supabase/serverClient"; + +/** + * Returns the set of template ids the given account has favorited. + * + * @param userId - The account UUID + * @returns Set of template ids; empty Set on no rows or on error. + */ +export async function selectAgentTemplateFavorites(userId: string): Promise> { + const { data, error } = await supabase + .from("agent_template_favorites") + .select("template_id") + .eq("user_id", userId); + + if (error) { + console.error("Error selecting agent_template_favorites:", error); + return new Set(); + } + + return new Set((data ?? []).map(row => row.template_id)); +} diff --git a/lib/supabase/agent_template_shares/deleteAgentTemplateShares.ts b/lib/supabase/agent_template_shares/deleteAgentTemplateShares.ts new file mode 100644 index 000000000..a514bd168 --- /dev/null +++ b/lib/supabase/agent_template_shares/deleteAgentTemplateShares.ts @@ -0,0 +1,21 @@ +import supabase from "@/lib/supabase/serverClient"; + +/** + * Deletes every `agent_template_shares` row for the given template id. + * + * @param templateId - The agent template UUID + * @returns True on success, false on database error. + */ +export async function deleteAgentTemplateShares(templateId: string): Promise { + const { error } = await supabase + .from("agent_template_shares") + .delete() + .eq("template_id", templateId); + + if (error) { + console.error("Error deleting agent_template_shares:", error); + return false; + } + + return true; +} diff --git a/lib/supabase/agent_template_shares/insertAgentTemplateShares.ts b/lib/supabase/agent_template_shares/insertAgentTemplateShares.ts new file mode 100644 index 000000000..632590c37 --- /dev/null +++ b/lib/supabase/agent_template_shares/insertAgentTemplateShares.ts @@ -0,0 +1,39 @@ +import supabase from "@/lib/supabase/serverClient"; +import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmails"; + +/** + * Resolves the supplied emails to account ids and upserts an + * `agent_template_shares` row for each. Unknown emails are silently ignored. + * + * @param templateId - The agent template UUID + * @param emails - Email addresses to share with + * @returns Number of shares inserted (counts pre-existing rows as 0). + */ +export async function insertAgentTemplateShares( + templateId: string, + emails: string[], +): Promise { + if (!Array.isArray(emails) || emails.length === 0) return 0; + + const accountEmails = await selectAccountEmails({ emails }); + const rows = accountEmails + .filter(row => row.account_id !== null) + .map(row => ({ template_id: templateId, user_id: row.account_id! })); + + if (rows.length === 0) return 0; + + const { data, error } = await supabase + .from("agent_template_shares") + .upsert(rows, { + onConflict: "template_id,user_id", + ignoreDuplicates: true, + }) + .select(); + + if (error) { + console.error("Error inserting agent_template_shares:", error); + return 0; + } + + return data?.length ?? 0; +} diff --git a/lib/supabase/agent_template_shares/selectAgentTemplateShares.ts b/lib/supabase/agent_template_shares/selectAgentTemplateShares.ts new file mode 100644 index 000000000..aa7c3a3bc --- /dev/null +++ b/lib/supabase/agent_template_shares/selectAgentTemplateShares.ts @@ -0,0 +1,26 @@ +import supabase from "@/lib/supabase/serverClient"; +import type { Tables } from "@/types/database.types"; + +/** + * Selects all agent_template_shares rows for the given template ids. + * + * @param templateIds - Array of agent template UUIDs + * @returns Array of share rows (may be empty). + */ +export async function selectAgentTemplateShares( + templateIds: string[], +): Promise[]> { + if (!Array.isArray(templateIds) || templateIds.length === 0) return []; + + const { data, error } = await supabase + .from("agent_template_shares") + .select("*") + .in("template_id", templateIds); + + if (error) { + console.error("Error selecting agent_template_shares:", error); + return []; + } + + return data ?? []; +} diff --git a/lib/supabase/agent_templates/deleteAgentTemplate.ts b/lib/supabase/agent_templates/deleteAgentTemplate.ts new file mode 100644 index 000000000..587a73f5e --- /dev/null +++ b/lib/supabase/agent_templates/deleteAgentTemplate.ts @@ -0,0 +1,19 @@ +import supabase from "@/lib/supabase/serverClient"; + +/** + * Deletes an agent template row by id. Cascades remove dependent shares / + * favorites at the database level. + * + * @param id - The agent template UUID + * @returns True if the delete succeeded, false otherwise. + */ +export async function deleteAgentTemplate(id: string): Promise { + const { error } = await supabase.from("agent_templates").delete().eq("id", id); + + if (error) { + console.error("Error deleting agent template:", error); + return false; + } + + return true; +} diff --git a/lib/supabase/agent_templates/getAccessibleAgentTemplates.ts b/lib/supabase/agent_templates/getAccessibleAgentTemplates.ts new file mode 100644 index 000000000..7a51f18c4 --- /dev/null +++ b/lib/supabase/agent_templates/getAccessibleAgentTemplates.ts @@ -0,0 +1,209 @@ +import supabase from "@/lib/supabase/serverClient"; +import { ADMIN_EMAILS } from "@/lib/const"; +import type { Tables } from "@/types/database.types"; +import { selectAgentTemplateFavorites } from "@/lib/supabase/agent_template_favorites/selectAgentTemplateFavorites"; +import { selectAgentTemplateShares } from "@/lib/supabase/agent_template_shares/selectAgentTemplateShares"; + +/** + * Embedded creator block surfaced on each agent template row. + */ +export interface AgentTemplateCreator { + id: string; + name: string | null; + image: string | null; + is_admin: boolean; +} + +/** + * Enriched agent template payload returned by GET /api/agent-templates and + * by the create / update handlers. + */ +export interface AgentTemplateWithDetails { + id: string; + title: string; + description: string; + prompt: string; + tags: string[] | null; + creator: AgentTemplateCreator | null; + is_private: boolean; + is_favourite: boolean; + favorites_count: number | null; + shared_emails: string[]; + created_at: string | null; + updated_at: string | null; +} + +interface CreatorJoin { + id: string; + name: string | null; + account_info: Array, "image">> | null; + account_emails: Array, "email">> | null; +} + +type AgentTemplateRowWithCreator = Omit, "creator"> & { + creator: CreatorJoin | null; +}; + +/** + * Builds the embedded creator object from a joined accounts row, deriving the + * is_admin flag by intersecting the account's emails with `ADMIN_EMAILS`. + */ +function buildCreator(row: CreatorJoin | null): AgentTemplateCreator | null { + if (!row) return null; + + const image = row.account_info?.[0]?.image ?? null; + const emails = (row.account_emails ?? []) + .map(e => e.email) + .filter((e): e is string => typeof e === "string"); + const isAdmin = emails.some(email => ADMIN_EMAILS.includes(email)); + + return { + id: row.id, + name: row.name ?? null, + image, + is_admin: isAdmin, + }; +} + +/** + * Fetches every agent template visible to `accountId` and enriches each row + * with the creator block, the caller's `is_favourite` flag, and (for private + * templates) the list of `shared_emails`. + * + * Visibility rules: + * - templates the caller created + * - public templates (`is_private = false`) + * - private templates the caller has been granted access to via + * `agent_template_shares` + * + * @param accountId - The authenticated account's UUID. + * @returns Array of enriched template rows; empty array on database error. + */ +export async function getAccessibleAgentTemplates( + accountId: string, +): Promise { + const ownedAndPublicSelect = ` + *, + creator:accounts!agent_templates_creator_fkey ( + id, + name, + account_info ( image ), + account_emails ( email ) + ) + `; + + const { data: ownedAndPublic, error: ownedErr } = await supabase + .from("agent_templates") + .select(ownedAndPublicSelect) + .or(`creator.eq.${accountId},is_private.eq.false`) + .order("title"); + + if (ownedErr) { + console.error("Error selecting owned/public agent_templates:", ownedErr); + return []; + } + + const { data: sharedJoin, error: sharedErr } = await supabase + .from("agent_template_shares") + .select( + `template:agent_templates!agent_template_shares_template_id_fkey ( + ${ownedAndPublicSelect} + )`, + ) + .eq("user_id", accountId); + + if (sharedErr) { + console.error("Error selecting shared agent_templates:", sharedErr); + return []; + } + + // Deduplicate by template id. + const byId = new Map(); + (ownedAndPublic ?? []).forEach(row => { + byId.set(row.id, row as unknown as AgentTemplateRowWithCreator); + }); + + (sharedJoin ?? []).forEach(share => { + const template = ( + share as unknown as { + template: AgentTemplateRowWithCreator | AgentTemplateRowWithCreator[] | null; + } + ).template; + if (!template) return; + const list = Array.isArray(template) ? template : [template]; + list.forEach(t => { + if (t && t.id && !byId.has(t.id)) byId.set(t.id, t); + }); + }); + + const templates = Array.from(byId.values()); + if (templates.length === 0) return []; + + const favourites = await selectAgentTemplateFavorites(accountId); + + // Resolve shared_emails for the private templates only. + const privateIds = templates.filter(t => t.is_private).map(t => t.id); + const sharedEmailsByTemplate = await getSharedEmailsByTemplateId(privateIds); + + return templates.map(t => ({ + id: t.id, + title: t.title, + description: t.description, + prompt: t.prompt, + tags: t.tags ?? null, + creator: buildCreator(t.creator), + is_private: t.is_private, + is_favourite: favourites.has(t.id), + favorites_count: t.favorites_count ?? null, + shared_emails: t.is_private ? (sharedEmailsByTemplate[t.id] ?? []) : [], + created_at: t.created_at ?? null, + updated_at: t.updated_at ?? null, + })); +} + +/** + * Builds a `template_id -> emails[]` map by fanning out template ids through + * shares, then resolving the recipient account ids back to email strings. + */ +async function getSharedEmailsByTemplateId( + templateIds: string[], +): Promise> { + if (templateIds.length === 0) return {}; + + const shares = await selectAgentTemplateShares(templateIds); + if (shares.length === 0) return {}; + + const userIds = Array.from(new Set(shares.map(s => s.user_id))); + + const { data: emailRows, error } = await supabase + .from("account_emails") + .select("account_id, email") + .in("account_id", userIds); + + if (error) { + console.error("Error selecting account_emails for shares:", error); + return {}; + } + + const emailsByUser = new Map(); + (emailRows ?? []).forEach(row => { + if (!row.account_id || !row.email) return; + const list = emailsByUser.get(row.account_id) ?? []; + list.push(row.email); + emailsByUser.set(row.account_id, list); + }); + + const result: Record = {}; + shares.forEach(share => { + const list = result[share.template_id] ?? []; + const userEmails = emailsByUser.get(share.user_id) ?? []; + list.push(...userEmails); + result[share.template_id] = list; + }); + + Object.keys(result).forEach(id => { + result[id] = Array.from(new Set(result[id])); + }); + + return result; +} diff --git a/lib/supabase/agent_templates/getAgentTemplateWithDetails.ts b/lib/supabase/agent_templates/getAgentTemplateWithDetails.ts new file mode 100644 index 000000000..9665155ce --- /dev/null +++ b/lib/supabase/agent_templates/getAgentTemplateWithDetails.ts @@ -0,0 +1,122 @@ +import supabase from "@/lib/supabase/serverClient"; +import { ADMIN_EMAILS } from "@/lib/const"; +import type { Tables } from "@/types/database.types"; +import type { + AgentTemplateCreator, + AgentTemplateWithDetails, +} from "@/lib/supabase/agent_templates/getAccessibleAgentTemplates"; +import { selectAgentTemplateFavorites } from "@/lib/supabase/agent_template_favorites/selectAgentTemplateFavorites"; +import { selectAgentTemplateShares } from "@/lib/supabase/agent_template_shares/selectAgentTemplateShares"; + +interface CreatorJoin { + id: string; + name: string | null; + account_info: Array, "image">> | null; + account_emails: Array, "email">> | null; +} + +type AgentTemplateRowWithCreator = Omit, "creator"> & { + creator: CreatorJoin | null; +}; + +/** + * Fetches a single agent template enriched with the creator block, the + * caller's `is_favourite` flag, and (for private templates) `shared_emails`. + * + * Mirrors the per-row shape returned by `getAccessibleAgentTemplates` so that + * POST and PATCH responses are byte-compatible with the GET list element. + * + * @param templateId - The agent template UUID + * @param accountId - Caller account UUID, used to compute `is_favourite` + * @returns The enriched template row, or null if not found / on error. + */ +export async function getAgentTemplateWithDetails( + templateId: string, + accountId: string, +): Promise { + const { data, error } = await supabase + .from("agent_templates") + .select( + ` + *, + creator:accounts!agent_templates_creator_fkey ( + id, + name, + account_info ( image ), + account_emails ( email ) + ) + `, + ) + .eq("id", templateId) + .maybeSingle(); + + if (error) { + console.error("Error selecting agent_template with details:", error); + return null; + } + + if (!data) return null; + + const row = data as unknown as AgentTemplateRowWithCreator; + + const favourites = await selectAgentTemplateFavorites(accountId); + + let sharedEmails: string[] = []; + if (row.is_private) { + sharedEmails = await getSharedEmailsForTemplate(row.id); + } + + return { + id: row.id, + title: row.title, + description: row.description, + prompt: row.prompt, + tags: row.tags ?? null, + creator: buildCreator(row.creator), + is_private: row.is_private, + is_favourite: favourites.has(row.id), + favorites_count: row.favorites_count ?? null, + shared_emails: sharedEmails, + created_at: row.created_at ?? null, + updated_at: row.updated_at ?? null, + }; +} + +/** + * Resolves shared recipients for a single template id back to email strings. + */ +async function getSharedEmailsForTemplate(templateId: string): Promise { + const shares = await selectAgentTemplateShares([templateId]); + if (shares.length === 0) return []; + + const userIds = Array.from(new Set(shares.map(s => s.user_id))); + + const { data, error } = await supabase + .from("account_emails") + .select("email") + .in("account_id", userIds); + + if (error) { + console.error("Error selecting account_emails for template shares:", error); + return []; + } + + return Array.from( + new Set((data ?? []).map(r => r.email).filter((e): e is string => typeof e === "string")), + ); +} + +function buildCreator(row: CreatorJoin | null): AgentTemplateCreator | null { + if (!row) return null; + const image = row.account_info?.[0]?.image ?? null; + const emails = (row.account_emails ?? []) + .map(e => e.email) + .filter((e): e is string => typeof e === "string"); + const isAdmin = emails.some(email => ADMIN_EMAILS.includes(email)); + return { + id: row.id, + name: row.name ?? null, + image, + is_admin: isAdmin, + }; +} diff --git a/lib/supabase/agent_templates/insertAgentTemplate.ts b/lib/supabase/agent_templates/insertAgentTemplate.ts new file mode 100644 index 000000000..901f036fe --- /dev/null +++ b/lib/supabase/agent_templates/insertAgentTemplate.ts @@ -0,0 +1,21 @@ +import supabase from "@/lib/supabase/serverClient"; +import type { Tables, TablesInsert } from "@/types/database.types"; + +/** + * Inserts a new agent template row. + * + * @param row - The agent_templates insert payload (must include creator). + * @returns The newly created agent_templates row, or null on error. + */ +export async function insertAgentTemplate( + row: TablesInsert<"agent_templates">, +): Promise | null> { + const { data, error } = await supabase.from("agent_templates").insert(row).select("*").single(); + + if (error) { + console.error("Error inserting agent template:", error); + return null; + } + + return data; +} diff --git a/lib/supabase/agent_templates/selectAgentTemplate.ts b/lib/supabase/agent_templates/selectAgentTemplate.ts new file mode 100644 index 000000000..b537442c6 --- /dev/null +++ b/lib/supabase/agent_templates/selectAgentTemplate.ts @@ -0,0 +1,23 @@ +import supabase from "@/lib/supabase/serverClient"; +import type { Tables } from "@/types/database.types"; + +/** + * Selects a single agent template by id. + * + * @param id - The agent template UUID + * @returns The matching agent_templates row or null if not found / on error. + */ +export async function selectAgentTemplate(id: string): Promise | null> { + const { data, error } = await supabase + .from("agent_templates") + .select("*") + .eq("id", id) + .maybeSingle(); + + if (error) { + console.error("Error selecting agent template:", error); + return null; + } + + return data; +} diff --git a/lib/supabase/agent_templates/updateAgentTemplate.ts b/lib/supabase/agent_templates/updateAgentTemplate.ts new file mode 100644 index 000000000..bc91f518d --- /dev/null +++ b/lib/supabase/agent_templates/updateAgentTemplate.ts @@ -0,0 +1,31 @@ +import supabase from "@/lib/supabase/serverClient"; +import type { Tables, TablesUpdate } from "@/types/database.types"; + +/** + * Applies a partial update to an agent template row, refreshing `updated_at`. + * + * @param id - The agent template UUID + * @param updates - Partial column updates + * @returns The updated row, or null on error. + */ +export async function updateAgentTemplate( + id: string, + updates: TablesUpdate<"agent_templates">, +): Promise | null> { + const { data, error } = await supabase + .from("agent_templates") + .update({ + ...updates, + updated_at: new Date().toISOString(), + }) + .eq("id", id) + .select("*") + .single(); + + if (error) { + console.error("Error updating agent template:", error); + return null; + } + + return data; +} From ff5e04fa8efa8513b630749084bc6c3a2595e821 Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Mon, 11 May 2026 22:48:00 +0530 Subject: [PATCH 02/32] test(api): drop route-level tests for agent-templates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Handler tests are the project convention (~62% coverage across the repo); route tests are anomalies (~11%). The route files are thin delegates to handlers — no test value beyond the handler tests already shipped. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../[id]/__tests__/route.test.ts | 50 ------------------- .../[id]/__tests__/routeTestMocks.ts | 13 ----- .../[id]/favorite/__tests__/route.test.ts | 35 ------------- .../[id]/favorite/__tests__/routeTestMocks.ts | 9 ---- .../agent-templates/__tests__/route.test.ts | 41 --------------- .../__tests__/routeTestMocks.ts | 13 ----- 6 files changed, 161 deletions(-) delete mode 100644 app/api/agent-templates/[id]/__tests__/route.test.ts delete mode 100644 app/api/agent-templates/[id]/__tests__/routeTestMocks.ts delete mode 100644 app/api/agent-templates/[id]/favorite/__tests__/route.test.ts delete mode 100644 app/api/agent-templates/[id]/favorite/__tests__/routeTestMocks.ts delete mode 100644 app/api/agent-templates/__tests__/route.test.ts delete mode 100644 app/api/agent-templates/__tests__/routeTestMocks.ts diff --git a/app/api/agent-templates/[id]/__tests__/route.test.ts b/app/api/agent-templates/[id]/__tests__/route.test.ts deleted file mode 100644 index 742004665..000000000 --- a/app/api/agent-templates/[id]/__tests__/route.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import "./routeTestMocks"; -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { NextRequest, NextResponse } from "next/server"; -import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { updateAgentTemplateHandler } from "@/lib/agent_templates/updateAgentTemplateHandler"; -import { deleteAgentTemplateHandler } from "@/lib/agent_templates/deleteAgentTemplateHandler"; - -const { PATCH, DELETE, OPTIONS } = await import("../route"); - -const TEMPLATE_ID = "22222222-2222-2222-2222-222222222222"; - -describe("app/api/agent-templates/[id]/route", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("OPTIONS returns 200 with CORS headers", async () => { - const res = await OPTIONS(); - expect(res.status).toBe(200); - expect(getCorsHeaders).toHaveBeenCalled(); - }); - - it("PATCH delegates to updateAgentTemplateHandler with the path params", async () => { - const handlerRes = NextResponse.json({ status: "success" }); - vi.mocked(updateAgentTemplateHandler).mockResolvedValue(handlerRes); - - const req = new NextRequest(`http://localhost/api/agent-templates/${TEMPLATE_ID}`, { - method: "PATCH", - }); - const params = Promise.resolve({ id: TEMPLATE_ID }); - const res = await PATCH(req, { params }); - - expect(updateAgentTemplateHandler).toHaveBeenCalledWith(req, params); - expect(res).toBe(handlerRes); - }); - - it("DELETE delegates to deleteAgentTemplateHandler with the path params", async () => { - const handlerRes = NextResponse.json({ status: "success" }); - vi.mocked(deleteAgentTemplateHandler).mockResolvedValue(handlerRes); - - const req = new NextRequest(`http://localhost/api/agent-templates/${TEMPLATE_ID}`, { - method: "DELETE", - }); - const params = Promise.resolve({ id: TEMPLATE_ID }); - const res = await DELETE(req, { params }); - - expect(deleteAgentTemplateHandler).toHaveBeenCalledWith(req, params); - expect(res).toBe(handlerRes); - }); -}); diff --git a/app/api/agent-templates/[id]/__tests__/routeTestMocks.ts b/app/api/agent-templates/[id]/__tests__/routeTestMocks.ts deleted file mode 100644 index 040eaae58..000000000 --- a/app/api/agent-templates/[id]/__tests__/routeTestMocks.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { vi } from "vitest"; - -vi.mock("@/lib/networking/getCorsHeaders", () => ({ - getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), -})); - -vi.mock("@/lib/agent_templates/updateAgentTemplateHandler", () => ({ - updateAgentTemplateHandler: vi.fn(), -})); - -vi.mock("@/lib/agent_templates/deleteAgentTemplateHandler", () => ({ - deleteAgentTemplateHandler: vi.fn(), -})); diff --git a/app/api/agent-templates/[id]/favorite/__tests__/route.test.ts b/app/api/agent-templates/[id]/favorite/__tests__/route.test.ts deleted file mode 100644 index a5a74fc1b..000000000 --- a/app/api/agent-templates/[id]/favorite/__tests__/route.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import "./routeTestMocks"; -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { NextRequest, NextResponse } from "next/server"; -import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { toggleAgentTemplateFavoriteHandler } from "@/lib/agent_templates/toggleAgentTemplateFavoriteHandler"; - -const { PUT, OPTIONS } = await import("../route"); - -const TEMPLATE_ID = "22222222-2222-2222-2222-222222222222"; - -describe("app/api/agent-templates/[id]/favorite/route", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("OPTIONS returns 200 with CORS headers", async () => { - const res = await OPTIONS(); - expect(res.status).toBe(200); - expect(getCorsHeaders).toHaveBeenCalled(); - }); - - it("PUT delegates to toggleAgentTemplateFavoriteHandler", async () => { - const handlerRes = NextResponse.json({ status: "success" }); - vi.mocked(toggleAgentTemplateFavoriteHandler).mockResolvedValue(handlerRes); - - const req = new NextRequest(`http://localhost/api/agent-templates/${TEMPLATE_ID}/favorite`, { - method: "PUT", - }); - const params = Promise.resolve({ id: TEMPLATE_ID }); - const res = await PUT(req, { params }); - - expect(toggleAgentTemplateFavoriteHandler).toHaveBeenCalledWith(req, params); - expect(res).toBe(handlerRes); - }); -}); diff --git a/app/api/agent-templates/[id]/favorite/__tests__/routeTestMocks.ts b/app/api/agent-templates/[id]/favorite/__tests__/routeTestMocks.ts deleted file mode 100644 index d2b20b75b..000000000 --- a/app/api/agent-templates/[id]/favorite/__tests__/routeTestMocks.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { vi } from "vitest"; - -vi.mock("@/lib/networking/getCorsHeaders", () => ({ - getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), -})); - -vi.mock("@/lib/agent_templates/toggleAgentTemplateFavoriteHandler", () => ({ - toggleAgentTemplateFavoriteHandler: vi.fn(), -})); diff --git a/app/api/agent-templates/__tests__/route.test.ts b/app/api/agent-templates/__tests__/route.test.ts deleted file mode 100644 index e909a201e..000000000 --- a/app/api/agent-templates/__tests__/route.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import "./routeTestMocks"; -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { NextRequest, NextResponse } from "next/server"; -import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { listAgentTemplatesHandler } from "@/lib/agent_templates/listAgentTemplatesHandler"; -import { createAgentTemplateHandler } from "@/lib/agent_templates/createAgentTemplateHandler"; - -const { GET, POST, OPTIONS } = await import("../route"); - -describe("app/api/agent-templates/route", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("OPTIONS returns 200 with CORS headers", async () => { - const res = await OPTIONS(); - expect(res.status).toBe(200); - expect(getCorsHeaders).toHaveBeenCalled(); - expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*"); - }); - - it("GET delegates to listAgentTemplatesHandler", async () => { - const handlerRes = NextResponse.json({ status: "success", templates: [] }); - vi.mocked(listAgentTemplatesHandler).mockResolvedValue(handlerRes); - - const req = new NextRequest("http://localhost/api/agent-templates"); - const res = await GET(req); - expect(listAgentTemplatesHandler).toHaveBeenCalledWith(req); - expect(res).toBe(handlerRes); - }); - - it("POST delegates to createAgentTemplateHandler", async () => { - const handlerRes = NextResponse.json({ status: "success" }, { status: 201 }); - vi.mocked(createAgentTemplateHandler).mockResolvedValue(handlerRes); - - const req = new NextRequest("http://localhost/api/agent-templates", { method: "POST" }); - const res = await POST(req); - expect(createAgentTemplateHandler).toHaveBeenCalledWith(req); - expect(res).toBe(handlerRes); - }); -}); diff --git a/app/api/agent-templates/__tests__/routeTestMocks.ts b/app/api/agent-templates/__tests__/routeTestMocks.ts deleted file mode 100644 index 566e2bb27..000000000 --- a/app/api/agent-templates/__tests__/routeTestMocks.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { vi } from "vitest"; - -vi.mock("@/lib/networking/getCorsHeaders", () => ({ - getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), -})); - -vi.mock("@/lib/agent_templates/listAgentTemplatesHandler", () => ({ - listAgentTemplatesHandler: vi.fn(), -})); - -vi.mock("@/lib/agent_templates/createAgentTemplateHandler", () => ({ - createAgentTemplateHandler: vi.fn(), -})); From 3c7acab51d52871c4f5e2d49033faacd11a03ca8 Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Mon, 11 May 2026 23:04:18 +0530 Subject: [PATCH 03/32] fix(api): address AI review feedback on agent-templates endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1 correctness: - Drop error.message from 500 responses across all 5 handlers; return a generic "Internal server error" string. Full error stays in server logs. - selectAgentTemplate, getAgentTemplateWithDetails, deleteAgentTemplateShares, insertAgentTemplateShares, getAccessibleAgentTemplates now throw on DB error instead of returning null/false/0/[]. Callers no longer conflate "DB failed" with "no row" (which caused false 404s and silent no-op success). - Privacy: shared_emails is now only returned to the template's creator. Previously any sharee could see who else the template was shared with. Acknowledged limitation: - PATCH share-update is still delete-then-insert without a Postgres-level transaction; comment in updateAgentTemplateHandler documents this. The helpers now throw on failure so the client at least gets a 500. Nits: - userId → accountId in the favorite helpers and internal vars in getAccessibleAgentTemplates, matching repo terminology convention. - ADMIN_EMAILS declared as readonly to harden the privilege list. - Added TODO comment for Supabase's 1000-row default cap on the templates query. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../updateAgentTemplateHandler.test.ts | 2 +- .../createAgentTemplateHandler.ts | 5 +- .../deleteAgentTemplateHandler.ts | 5 +- .../listAgentTemplatesHandler.ts | 5 +- .../toggleAgentTemplateFavoriteHandler.ts | 5 +- .../updateAgentTemplateHandler.ts | 10 ++-- lib/const.ts | 2 +- .../deleteAgentTemplateFavorite.ts | 8 ++-- .../insertAgentTemplateFavorite.ts | 8 ++-- .../selectAgentTemplateFavorites.ts | 6 +-- .../deleteAgentTemplateShares.ts | 11 +++-- .../insertAgentTemplateShares.ts | 8 +++- .../getAccessibleAgentTemplates.ts | 46 ++++++++++++------- .../getAgentTemplateWithDetails.ts | 12 +++-- .../agent_templates/selectAgentTemplate.ts | 9 +++- 15 files changed, 79 insertions(+), 63 deletions(-) diff --git a/lib/agent_templates/__tests__/updateAgentTemplateHandler.test.ts b/lib/agent_templates/__tests__/updateAgentTemplateHandler.test.ts index daad238c8..421c16f84 100644 --- a/lib/agent_templates/__tests__/updateAgentTemplateHandler.test.ts +++ b/lib/agent_templates/__tests__/updateAgentTemplateHandler.test.ts @@ -56,7 +56,7 @@ describe("updateAgentTemplateHandler", () => { }); vi.mocked(updateAgentTemplate).mockResolvedValue({ id: TEMPLATE_ID } as any); - vi.mocked(deleteAgentTemplateShares).mockResolvedValue(true); + vi.mocked(deleteAgentTemplateShares).mockResolvedValue(undefined); vi.mocked(insertAgentTemplateShares).mockResolvedValue(1); vi.mocked(getAgentTemplateWithDetails).mockResolvedValue({ id: TEMPLATE_ID } as any); diff --git a/lib/agent_templates/createAgentTemplateHandler.ts b/lib/agent_templates/createAgentTemplateHandler.ts index 9c494e91b..1405064e5 100644 --- a/lib/agent_templates/createAgentTemplateHandler.ts +++ b/lib/agent_templates/createAgentTemplateHandler.ts @@ -57,10 +57,7 @@ export async function createAgentTemplateHandler(request: NextRequest): Promise< } catch (error) { console.error("[ERROR] createAgentTemplateHandler:", error); return NextResponse.json( - { - status: "error", - error: error instanceof Error ? error.message : "Internal server error", - }, + { status: "error", error: "Internal server error" }, { status: 500, headers: getCorsHeaders() }, ); } diff --git a/lib/agent_templates/deleteAgentTemplateHandler.ts b/lib/agent_templates/deleteAgentTemplateHandler.ts index 031b56a90..1f7ed0554 100644 --- a/lib/agent_templates/deleteAgentTemplateHandler.ts +++ b/lib/agent_templates/deleteAgentTemplateHandler.ts @@ -34,10 +34,7 @@ export async function deleteAgentTemplateHandler( } catch (error) { console.error("[ERROR] deleteAgentTemplateHandler:", error); return NextResponse.json( - { - status: "error", - error: error instanceof Error ? error.message : "Internal server error", - }, + { status: "error", error: "Internal server error" }, { status: 500, headers: getCorsHeaders() }, ); } diff --git a/lib/agent_templates/listAgentTemplatesHandler.ts b/lib/agent_templates/listAgentTemplatesHandler.ts index e3dbce4a5..3581409d0 100644 --- a/lib/agent_templates/listAgentTemplatesHandler.ts +++ b/lib/agent_templates/listAgentTemplatesHandler.ts @@ -27,10 +27,7 @@ export async function listAgentTemplatesHandler(request: NextRequest): Promise 0) { @@ -62,10 +67,7 @@ export async function updateAgentTemplateHandler( } catch (error) { console.error("[ERROR] updateAgentTemplateHandler:", error); return NextResponse.json( - { - status: "error", - error: error instanceof Error ? error.message : "Internal server error", - }, + { status: "error", error: "Internal server error" }, { status: 500, headers: getCorsHeaders() }, ); } diff --git a/lib/const.ts b/lib/const.ts index 1cc738f8b..b06d4f6d6 100644 --- a/lib/const.ts +++ b/lib/const.ts @@ -54,7 +54,7 @@ export const SNAPSHOT_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000; * Surfaced as `creator.is_admin` in `/api/agent-templates` so clients can flag * official Recoup templates. Mirrors `chat/lib/admin.ts`. */ -export const ADMIN_EMAILS: string[] = ["sidney+1@recoupable.com"]; +export const ADMIN_EMAILS: readonly string[] = ["sidney+1@recoupable.com"] as const; // EVALS export const EVAL_ACCOUNT_ID = "fb678396-a68f-4294-ae50-b8cacf9ce77b"; diff --git a/lib/supabase/agent_template_favorites/deleteAgentTemplateFavorite.ts b/lib/supabase/agent_template_favorites/deleteAgentTemplateFavorite.ts index ecc8f491a..887cb0118 100644 --- a/lib/supabase/agent_template_favorites/deleteAgentTemplateFavorite.ts +++ b/lib/supabase/agent_template_favorites/deleteAgentTemplateFavorite.ts @@ -1,21 +1,21 @@ import supabase from "@/lib/supabase/serverClient"; /** - * Removes the favorite row for `(template_id, user_id)`. Idempotent. + * Removes the favorite row for `(template_id, account)`. Idempotent. * * @param templateId - The agent template UUID - * @param userId - The account UUID whose favorite is being removed + * @param accountId - The account UUID whose favorite is being removed * @returns True on success, false on database error. */ export async function deleteAgentTemplateFavorite( templateId: string, - userId: string, + accountId: string, ): Promise { const { error } = await supabase .from("agent_template_favorites") .delete() .eq("template_id", templateId) - .eq("user_id", userId); + .eq("user_id", accountId); if (error) { console.error("Error deleting agent_template_favorite:", error); diff --git a/lib/supabase/agent_template_favorites/insertAgentTemplateFavorite.ts b/lib/supabase/agent_template_favorites/insertAgentTemplateFavorite.ts index 549b64f09..dba2868f2 100644 --- a/lib/supabase/agent_template_favorites/insertAgentTemplateFavorite.ts +++ b/lib/supabase/agent_template_favorites/insertAgentTemplateFavorite.ts @@ -1,20 +1,20 @@ import supabase from "@/lib/supabase/serverClient"; /** - * Inserts a favorite row for `(template_id, user_id)`. Idempotent — a + * Inserts a favorite row for `(template_id, account)`. Idempotent — a * pre-existing row (Postgres unique-violation 23505) is treated as success. * * @param templateId - The agent template UUID - * @param userId - The favoriting account UUID + * @param accountId - The favoriting account UUID * @returns True if the favorite exists after the call, false on unexpected error. */ export async function insertAgentTemplateFavorite( templateId: string, - userId: string, + accountId: string, ): Promise { const { error } = await supabase .from("agent_template_favorites") - .insert({ template_id: templateId, user_id: userId }) + .insert({ template_id: templateId, user_id: accountId }) .select("template_id") .maybeSingle(); diff --git a/lib/supabase/agent_template_favorites/selectAgentTemplateFavorites.ts b/lib/supabase/agent_template_favorites/selectAgentTemplateFavorites.ts index f369c551f..a6d26f491 100644 --- a/lib/supabase/agent_template_favorites/selectAgentTemplateFavorites.ts +++ b/lib/supabase/agent_template_favorites/selectAgentTemplateFavorites.ts @@ -3,14 +3,14 @@ import supabase from "@/lib/supabase/serverClient"; /** * Returns the set of template ids the given account has favorited. * - * @param userId - The account UUID + * @param accountId - The account UUID * @returns Set of template ids; empty Set on no rows or on error. */ -export async function selectAgentTemplateFavorites(userId: string): Promise> { +export async function selectAgentTemplateFavorites(accountId: string): Promise> { const { data, error } = await supabase .from("agent_template_favorites") .select("template_id") - .eq("user_id", userId); + .eq("user_id", accountId); if (error) { console.error("Error selecting agent_template_favorites:", error); diff --git a/lib/supabase/agent_template_shares/deleteAgentTemplateShares.ts b/lib/supabase/agent_template_shares/deleteAgentTemplateShares.ts index a514bd168..478e64ac4 100644 --- a/lib/supabase/agent_template_shares/deleteAgentTemplateShares.ts +++ b/lib/supabase/agent_template_shares/deleteAgentTemplateShares.ts @@ -3,10 +3,13 @@ import supabase from "@/lib/supabase/serverClient"; /** * Deletes every `agent_template_shares` row for the given template id. * + * Throws on database error so callers cannot silently continue with an + * inconsistent share state. + * * @param templateId - The agent template UUID - * @returns True on success, false on database error. + * @throws If the Supabase delete fails. */ -export async function deleteAgentTemplateShares(templateId: string): Promise { +export async function deleteAgentTemplateShares(templateId: string): Promise { const { error } = await supabase .from("agent_template_shares") .delete() @@ -14,8 +17,6 @@ export async function deleteAgentTemplateShares(templateId: string): Promise t.is_private).map(t => t.id); - const sharedEmailsByTemplate = await getSharedEmailsByTemplateId(privateIds); + // Resolve shared_emails only for private templates the caller owns. + // Sharees do not see who else a template was shared with. + const ownedPrivateIds = templates + .filter(t => t.is_private && t.creator?.id === accountId) + .map(t => t.id); + const sharedEmailsByTemplate = await getSharedEmailsByTemplateId(ownedPrivateIds); return templates.map(t => ({ id: t.id, @@ -155,7 +168,8 @@ export async function getAccessibleAgentTemplates( is_private: t.is_private, is_favourite: favourites.has(t.id), favorites_count: t.favorites_count ?? null, - shared_emails: t.is_private ? (sharedEmailsByTemplate[t.id] ?? []) : [], + shared_emails: + t.is_private && t.creator?.id === accountId ? (sharedEmailsByTemplate[t.id] ?? []) : [], created_at: t.created_at ?? null, updated_at: t.updated_at ?? null, })); @@ -173,31 +187,31 @@ async function getSharedEmailsByTemplateId( const shares = await selectAgentTemplateShares(templateIds); if (shares.length === 0) return {}; - const userIds = Array.from(new Set(shares.map(s => s.user_id))); + const accountIds = Array.from(new Set(shares.map(s => s.user_id))); const { data: emailRows, error } = await supabase .from("account_emails") .select("account_id, email") - .in("account_id", userIds); + .in("account_id", accountIds); if (error) { console.error("Error selecting account_emails for shares:", error); - return {}; + throw new Error(`getSharedEmailsByTemplateId failed: ${error.message}`); } - const emailsByUser = new Map(); + const emailsByAccount = new Map(); (emailRows ?? []).forEach(row => { if (!row.account_id || !row.email) return; - const list = emailsByUser.get(row.account_id) ?? []; + const list = emailsByAccount.get(row.account_id) ?? []; list.push(row.email); - emailsByUser.set(row.account_id, list); + emailsByAccount.set(row.account_id, list); }); const result: Record = {}; shares.forEach(share => { const list = result[share.template_id] ?? []; - const userEmails = emailsByUser.get(share.user_id) ?? []; - list.push(...userEmails); + const accountEmails = emailsByAccount.get(share.user_id) ?? []; + list.push(...accountEmails); result[share.template_id] = list; }); diff --git a/lib/supabase/agent_templates/getAgentTemplateWithDetails.ts b/lib/supabase/agent_templates/getAgentTemplateWithDetails.ts index 9665155ce..a6ffc0f23 100644 --- a/lib/supabase/agent_templates/getAgentTemplateWithDetails.ts +++ b/lib/supabase/agent_templates/getAgentTemplateWithDetails.ts @@ -52,7 +52,7 @@ export async function getAgentTemplateWithDetails( if (error) { console.error("Error selecting agent_template with details:", error); - return null; + throw new Error(`getAgentTemplateWithDetails failed: ${error.message}`); } if (!data) return null; @@ -61,8 +61,10 @@ export async function getAgentTemplateWithDetails( const favourites = await selectAgentTemplateFavorites(accountId); + // Privacy: only the template owner can see who else it was shared with. + // Sharees would otherwise leak each other's emails. let sharedEmails: string[] = []; - if (row.is_private) { + if (row.is_private && row.creator?.id === accountId) { sharedEmails = await getSharedEmailsForTemplate(row.id); } @@ -89,16 +91,16 @@ async function getSharedEmailsForTemplate(templateId: string): Promise const shares = await selectAgentTemplateShares([templateId]); if (shares.length === 0) return []; - const userIds = Array.from(new Set(shares.map(s => s.user_id))); + const accountIds = Array.from(new Set(shares.map(s => s.user_id))); const { data, error } = await supabase .from("account_emails") .select("email") - .in("account_id", userIds); + .in("account_id", accountIds); if (error) { console.error("Error selecting account_emails for template shares:", error); - return []; + throw new Error(`getSharedEmailsForTemplate failed: ${error.message}`); } return Array.from( diff --git a/lib/supabase/agent_templates/selectAgentTemplate.ts b/lib/supabase/agent_templates/selectAgentTemplate.ts index b537442c6..d46229d6f 100644 --- a/lib/supabase/agent_templates/selectAgentTemplate.ts +++ b/lib/supabase/agent_templates/selectAgentTemplate.ts @@ -4,8 +4,13 @@ import type { Tables } from "@/types/database.types"; /** * Selects a single agent template by id. * + * Returns `null` only when the row does not exist. Database errors are thrown + * so callers can distinguish a real failure from a missing row (and surface + * a 500 instead of a false 404). + * * @param id - The agent template UUID - * @returns The matching agent_templates row or null if not found / on error. + * @returns The matching agent_templates row, or null if not found. + * @throws If the Supabase query fails. */ export async function selectAgentTemplate(id: string): Promise | null> { const { data, error } = await supabase @@ -16,7 +21,7 @@ export async function selectAgentTemplate(id: string): Promise Date: Tue, 12 May 2026 01:09:56 +0530 Subject: [PATCH 04/32] refactor(api): collapse agent_templates supabase layer - One canonical SELECT + SDK-derived row type (no hand-written CreatorJoin) - Pure-data supabase functions: selectAgentTemplateById, selectOwnedAndPublicAgentTemplates, selectSharedAgentTemplates - Domain layer owns response shaping: buildAgentTemplateResponse, getAccessibleAgentTemplatesForAccount, getAgentTemplateForAccount, resolveSharedEmailsByTemplateId - Drop getAccessibleAgentTemplates, getAgentTemplateWithDetails, selectAgentTemplate (duplicated types, as-unknown-as casts, parallel buildCreator implementations) - Validators run auth before body parsing; toggle-favorite checks template visibility (own/public/shared) before mutating - Reuse selectAccountEmails instead of inlining account_emails queries - selectAgentTemplateFavorites returns rows; Set construction moves up Co-Authored-By: Claude Opus 4.7 (1M context) --- .../createAgentTemplateHandler.test.ts | 85 +++---- .../listAgentTemplatesHandler.test.ts | 38 ++- .../updateAgentTemplateHandler.test.ts | 20 +- .../buildAgentTemplateResponse.ts | 55 +++++ .../createAgentTemplateHandler.ts | 15 +- .../getAccessibleAgentTemplatesForAccount.ts | 52 ++++ .../getAgentTemplateForAccount.ts | 34 +++ .../listAgentTemplatesHandler.ts | 12 +- .../resolveSharedEmailsByTemplateId.ts | 40 ++++ .../updateAgentTemplateHandler.ts | 15 +- .../validateDeleteAgentTemplateRequest.ts | 19 +- .../validateToggleFavoriteRequest.ts | 43 +++- .../validateUpdateAgentTemplateRequest.ts | 19 +- .../selectAgentTemplateFavorites.ts | 17 +- .../agentTemplateWithCreatorSelect.ts | 16 ++ .../getAccessibleAgentTemplates.ts | 223 ------------------ .../getAgentTemplateWithDetails.ts | 124 ---------- .../agent_templates/selectAgentTemplate.ts | 28 --- .../selectAgentTemplateById.ts | 29 +++ .../selectOwnedAndPublicAgentTemplates.ts | 29 +++ .../selectSharedAgentTemplates.ts | 44 ++++ 21 files changed, 432 insertions(+), 525 deletions(-) create mode 100644 lib/agent_templates/buildAgentTemplateResponse.ts create mode 100644 lib/agent_templates/getAccessibleAgentTemplatesForAccount.ts create mode 100644 lib/agent_templates/getAgentTemplateForAccount.ts create mode 100644 lib/agent_templates/resolveSharedEmailsByTemplateId.ts create mode 100644 lib/supabase/agent_templates/agentTemplateWithCreatorSelect.ts delete mode 100644 lib/supabase/agent_templates/getAccessibleAgentTemplates.ts delete mode 100644 lib/supabase/agent_templates/getAgentTemplateWithDetails.ts delete mode 100644 lib/supabase/agent_templates/selectAgentTemplate.ts create mode 100644 lib/supabase/agent_templates/selectAgentTemplateById.ts create mode 100644 lib/supabase/agent_templates/selectOwnedAndPublicAgentTemplates.ts create mode 100644 lib/supabase/agent_templates/selectSharedAgentTemplates.ts diff --git a/lib/agent_templates/__tests__/createAgentTemplateHandler.test.ts b/lib/agent_templates/__tests__/createAgentTemplateHandler.test.ts index 1f2a2565f..371dfa361 100644 --- a/lib/agent_templates/__tests__/createAgentTemplateHandler.test.ts +++ b/lib/agent_templates/__tests__/createAgentTemplateHandler.test.ts @@ -17,8 +17,8 @@ vi.mock("@/lib/supabase/agent_template_shares/insertAgentTemplateShares", () => insertAgentTemplateShares: vi.fn(), })); -vi.mock("@/lib/supabase/agent_templates/getAgentTemplateWithDetails", () => ({ - getAgentTemplateWithDetails: vi.fn(), +vi.mock("@/lib/agent_templates/getAgentTemplateForAccount", () => ({ + getAgentTemplateForAccount: vi.fn(), })); const { createAgentTemplateHandler } = await import("../createAgentTemplateHandler"); @@ -27,54 +27,51 @@ const { insertAgentTemplate } = await import("@/lib/supabase/agent_templates/ins const { insertAgentTemplateShares } = await import( "@/lib/supabase/agent_template_shares/insertAgentTemplateShares" ); -const { getAgentTemplateWithDetails } = await import( - "@/lib/supabase/agent_templates/getAgentTemplateWithDetails" +const { getAgentTemplateForAccount } = await import( + "@/lib/agent_templates/getAgentTemplateForAccount" ); const ACCOUNT_ID = "11111111-1111-1111-1111-111111111111"; const TEMPLATE_ID = "22222222-2222-2222-2222-222222222222"; -function makeRequest(body: unknown) { - return new NextRequest("http://localhost/api/agent-templates", { +const mockAuthOk = () => + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: ACCOUNT_ID, + orgId: null, + authToken: "k", + }); + +const makeRequest = (body: unknown) => + new NextRequest("http://localhost/api/agent-templates", { method: "POST", headers: { "x-api-key": "k", "content-type": "application/json" }, body: JSON.stringify(body), }); -} + +const validBody = { + title: "Valid title", + description: "valid description", + prompt: "Valid prompt content for template", + tags: [], + is_private: false, + share_emails: [], +}; describe("createAgentTemplateHandler", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); + beforeEach(() => vi.clearAllMocks()); it("creates a template and shares emails when private", async () => { - vi.mocked(validateAuthContext).mockResolvedValue({ - accountId: ACCOUNT_ID, - orgId: null, - authToken: "k", - }); - - vi.mocked(insertAgentTemplate).mockResolvedValue({ id: TEMPLATE_ID } as any); + mockAuthOk(); + vi.mocked(insertAgentTemplate).mockResolvedValue({ id: TEMPLATE_ID } as never); vi.mocked(insertAgentTemplateShares).mockResolvedValue(1); - vi.mocked(getAgentTemplateWithDetails).mockResolvedValue({ - id: TEMPLATE_ID, - title: "Hello world title", - } as any); - - const req = makeRequest({ - title: "My Template", - description: "A useful description", - prompt: "This is the prompt content for the template", - tags: ["a", "b"], - is_private: true, - share_emails: ["a@x.com"], - }); - - const res = await createAgentTemplateHandler(req); + vi.mocked(getAgentTemplateForAccount).mockResolvedValue({ id: TEMPLATE_ID } as never); + + const res = await createAgentTemplateHandler( + makeRequest({ ...validBody, is_private: true, share_emails: ["a@x.com"] }), + ); + expect(res.status).toBe(201); - const body = await res.json(); - expect(body.status).toBe("success"); - expect(body.template.id).toBe(TEMPLATE_ID); + expect((await res.json()).template.id).toBe(TEMPLATE_ID); expect(insertAgentTemplate).toHaveBeenCalledWith( expect.objectContaining({ creator: ACCOUNT_ID, is_private: true }), ); @@ -82,26 +79,16 @@ describe("createAgentTemplateHandler", () => { }); it("returns 400 when validation fails", async () => { - const req = makeRequest({ title: "no" }); - const res = await createAgentTemplateHandler(req); + mockAuthOk(); + const res = await createAgentTemplateHandler(makeRequest({ title: "no" })); expect(res.status).toBe(400); - const body = await res.json(); - expect(body.status).toBe("error"); - expect(validateAuthContext).not.toHaveBeenCalled(); + expect(insertAgentTemplate).not.toHaveBeenCalled(); }); it("returns 401 when auth fails", async () => { const failure = NextResponse.json({ status: "error", error: "Unauthorized" }, { status: 401 }); vi.mocked(validateAuthContext).mockResolvedValue(failure); - const req = makeRequest({ - title: "Valid title", - description: "valid description", - prompt: "Valid prompt content for template", - tags: [], - is_private: false, - share_emails: [], - }); - const res = await createAgentTemplateHandler(req); + const res = await createAgentTemplateHandler(makeRequest(validBody)); expect(res).toBe(failure); }); }); diff --git a/lib/agent_templates/__tests__/listAgentTemplatesHandler.test.ts b/lib/agent_templates/__tests__/listAgentTemplatesHandler.test.ts index 1af5cfb8f..021f9a3c6 100644 --- a/lib/agent_templates/__tests__/listAgentTemplatesHandler.test.ts +++ b/lib/agent_templates/__tests__/listAgentTemplatesHandler.test.ts @@ -9,22 +9,20 @@ vi.mock("@/lib/auth/validateAuthContext", () => ({ validateAuthContext: vi.fn(), })); -vi.mock("@/lib/supabase/agent_templates/getAccessibleAgentTemplates", () => ({ - getAccessibleAgentTemplates: vi.fn(), +vi.mock("@/lib/agent_templates/getAccessibleAgentTemplatesForAccount", () => ({ + getAccessibleAgentTemplatesForAccount: vi.fn(), })); const { listAgentTemplatesHandler } = await import("../listAgentTemplatesHandler"); const { validateAuthContext } = await import("@/lib/auth/validateAuthContext"); -const { getAccessibleAgentTemplates } = await import( - "@/lib/supabase/agent_templates/getAccessibleAgentTemplates" +const { getAccessibleAgentTemplatesForAccount } = await import( + "@/lib/agent_templates/getAccessibleAgentTemplatesForAccount" ); const ACCOUNT_ID = "11111111-1111-1111-1111-111111111111"; describe("listAgentTemplatesHandler", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); + beforeEach(() => vi.clearAllMocks()); it("returns templates for the authenticated account", async () => { vi.mocked(validateAuthContext).mockResolvedValue({ @@ -32,32 +30,28 @@ describe("listAgentTemplatesHandler", () => { orgId: null, authToken: "k", }); - vi.mocked(getAccessibleAgentTemplates).mockResolvedValue([ - // Cast to match the enriched shape we expect downstream. - - { id: "t1", title: "T", shared_emails: [] } as any, + vi.mocked(getAccessibleAgentTemplatesForAccount).mockResolvedValue([ + { id: "t1", title: "T", shared_emails: [] } as never, ]); - const req = new NextRequest("http://localhost/api/agent-templates", { - headers: { "x-api-key": "k" }, - }); - const res = await listAgentTemplatesHandler(req); + const res = await listAgentTemplatesHandler( + new NextRequest("http://localhost/api/agent-templates", { headers: { "x-api-key": "k" } }), + ); expect(res.status).toBe(200); - const body = await res.json(); - expect(body.status).toBe("success"); - expect(body.templates).toHaveLength(1); - expect(getAccessibleAgentTemplates).toHaveBeenCalledWith(ACCOUNT_ID); + expect((await res.json()).templates).toHaveLength(1); + expect(getAccessibleAgentTemplatesForAccount).toHaveBeenCalledWith(ACCOUNT_ID); }); it("returns the auth NextResponse when authentication fails", async () => { const failure = NextResponse.json({ status: "error", error: "Unauthorized" }, { status: 401 }); vi.mocked(validateAuthContext).mockResolvedValue(failure); - const req = new NextRequest("http://localhost/api/agent-templates"); - const res = await listAgentTemplatesHandler(req); + const res = await listAgentTemplatesHandler( + new NextRequest("http://localhost/api/agent-templates"), + ); expect(res).toBe(failure); - expect(getAccessibleAgentTemplates).not.toHaveBeenCalled(); + expect(getAccessibleAgentTemplatesForAccount).not.toHaveBeenCalled(); }); }); diff --git a/lib/agent_templates/__tests__/updateAgentTemplateHandler.test.ts b/lib/agent_templates/__tests__/updateAgentTemplateHandler.test.ts index 421c16f84..223979315 100644 --- a/lib/agent_templates/__tests__/updateAgentTemplateHandler.test.ts +++ b/lib/agent_templates/__tests__/updateAgentTemplateHandler.test.ts @@ -21,8 +21,8 @@ vi.mock("@/lib/supabase/agent_template_shares/insertAgentTemplateShares", () => insertAgentTemplateShares: vi.fn(), })); -vi.mock("@/lib/supabase/agent_templates/getAgentTemplateWithDetails", () => ({ - getAgentTemplateWithDetails: vi.fn(), +vi.mock("@/lib/agent_templates/getAgentTemplateForAccount", () => ({ + getAgentTemplateForAccount: vi.fn(), })); const { updateAgentTemplateHandler } = await import("../updateAgentTemplateHandler"); @@ -36,17 +36,15 @@ const { deleteAgentTemplateShares } = await import( const { insertAgentTemplateShares } = await import( "@/lib/supabase/agent_template_shares/insertAgentTemplateShares" ); -const { getAgentTemplateWithDetails } = await import( - "@/lib/supabase/agent_templates/getAgentTemplateWithDetails" +const { getAgentTemplateForAccount } = await import( + "@/lib/agent_templates/getAgentTemplateForAccount" ); const ACCOUNT_ID = "11111111-1111-1111-1111-111111111111"; const TEMPLATE_ID = "22222222-2222-2222-2222-222222222222"; describe("updateAgentTemplateHandler", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); + beforeEach(() => vi.clearAllMocks()); it("updates the template and replaces shares when share_emails provided", async () => { vi.mocked(validateUpdateAgentTemplateRequest).mockResolvedValue({ @@ -54,12 +52,10 @@ describe("updateAgentTemplateHandler", () => { accountId: ACCOUNT_ID, body: { title: "New Title", share_emails: ["x@y.com"] }, }); - - vi.mocked(updateAgentTemplate).mockResolvedValue({ id: TEMPLATE_ID } as any); + vi.mocked(updateAgentTemplate).mockResolvedValue({ id: TEMPLATE_ID } as never); vi.mocked(deleteAgentTemplateShares).mockResolvedValue(undefined); vi.mocked(insertAgentTemplateShares).mockResolvedValue(1); - - vi.mocked(getAgentTemplateWithDetails).mockResolvedValue({ id: TEMPLATE_ID } as any); + vi.mocked(getAgentTemplateForAccount).mockResolvedValue({ id: TEMPLATE_ID } as never); const req = new NextRequest(`http://localhost/api/agent-templates/${TEMPLATE_ID}`, { method: "PATCH", @@ -67,8 +63,6 @@ describe("updateAgentTemplateHandler", () => { const res = await updateAgentTemplateHandler(req, Promise.resolve({ id: TEMPLATE_ID })); expect(res.status).toBe(200); - const body = await res.json(); - expect(body.status).toBe("success"); expect(updateAgentTemplate).toHaveBeenCalledWith(TEMPLATE_ID, { title: "New Title" }); expect(deleteAgentTemplateShares).toHaveBeenCalledWith(TEMPLATE_ID); expect(insertAgentTemplateShares).toHaveBeenCalledWith(TEMPLATE_ID, ["x@y.com"]); diff --git a/lib/agent_templates/buildAgentTemplateResponse.ts b/lib/agent_templates/buildAgentTemplateResponse.ts new file mode 100644 index 000000000..9db417830 --- /dev/null +++ b/lib/agent_templates/buildAgentTemplateResponse.ts @@ -0,0 +1,55 @@ +import type { Tables } from "@/types/database.types"; +import { ADMIN_EMAILS } from "@/lib/const"; +import type { AgentTemplateWithCreator } from "@/lib/supabase/agent_templates/agentTemplateWithCreatorSelect"; + +export interface AgentTemplateCreator { + id: string; + name: string | null; + image: string | null; + is_admin: boolean; +} + +export type AgentTemplateResponse = Omit, "creator"> & { + creator: AgentTemplateCreator | null; + is_favourite: boolean; + shared_emails: string[]; +}; + +/** + * Flattens the joined creator block and computes `is_admin` by intersecting + * the creator's emails with `ADMIN_EMAILS`. + */ +function buildCreator(joined: AgentTemplateWithCreator["creator"]): AgentTemplateCreator | null { + if (!joined) return null; + const row = Array.isArray(joined) ? joined[0] : joined; + if (!row) return null; + + const emails = (row.account_emails ?? []) + .map(e => e.email) + .filter((e): e is string => typeof e === "string"); + + return { + id: row.id, + name: row.name ?? null, + image: row.account_info?.[0]?.image ?? null, + is_admin: emails.some(email => ADMIN_EMAILS.includes(email)), + }; +} + +/** + * Shapes a raw joined agent template row into the API response, layering in + * caller-specific signals (`is_favourite`, `shared_emails`) that are computed + * upstream. + */ +export function buildAgentTemplateResponse( + row: AgentTemplateWithCreator, + args: { isFavourite: boolean; sharedEmails: string[] }, +): AgentTemplateResponse { + const { creator, ...rest } = row; + return { + ...rest, + creator: buildCreator(creator), + is_favourite: args.isFavourite, + shared_emails: args.sharedEmails, + }; +} diff --git a/lib/agent_templates/createAgentTemplateHandler.ts b/lib/agent_templates/createAgentTemplateHandler.ts index 1405064e5..0ba4ad70d 100644 --- a/lib/agent_templates/createAgentTemplateHandler.ts +++ b/lib/agent_templates/createAgentTemplateHandler.ts @@ -5,27 +5,24 @@ import { safeParseJson } from "@/lib/networking/safeParseJson"; import { validateCreateAgentTemplateBody } from "@/lib/agent_templates/validateCreateAgentTemplateBody"; import { insertAgentTemplate } from "@/lib/supabase/agent_templates/insertAgentTemplate"; import { insertAgentTemplateShares } from "@/lib/supabase/agent_template_shares/insertAgentTemplateShares"; -import { getAgentTemplateWithDetails } from "@/lib/supabase/agent_templates/getAgentTemplateWithDetails"; +import { getAgentTemplateForAccount } from "@/lib/agent_templates/getAgentTemplateForAccount"; /** * Handler for POST /api/agent-templates. * * Creates an agent template owned by the authenticated account. When - * `is_private=true`, the supplied `share_emails` are resolved to accounts and + * `is_private=true`, supplied `share_emails` are resolved to accounts and * upserted into `agent_template_shares`. - * - * @param request - The incoming request - * @returns A 201 NextResponse with `{ status, template }`, or an error. */ export async function createAgentTemplateHandler(request: NextRequest): Promise { try { + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) return authResult; + const body = await safeParseJson(request); const parsedBody = validateCreateAgentTemplateBody(body); if (parsedBody instanceof NextResponse) return parsedBody; - const authResult = await validateAuthContext(request); - if (authResult instanceof NextResponse) return authResult; - const accountId = authResult.accountId; const inserted = await insertAgentTemplate({ @@ -48,7 +45,7 @@ export async function createAgentTemplateHandler(request: NextRequest): Promise< await insertAgentTemplateShares(inserted.id, parsedBody.share_emails); } - const template = await getAgentTemplateWithDetails(inserted.id, accountId); + const template = await getAgentTemplateForAccount(inserted.id, accountId); return NextResponse.json( { status: "success", template }, diff --git a/lib/agent_templates/getAccessibleAgentTemplatesForAccount.ts b/lib/agent_templates/getAccessibleAgentTemplatesForAccount.ts new file mode 100644 index 000000000..9d4676dd0 --- /dev/null +++ b/lib/agent_templates/getAccessibleAgentTemplatesForAccount.ts @@ -0,0 +1,52 @@ +import { selectOwnedAndPublicAgentTemplates } from "@/lib/supabase/agent_templates/selectOwnedAndPublicAgentTemplates"; +import { selectSharedAgentTemplates } from "@/lib/supabase/agent_templates/selectSharedAgentTemplates"; +import { selectAgentTemplateFavorites } from "@/lib/supabase/agent_template_favorites/selectAgentTemplateFavorites"; +import type { AgentTemplateWithCreator } from "@/lib/supabase/agent_templates/agentTemplateWithCreatorSelect"; +import { + buildAgentTemplateResponse, + type AgentTemplateResponse, +} from "@/lib/agent_templates/buildAgentTemplateResponse"; +import { resolveSharedEmailsByTemplateId } from "@/lib/agent_templates/resolveSharedEmailsByTemplateId"; + +function creatorIdOf(row: AgentTemplateWithCreator): string | null { + const c = row.creator; + if (!c) return null; + return Array.isArray(c) ? (c[0]?.id ?? null) : c.id; +} + +/** + * Returns every agent template visible to `accountId` (own, public, shared), + * shaped for the API response with `creator`, `is_favourite`, and (for + * private templates the caller owns) `shared_emails` populated. + * + * Sharees never see `shared_emails` — only the template's creator does. + */ +export async function getAccessibleAgentTemplatesForAccount( + accountId: string, +): Promise { + const [ownedAndPublic, shared, favorites] = await Promise.all([ + selectOwnedAndPublicAgentTemplates(accountId), + selectSharedAgentTemplates(accountId), + selectAgentTemplateFavorites(accountId), + ]); + + const byId = new Map(); + [...ownedAndPublic, ...shared].forEach(row => { + if (!byId.has(row.id)) byId.set(row.id, row); + }); + const rows = Array.from(byId.values()); + + const favoriteIds = new Set(favorites.map(f => f.template_id)); + const ownedPrivateIds = rows + .filter(r => r.is_private && creatorIdOf(r) === accountId) + .map(r => r.id); + const sharedEmailsMap = await resolveSharedEmailsByTemplateId(ownedPrivateIds); + + return rows.map(row => + buildAgentTemplateResponse(row, { + isFavourite: favoriteIds.has(row.id), + sharedEmails: + row.is_private && creatorIdOf(row) === accountId ? (sharedEmailsMap[row.id] ?? []) : [], + }), + ); +} diff --git a/lib/agent_templates/getAgentTemplateForAccount.ts b/lib/agent_templates/getAgentTemplateForAccount.ts new file mode 100644 index 000000000..5338b15e4 --- /dev/null +++ b/lib/agent_templates/getAgentTemplateForAccount.ts @@ -0,0 +1,34 @@ +import { selectAgentTemplateById } from "@/lib/supabase/agent_templates/selectAgentTemplateById"; +import { selectAgentTemplateFavorites } from "@/lib/supabase/agent_template_favorites/selectAgentTemplateFavorites"; +import { + buildAgentTemplateResponse, + type AgentTemplateResponse, +} from "@/lib/agent_templates/buildAgentTemplateResponse"; +import { resolveSharedEmailsByTemplateId } from "@/lib/agent_templates/resolveSharedEmailsByTemplateId"; + +/** + * Fetches a single agent template by id, shaped for the API response with + * `is_favourite` (for `accountId`) and `shared_emails` (only when the caller + * is the template's creator) populated. Returns `null` when the template + * does not exist. + */ +export async function getAgentTemplateForAccount( + templateId: string, + accountId: string, +): Promise { + const row = await selectAgentTemplateById(templateId); + if (!row) return null; + + const creator = Array.isArray(row.creator) ? row.creator[0] : row.creator; + const isOwner = creator?.id === accountId; + + const [favorites, sharedEmailsMap] = await Promise.all([ + selectAgentTemplateFavorites(accountId), + row.is_private && isOwner ? resolveSharedEmailsByTemplateId([row.id]) : Promise.resolve({}), + ]); + + return buildAgentTemplateResponse(row, { + isFavourite: favorites.some(f => f.template_id === row.id), + sharedEmails: sharedEmailsMap[row.id] ?? [], + }); +} diff --git a/lib/agent_templates/listAgentTemplatesHandler.ts b/lib/agent_templates/listAgentTemplatesHandler.ts index 3581409d0..22bb61a78 100644 --- a/lib/agent_templates/listAgentTemplatesHandler.ts +++ b/lib/agent_templates/listAgentTemplatesHandler.ts @@ -1,24 +1,20 @@ import { NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; -import { getAccessibleAgentTemplates } from "@/lib/supabase/agent_templates/getAccessibleAgentTemplates"; +import { getAccessibleAgentTemplatesForAccount } from "@/lib/agent_templates/getAccessibleAgentTemplatesForAccount"; /** * Handler for GET /api/agent-templates. * - * Returns every agent template the authenticated account can see (own + public - * + shared) with the creator block, `is_favourite`, and `shared_emails` - * embedded. - * - * @param request - The incoming request - * @returns A 200 NextResponse with `{ status, templates }`, or an error. + * Returns every agent template the authenticated account can see (own, public, + * shared) with `creator`, `is_favourite`, and `shared_emails` embedded. */ export async function listAgentTemplatesHandler(request: NextRequest): Promise { try { const authResult = await validateAuthContext(request); if (authResult instanceof NextResponse) return authResult; - const templates = await getAccessibleAgentTemplates(authResult.accountId); + const templates = await getAccessibleAgentTemplatesForAccount(authResult.accountId); return NextResponse.json( { status: "success", templates }, diff --git a/lib/agent_templates/resolveSharedEmailsByTemplateId.ts b/lib/agent_templates/resolveSharedEmailsByTemplateId.ts new file mode 100644 index 000000000..e78f41322 --- /dev/null +++ b/lib/agent_templates/resolveSharedEmailsByTemplateId.ts @@ -0,0 +1,40 @@ +import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmails"; +import { selectAgentTemplateShares } from "@/lib/supabase/agent_template_shares/selectAgentTemplateShares"; + +/** + * For each supplied template id, returns the list of emails it has been + * shared with — by joining `agent_template_shares` to `account_emails`. + * Empty input returns an empty map. + */ +export async function resolveSharedEmailsByTemplateId( + templateIds: string[], +): Promise> { + if (templateIds.length === 0) return {}; + + const shares = await selectAgentTemplateShares(templateIds); + if (shares.length === 0) return {}; + + const accountIds = Array.from(new Set(shares.map(s => s.user_id))); + const accountEmails = await selectAccountEmails({ accountIds }); + + const emailsByAccount = new Map(); + accountEmails.forEach(row => { + if (!row.account_id || !row.email) return; + const list = emailsByAccount.get(row.account_id) ?? []; + list.push(row.email); + emailsByAccount.set(row.account_id, list); + }); + + const result: Record = {}; + shares.forEach(share => { + const list = result[share.template_id] ?? []; + list.push(...(emailsByAccount.get(share.user_id) ?? [])); + result[share.template_id] = list; + }); + + Object.keys(result).forEach(id => { + result[id] = Array.from(new Set(result[id])); + }); + + return result; +} diff --git a/lib/agent_templates/updateAgentTemplateHandler.ts b/lib/agent_templates/updateAgentTemplateHandler.ts index a2c362be3..8f22f2252 100644 --- a/lib/agent_templates/updateAgentTemplateHandler.ts +++ b/lib/agent_templates/updateAgentTemplateHandler.ts @@ -4,7 +4,7 @@ import { validateUpdateAgentTemplateRequest } from "@/lib/agent_templates/valida import { updateAgentTemplate } from "@/lib/supabase/agent_templates/updateAgentTemplate"; import { deleteAgentTemplateShares } from "@/lib/supabase/agent_template_shares/deleteAgentTemplateShares"; import { insertAgentTemplateShares } from "@/lib/supabase/agent_template_shares/insertAgentTemplateShares"; -import { getAgentTemplateWithDetails } from "@/lib/supabase/agent_templates/getAgentTemplateWithDetails"; +import { getAgentTemplateForAccount } from "@/lib/agent_templates/getAgentTemplateForAccount"; import type { TablesUpdate } from "@/types/database.types"; /** @@ -13,10 +13,6 @@ import type { TablesUpdate } from "@/types/database.types"; * Applies a partial update to an agent template the caller owns. When * `share_emails` is provided, existing shares are wiped and re-inserted from * the resolved emails. - * - * @param request - The incoming request - * @param params - Route params containing the template id - * @returns A 200 NextResponse with `{ status, template }`, or an error. */ export async function updateAgentTemplateHandler( request: NextRequest, @@ -46,11 +42,10 @@ export async function updateAgentTemplateHandler( } } - // NOTE: this delete-then-insert is not atomic. If the insert fails after - // the delete succeeds the template will end up with no shares. A real fix + // NOTE: delete-then-insert is not atomic. If the insert fails after the + // delete succeeds the template will end up with no shares. A real fix // requires a Postgres RPC; for now both helpers throw on DB error so the - // outer catch returns a 500 and the client knows the operation didn't - // complete cleanly. + // outer catch returns a 500. if (typeof body.share_emails !== "undefined") { await deleteAgentTemplateShares(templateId); if (body.share_emails.length > 0) { @@ -58,7 +53,7 @@ export async function updateAgentTemplateHandler( } } - const template = await getAgentTemplateWithDetails(templateId, accountId); + const template = await getAgentTemplateForAccount(templateId, accountId); return NextResponse.json( { status: "success", template }, diff --git a/lib/agent_templates/validateDeleteAgentTemplateRequest.ts b/lib/agent_templates/validateDeleteAgentTemplateRequest.ts index 4111f7b46..5e26f2174 100644 --- a/lib/agent_templates/validateDeleteAgentTemplateRequest.ts +++ b/lib/agent_templates/validateDeleteAgentTemplateRequest.ts @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { validateAccountParams } from "@/lib/accounts/validateAccountParams"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { selectAgentTemplate } from "@/lib/supabase/agent_templates/selectAgentTemplate"; +import { selectAgentTemplateById } from "@/lib/supabase/agent_templates/selectAgentTemplateById"; export interface ValidatedDeleteAgentTemplateRequest { templateId: string; @@ -10,27 +10,23 @@ export interface ValidatedDeleteAgentTemplateRequest { } /** - * Validates DELETE /api/agent-templates/{id}: id format, auth, and that the + * Validates DELETE /api/agent-templates/{id}: auth, id format, and that the * caller is the template's creator. - * - * @param request - The incoming request - * @param id - The template id from the route - * @returns Validated payload, or a NextResponse error. */ export async function validateDeleteAgentTemplateRequest( request: NextRequest, id: string, ): Promise { - const validatedParams = validateAccountParams(id); - if (validatedParams instanceof NextResponse) return validatedParams; - const authResult = await validateAuthContext(request); if (authResult instanceof NextResponse) return authResult; + const validatedParams = validateAccountParams(id); + if (validatedParams instanceof NextResponse) return validatedParams; + const templateId = validatedParams.id; const accountId = authResult.accountId; - const existing = await selectAgentTemplate(templateId); + const existing = await selectAgentTemplateById(templateId); if (!existing) { return NextResponse.json( { status: "error", error: "Agent template not found" }, @@ -38,7 +34,8 @@ export async function validateDeleteAgentTemplateRequest( ); } - if (existing.creator !== accountId) { + const creator = Array.isArray(existing.creator) ? existing.creator[0] : existing.creator; + if (creator?.id !== accountId) { return NextResponse.json( { status: "error", error: "Forbidden" }, { status: 403, headers: getCorsHeaders() }, diff --git a/lib/agent_templates/validateToggleFavoriteRequest.ts b/lib/agent_templates/validateToggleFavoriteRequest.ts index 5810bae07..32b9f096b 100644 --- a/lib/agent_templates/validateToggleFavoriteRequest.ts +++ b/lib/agent_templates/validateToggleFavoriteRequest.ts @@ -4,6 +4,8 @@ import { validateAccountParams } from "@/lib/accounts/validateAccountParams"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { safeParseJson } from "@/lib/networking/safeParseJson"; +import { selectAgentTemplateById } from "@/lib/supabase/agent_templates/selectAgentTemplateById"; +import { selectAgentTemplateShares } from "@/lib/supabase/agent_template_shares/selectAgentTemplateShares"; export const toggleFavoriteBodySchema = z.object({ is_favourite: z.boolean({ message: "is_favourite is required" }), @@ -18,17 +20,16 @@ export interface ValidatedToggleFavoriteRequest { } /** - * Validates PUT /api/agent-templates/{id}/favorite: id format, auth, and the - * `{ is_favourite: boolean }` body. - * - * @param request - The incoming request - * @param id - The template id from the route - * @returns Validated payload, or a NextResponse error. + * Validates PUT /api/agent-templates/{id}/favorite: auth, id format, body, + * and that the caller can see the template (own, public, or shared). */ export async function validateToggleFavoriteRequest( request: NextRequest, id: string, ): Promise { + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) return authResult; + const validatedParams = validateAccountParams(id); if (validatedParams instanceof NextResponse) return validatedParams; @@ -46,12 +47,34 @@ export async function validateToggleFavoriteRequest( ); } - const authResult = await validateAuthContext(request); - if (authResult instanceof NextResponse) return authResult; + const templateId = validatedParams.id; + const accountId = authResult.accountId; + + const existing = await selectAgentTemplateById(templateId); + if (!existing) { + return NextResponse.json( + { status: "error", error: "Agent template not found" }, + { status: 404, headers: getCorsHeaders() }, + ); + } + + const creator = Array.isArray(existing.creator) ? existing.creator[0] : existing.creator; + const isOwner = creator?.id === accountId; + let canAccess = isOwner || !existing.is_private; + if (!canAccess) { + const shares = await selectAgentTemplateShares([templateId]); + canAccess = shares.some(s => s.user_id === accountId); + } + if (!canAccess) { + return NextResponse.json( + { status: "error", error: "Forbidden" }, + { status: 403, headers: getCorsHeaders() }, + ); + } return { - templateId: validatedParams.id, - accountId: authResult.accountId, + templateId, + accountId, isFavourite: parsedBody.data.is_favourite, }; } diff --git a/lib/agent_templates/validateUpdateAgentTemplateRequest.ts b/lib/agent_templates/validateUpdateAgentTemplateRequest.ts index b0f2bddb4..901c25986 100644 --- a/lib/agent_templates/validateUpdateAgentTemplateRequest.ts +++ b/lib/agent_templates/validateUpdateAgentTemplateRequest.ts @@ -4,7 +4,7 @@ import { validateAccountParams } from "@/lib/accounts/validateAccountParams"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { safeParseJson } from "@/lib/networking/safeParseJson"; -import { selectAgentTemplate } from "@/lib/supabase/agent_templates/selectAgentTemplate"; +import { selectAgentTemplateById } from "@/lib/supabase/agent_templates/selectAgentTemplateById"; export const updateAgentTemplateBodySchema = z .object({ @@ -28,17 +28,16 @@ export interface ValidatedUpdateAgentTemplateRequest { } /** - * Validates PATCH /api/agent-templates/{id}: id format, body, auth, and that + * Validates PATCH /api/agent-templates/{id}: auth, id format, body, and that * the caller is the template's creator. - * - * @param request - The incoming request - * @param id - The template id from the route - * @returns Validated payload, or a NextResponse error. */ export async function validateUpdateAgentTemplateRequest( request: NextRequest, id: string, ): Promise { + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) return authResult; + const validatedParams = validateAccountParams(id); if (validatedParams instanceof NextResponse) return validatedParams; @@ -56,13 +55,10 @@ export async function validateUpdateAgentTemplateRequest( ); } - const authResult = await validateAuthContext(request); - if (authResult instanceof NextResponse) return authResult; - const templateId = validatedParams.id; const accountId = authResult.accountId; - const existing = await selectAgentTemplate(templateId); + const existing = await selectAgentTemplateById(templateId); if (!existing) { return NextResponse.json( { status: "error", error: "Agent template not found" }, @@ -70,7 +66,8 @@ export async function validateUpdateAgentTemplateRequest( ); } - if (existing.creator !== accountId) { + const creator = Array.isArray(existing.creator) ? existing.creator[0] : existing.creator; + if (creator?.id !== accountId) { return NextResponse.json( { status: "error", error: "Forbidden" }, { status: 403, headers: getCorsHeaders() }, diff --git a/lib/supabase/agent_template_favorites/selectAgentTemplateFavorites.ts b/lib/supabase/agent_template_favorites/selectAgentTemplateFavorites.ts index a6d26f491..46183bbf4 100644 --- a/lib/supabase/agent_template_favorites/selectAgentTemplateFavorites.ts +++ b/lib/supabase/agent_template_favorites/selectAgentTemplateFavorites.ts @@ -1,21 +1,24 @@ import supabase from "@/lib/supabase/serverClient"; +import type { Tables } from "@/types/database.types"; /** - * Returns the set of template ids the given account has favorited. + * Selects raw `agent_template_favorites` rows for the given account. * - * @param accountId - The account UUID - * @returns Set of template ids; empty Set on no rows or on error. + * Returns an empty array on database error (and logs it). Callers that need + * a Set of template ids should compose it themselves. */ -export async function selectAgentTemplateFavorites(accountId: string): Promise> { +export async function selectAgentTemplateFavorites( + accountId: string, +): Promise[]> { const { data, error } = await supabase .from("agent_template_favorites") - .select("template_id") + .select("*") .eq("user_id", accountId); if (error) { console.error("Error selecting agent_template_favorites:", error); - return new Set(); + return []; } - return new Set((data ?? []).map(row => row.template_id)); + return data ?? []; } diff --git a/lib/supabase/agent_templates/agentTemplateWithCreatorSelect.ts b/lib/supabase/agent_templates/agentTemplateWithCreatorSelect.ts new file mode 100644 index 000000000..82af50469 --- /dev/null +++ b/lib/supabase/agent_templates/agentTemplateWithCreatorSelect.ts @@ -0,0 +1,16 @@ +import type { QueryData } from "@supabase/supabase-js"; +import supabase from "@/lib/supabase/serverClient"; + +export const AGENT_TEMPLATE_WITH_CREATOR_SELECT = ` + *, + creator:accounts!agent_templates_creator_fkey ( + id, + name, + account_info ( image ), + account_emails ( email ) + ) +` as const; + +const _typedQuery = supabase.from("agent_templates").select(AGENT_TEMPLATE_WITH_CREATOR_SELECT); + +export type AgentTemplateWithCreator = QueryData[number]; diff --git a/lib/supabase/agent_templates/getAccessibleAgentTemplates.ts b/lib/supabase/agent_templates/getAccessibleAgentTemplates.ts deleted file mode 100644 index 1e339d777..000000000 --- a/lib/supabase/agent_templates/getAccessibleAgentTemplates.ts +++ /dev/null @@ -1,223 +0,0 @@ -import supabase from "@/lib/supabase/serverClient"; -import { ADMIN_EMAILS } from "@/lib/const"; -import type { Tables } from "@/types/database.types"; -import { selectAgentTemplateFavorites } from "@/lib/supabase/agent_template_favorites/selectAgentTemplateFavorites"; -import { selectAgentTemplateShares } from "@/lib/supabase/agent_template_shares/selectAgentTemplateShares"; - -/** - * Embedded creator block surfaced on each agent template row. - */ -export interface AgentTemplateCreator { - id: string; - name: string | null; - image: string | null; - is_admin: boolean; -} - -/** - * Enriched agent template payload returned by GET /api/agent-templates and - * by the create / update handlers. - */ -export interface AgentTemplateWithDetails { - id: string; - title: string; - description: string; - prompt: string; - tags: string[] | null; - creator: AgentTemplateCreator | null; - is_private: boolean; - is_favourite: boolean; - favorites_count: number | null; - shared_emails: string[]; - created_at: string | null; - updated_at: string | null; -} - -interface CreatorJoin { - id: string; - name: string | null; - account_info: Array, "image">> | null; - account_emails: Array, "email">> | null; -} - -type AgentTemplateRowWithCreator = Omit, "creator"> & { - creator: CreatorJoin | null; -}; - -/** - * Builds the embedded creator object from a joined accounts row, deriving the - * is_admin flag by intersecting the account's emails with `ADMIN_EMAILS`. - */ -function buildCreator(row: CreatorJoin | null): AgentTemplateCreator | null { - if (!row) return null; - - const image = row.account_info?.[0]?.image ?? null; - const emails = (row.account_emails ?? []) - .map(e => e.email) - .filter((e): e is string => typeof e === "string"); - const isAdmin = emails.some(email => ADMIN_EMAILS.includes(email)); - - return { - id: row.id, - name: row.name ?? null, - image, - is_admin: isAdmin, - }; -} - -/** - * Fetches every agent template visible to `accountId` and enriches each row - * with the creator block, the caller's `is_favourite` flag, and (for private - * templates the caller owns) the list of `shared_emails`. - * - * Visibility rules: - * - templates the caller created - * - public templates (`is_private = false`) - * - private templates the caller has been granted access to via - * `agent_template_shares` - * - * Privacy: `shared_emails` is only populated for templates where the caller - * is the creator. Sharees would otherwise leak each other's emails. - * - * Throws on database error so the handler surfaces a real 500 instead of - * returning an empty list that looks like "no templates". - * - * TODO: paginate. The owned-and-public query is subject to Supabase's - * default 1000-row cap; as public templates grow this will silently truncate. - * - * @param accountId - The authenticated account's UUID. - * @returns Array of enriched template rows. - * @throws If any underlying Supabase query fails. - */ -export async function getAccessibleAgentTemplates( - accountId: string, -): Promise { - const ownedAndPublicSelect = ` - *, - creator:accounts!agent_templates_creator_fkey ( - id, - name, - account_info ( image ), - account_emails ( email ) - ) - `; - - const { data: ownedAndPublic, error: ownedErr } = await supabase - .from("agent_templates") - .select(ownedAndPublicSelect) - .or(`creator.eq.${accountId},is_private.eq.false`) - .order("title"); - - if (ownedErr) { - console.error("Error selecting owned/public agent_templates:", ownedErr); - throw new Error(`getAccessibleAgentTemplates (owned/public) failed: ${ownedErr.message}`); - } - - const { data: sharedJoin, error: sharedErr } = await supabase - .from("agent_template_shares") - .select( - `template:agent_templates!agent_template_shares_template_id_fkey ( - ${ownedAndPublicSelect} - )`, - ) - .eq("user_id", accountId); - - if (sharedErr) { - console.error("Error selecting shared agent_templates:", sharedErr); - throw new Error(`getAccessibleAgentTemplates (shared) failed: ${sharedErr.message}`); - } - - // Deduplicate by template id. - const byId = new Map(); - (ownedAndPublic ?? []).forEach(row => { - byId.set(row.id, row as unknown as AgentTemplateRowWithCreator); - }); - - (sharedJoin ?? []).forEach(share => { - const template = ( - share as unknown as { - template: AgentTemplateRowWithCreator | AgentTemplateRowWithCreator[] | null; - } - ).template; - if (!template) return; - const list = Array.isArray(template) ? template : [template]; - list.forEach(t => { - if (t && t.id && !byId.has(t.id)) byId.set(t.id, t); - }); - }); - - const templates = Array.from(byId.values()); - if (templates.length === 0) return []; - - const favourites = await selectAgentTemplateFavorites(accountId); - - // Resolve shared_emails only for private templates the caller owns. - // Sharees do not see who else a template was shared with. - const ownedPrivateIds = templates - .filter(t => t.is_private && t.creator?.id === accountId) - .map(t => t.id); - const sharedEmailsByTemplate = await getSharedEmailsByTemplateId(ownedPrivateIds); - - return templates.map(t => ({ - id: t.id, - title: t.title, - description: t.description, - prompt: t.prompt, - tags: t.tags ?? null, - creator: buildCreator(t.creator), - is_private: t.is_private, - is_favourite: favourites.has(t.id), - favorites_count: t.favorites_count ?? null, - shared_emails: - t.is_private && t.creator?.id === accountId ? (sharedEmailsByTemplate[t.id] ?? []) : [], - created_at: t.created_at ?? null, - updated_at: t.updated_at ?? null, - })); -} - -/** - * Builds a `template_id -> emails[]` map by fanning out template ids through - * shares, then resolving the recipient account ids back to email strings. - */ -async function getSharedEmailsByTemplateId( - templateIds: string[], -): Promise> { - if (templateIds.length === 0) return {}; - - const shares = await selectAgentTemplateShares(templateIds); - if (shares.length === 0) return {}; - - const accountIds = Array.from(new Set(shares.map(s => s.user_id))); - - const { data: emailRows, error } = await supabase - .from("account_emails") - .select("account_id, email") - .in("account_id", accountIds); - - if (error) { - console.error("Error selecting account_emails for shares:", error); - throw new Error(`getSharedEmailsByTemplateId failed: ${error.message}`); - } - - const emailsByAccount = new Map(); - (emailRows ?? []).forEach(row => { - if (!row.account_id || !row.email) return; - const list = emailsByAccount.get(row.account_id) ?? []; - list.push(row.email); - emailsByAccount.set(row.account_id, list); - }); - - const result: Record = {}; - shares.forEach(share => { - const list = result[share.template_id] ?? []; - const accountEmails = emailsByAccount.get(share.user_id) ?? []; - list.push(...accountEmails); - result[share.template_id] = list; - }); - - Object.keys(result).forEach(id => { - result[id] = Array.from(new Set(result[id])); - }); - - return result; -} diff --git a/lib/supabase/agent_templates/getAgentTemplateWithDetails.ts b/lib/supabase/agent_templates/getAgentTemplateWithDetails.ts deleted file mode 100644 index a6ffc0f23..000000000 --- a/lib/supabase/agent_templates/getAgentTemplateWithDetails.ts +++ /dev/null @@ -1,124 +0,0 @@ -import supabase from "@/lib/supabase/serverClient"; -import { ADMIN_EMAILS } from "@/lib/const"; -import type { Tables } from "@/types/database.types"; -import type { - AgentTemplateCreator, - AgentTemplateWithDetails, -} from "@/lib/supabase/agent_templates/getAccessibleAgentTemplates"; -import { selectAgentTemplateFavorites } from "@/lib/supabase/agent_template_favorites/selectAgentTemplateFavorites"; -import { selectAgentTemplateShares } from "@/lib/supabase/agent_template_shares/selectAgentTemplateShares"; - -interface CreatorJoin { - id: string; - name: string | null; - account_info: Array, "image">> | null; - account_emails: Array, "email">> | null; -} - -type AgentTemplateRowWithCreator = Omit, "creator"> & { - creator: CreatorJoin | null; -}; - -/** - * Fetches a single agent template enriched with the creator block, the - * caller's `is_favourite` flag, and (for private templates) `shared_emails`. - * - * Mirrors the per-row shape returned by `getAccessibleAgentTemplates` so that - * POST and PATCH responses are byte-compatible with the GET list element. - * - * @param templateId - The agent template UUID - * @param accountId - Caller account UUID, used to compute `is_favourite` - * @returns The enriched template row, or null if not found / on error. - */ -export async function getAgentTemplateWithDetails( - templateId: string, - accountId: string, -): Promise { - const { data, error } = await supabase - .from("agent_templates") - .select( - ` - *, - creator:accounts!agent_templates_creator_fkey ( - id, - name, - account_info ( image ), - account_emails ( email ) - ) - `, - ) - .eq("id", templateId) - .maybeSingle(); - - if (error) { - console.error("Error selecting agent_template with details:", error); - throw new Error(`getAgentTemplateWithDetails failed: ${error.message}`); - } - - if (!data) return null; - - const row = data as unknown as AgentTemplateRowWithCreator; - - const favourites = await selectAgentTemplateFavorites(accountId); - - // Privacy: only the template owner can see who else it was shared with. - // Sharees would otherwise leak each other's emails. - let sharedEmails: string[] = []; - if (row.is_private && row.creator?.id === accountId) { - sharedEmails = await getSharedEmailsForTemplate(row.id); - } - - return { - id: row.id, - title: row.title, - description: row.description, - prompt: row.prompt, - tags: row.tags ?? null, - creator: buildCreator(row.creator), - is_private: row.is_private, - is_favourite: favourites.has(row.id), - favorites_count: row.favorites_count ?? null, - shared_emails: sharedEmails, - created_at: row.created_at ?? null, - updated_at: row.updated_at ?? null, - }; -} - -/** - * Resolves shared recipients for a single template id back to email strings. - */ -async function getSharedEmailsForTemplate(templateId: string): Promise { - const shares = await selectAgentTemplateShares([templateId]); - if (shares.length === 0) return []; - - const accountIds = Array.from(new Set(shares.map(s => s.user_id))); - - const { data, error } = await supabase - .from("account_emails") - .select("email") - .in("account_id", accountIds); - - if (error) { - console.error("Error selecting account_emails for template shares:", error); - throw new Error(`getSharedEmailsForTemplate failed: ${error.message}`); - } - - return Array.from( - new Set((data ?? []).map(r => r.email).filter((e): e is string => typeof e === "string")), - ); -} - -function buildCreator(row: CreatorJoin | null): AgentTemplateCreator | null { - if (!row) return null; - const image = row.account_info?.[0]?.image ?? null; - const emails = (row.account_emails ?? []) - .map(e => e.email) - .filter((e): e is string => typeof e === "string"); - const isAdmin = emails.some(email => ADMIN_EMAILS.includes(email)); - return { - id: row.id, - name: row.name ?? null, - image, - is_admin: isAdmin, - }; -} diff --git a/lib/supabase/agent_templates/selectAgentTemplate.ts b/lib/supabase/agent_templates/selectAgentTemplate.ts deleted file mode 100644 index d46229d6f..000000000 --- a/lib/supabase/agent_templates/selectAgentTemplate.ts +++ /dev/null @@ -1,28 +0,0 @@ -import supabase from "@/lib/supabase/serverClient"; -import type { Tables } from "@/types/database.types"; - -/** - * Selects a single agent template by id. - * - * Returns `null` only when the row does not exist. Database errors are thrown - * so callers can distinguish a real failure from a missing row (and surface - * a 500 instead of a false 404). - * - * @param id - The agent template UUID - * @returns The matching agent_templates row, or null if not found. - * @throws If the Supabase query fails. - */ -export async function selectAgentTemplate(id: string): Promise | null> { - const { data, error } = await supabase - .from("agent_templates") - .select("*") - .eq("id", id) - .maybeSingle(); - - if (error) { - console.error("Error selecting agent template:", error); - throw new Error(`selectAgentTemplate failed: ${error.message}`); - } - - return data; -} diff --git a/lib/supabase/agent_templates/selectAgentTemplateById.ts b/lib/supabase/agent_templates/selectAgentTemplateById.ts new file mode 100644 index 000000000..ce18afb79 --- /dev/null +++ b/lib/supabase/agent_templates/selectAgentTemplateById.ts @@ -0,0 +1,29 @@ +import supabase from "@/lib/supabase/serverClient"; +import { + AGENT_TEMPLATE_WITH_CREATOR_SELECT, + type AgentTemplateWithCreator, +} from "@/lib/supabase/agent_templates/agentTemplateWithCreatorSelect"; + +/** + * Selects a single agent template by id, joined with the creator account + * (id, name, image, admin-email markers). + * + * Returns `null` only when the row does not exist. Database errors throw so + * callers can distinguish a real failure from a missing row. + */ +export async function selectAgentTemplateById( + id: string, +): Promise { + const { data, error } = await supabase + .from("agent_templates") + .select(AGENT_TEMPLATE_WITH_CREATOR_SELECT) + .eq("id", id) + .maybeSingle(); + + if (error) { + console.error("Error selecting agent template by id:", error); + throw new Error(`selectAgentTemplateById failed: ${error.message}`); + } + + return data; +} diff --git a/lib/supabase/agent_templates/selectOwnedAndPublicAgentTemplates.ts b/lib/supabase/agent_templates/selectOwnedAndPublicAgentTemplates.ts new file mode 100644 index 000000000..0bcd86f8d --- /dev/null +++ b/lib/supabase/agent_templates/selectOwnedAndPublicAgentTemplates.ts @@ -0,0 +1,29 @@ +import supabase from "@/lib/supabase/serverClient"; +import { + AGENT_TEMPLATE_WITH_CREATOR_SELECT, + type AgentTemplateWithCreator, +} from "@/lib/supabase/agent_templates/agentTemplateWithCreatorSelect"; + +/** + * Selects every agent template the account owns or that is public, + * joined with the creator block. Throws on database error. + * + * TODO: paginate. Subject to Supabase's default 1000-row cap; as public + * templates grow this will silently truncate. + */ +export async function selectOwnedAndPublicAgentTemplates( + accountId: string, +): Promise { + const { data, error } = await supabase + .from("agent_templates") + .select(AGENT_TEMPLATE_WITH_CREATOR_SELECT) + .or(`creator.eq.${accountId},is_private.eq.false`) + .order("title"); + + if (error) { + console.error("Error selecting owned/public agent_templates:", error); + throw new Error(`selectOwnedAndPublicAgentTemplates failed: ${error.message}`); + } + + return data ?? []; +} diff --git a/lib/supabase/agent_templates/selectSharedAgentTemplates.ts b/lib/supabase/agent_templates/selectSharedAgentTemplates.ts new file mode 100644 index 000000000..4d749412b --- /dev/null +++ b/lib/supabase/agent_templates/selectSharedAgentTemplates.ts @@ -0,0 +1,44 @@ +import supabase from "@/lib/supabase/serverClient"; +import { + AGENT_TEMPLATE_WITH_CREATOR_SELECT, + type AgentTemplateWithCreator, +} from "@/lib/supabase/agent_templates/agentTemplateWithCreatorSelect"; + +/** + * Selects every agent template that has been shared with `accountId` via + * `agent_template_shares`, joined with the creator block. Throws on database + * error. + * + * The nested join is unwrapped to return `AgentTemplateWithCreator[]` rows + * directly — postgrest types the relation as an array per its + * `isOneToOne: false` foreign key, even though each share row points to a + * single template. + */ +export async function selectSharedAgentTemplates( + accountId: string, +): Promise { + const { data, error } = await supabase + .from("agent_template_shares") + .select( + `template:agent_templates!agent_template_shares_template_id_fkey ( + ${AGENT_TEMPLATE_WITH_CREATOR_SELECT} + )`, + ) + .eq("user_id", accountId); + + if (error) { + console.error("Error selecting shared agent_templates:", error); + throw new Error(`selectSharedAgentTemplates failed: ${error.message}`); + } + + const rows: AgentTemplateWithCreator[] = []; + (data ?? []).forEach(share => { + const { template } = share as { + template: AgentTemplateWithCreator | AgentTemplateWithCreator[] | null; + }; + if (!template) return; + if (Array.isArray(template)) rows.push(...template); + else rows.push(template); + }); + return rows; +} From 2067efc81dafcf19a264d35be3e9c5ab113067c8 Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Tue, 12 May 2026 01:15:24 +0530 Subject: [PATCH 05/32] refactor(api): collapse agent_templates selectors into one entry point MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit selectAgentTemplates({ id }) or selectAgentTemplates({ accessibleTo }) — single function, single SELECT, single SDK-derived row type. Validators take the [0] slice for single-row use. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../buildAgentTemplateResponse.ts | 5 +- .../getAccessibleAgentTemplatesForAccount.ts | 22 ++--- .../getAgentTemplateForAccount.ts | 17 ++-- .../validateDeleteAgentTemplateRequest.ts | 4 +- .../validateToggleFavoriteRequest.ts | 4 +- .../validateUpdateAgentTemplateRequest.ts | 4 +- .../agentTemplateWithCreatorSelect.ts | 16 ---- .../selectAgentTemplateById.ts | 29 ------- .../agent_templates/selectAgentTemplates.ts | 84 +++++++++++++++++++ .../selectOwnedAndPublicAgentTemplates.ts | 29 ------- .../selectSharedAgentTemplates.ts | 44 ---------- 11 files changed, 107 insertions(+), 151 deletions(-) delete mode 100644 lib/supabase/agent_templates/agentTemplateWithCreatorSelect.ts delete mode 100644 lib/supabase/agent_templates/selectAgentTemplateById.ts create mode 100644 lib/supabase/agent_templates/selectAgentTemplates.ts delete mode 100644 lib/supabase/agent_templates/selectOwnedAndPublicAgentTemplates.ts delete mode 100644 lib/supabase/agent_templates/selectSharedAgentTemplates.ts diff --git a/lib/agent_templates/buildAgentTemplateResponse.ts b/lib/agent_templates/buildAgentTemplateResponse.ts index 9db417830..2aa02ee1a 100644 --- a/lib/agent_templates/buildAgentTemplateResponse.ts +++ b/lib/agent_templates/buildAgentTemplateResponse.ts @@ -1,6 +1,6 @@ import type { Tables } from "@/types/database.types"; import { ADMIN_EMAILS } from "@/lib/const"; -import type { AgentTemplateWithCreator } from "@/lib/supabase/agent_templates/agentTemplateWithCreatorSelect"; +import type { AgentTemplateWithCreator } from "@/lib/supabase/agent_templates/selectAgentTemplates"; export interface AgentTemplateCreator { id: string; @@ -38,8 +38,7 @@ function buildCreator(joined: AgentTemplateWithCreator["creator"]): AgentTemplat /** * Shapes a raw joined agent template row into the API response, layering in - * caller-specific signals (`is_favourite`, `shared_emails`) that are computed - * upstream. + * caller-specific signals (`is_favourite`, `shared_emails`) computed upstream. */ export function buildAgentTemplateResponse( row: AgentTemplateWithCreator, diff --git a/lib/agent_templates/getAccessibleAgentTemplatesForAccount.ts b/lib/agent_templates/getAccessibleAgentTemplatesForAccount.ts index 9d4676dd0..e04e4c1a9 100644 --- a/lib/agent_templates/getAccessibleAgentTemplatesForAccount.ts +++ b/lib/agent_templates/getAccessibleAgentTemplatesForAccount.ts @@ -1,7 +1,6 @@ -import { selectOwnedAndPublicAgentTemplates } from "@/lib/supabase/agent_templates/selectOwnedAndPublicAgentTemplates"; -import { selectSharedAgentTemplates } from "@/lib/supabase/agent_templates/selectSharedAgentTemplates"; +import { selectAgentTemplates } from "@/lib/supabase/agent_templates/selectAgentTemplates"; +import type { AgentTemplateWithCreator } from "@/lib/supabase/agent_templates/selectAgentTemplates"; import { selectAgentTemplateFavorites } from "@/lib/supabase/agent_template_favorites/selectAgentTemplateFavorites"; -import type { AgentTemplateWithCreator } from "@/lib/supabase/agent_templates/agentTemplateWithCreatorSelect"; import { buildAgentTemplateResponse, type AgentTemplateResponse, @@ -16,26 +15,17 @@ function creatorIdOf(row: AgentTemplateWithCreator): string | null { /** * Returns every agent template visible to `accountId` (own, public, shared), - * shaped for the API response with `creator`, `is_favourite`, and (for - * private templates the caller owns) `shared_emails` populated. - * - * Sharees never see `shared_emails` — only the template's creator does. + * shaped for the API with `creator`, `is_favourite`, and `shared_emails` + * (only when the caller is the template's creator) populated. */ export async function getAccessibleAgentTemplatesForAccount( accountId: string, ): Promise { - const [ownedAndPublic, shared, favorites] = await Promise.all([ - selectOwnedAndPublicAgentTemplates(accountId), - selectSharedAgentTemplates(accountId), + const [rows, favorites] = await Promise.all([ + selectAgentTemplates({ accessibleTo: accountId }), selectAgentTemplateFavorites(accountId), ]); - const byId = new Map(); - [...ownedAndPublic, ...shared].forEach(row => { - if (!byId.has(row.id)) byId.set(row.id, row); - }); - const rows = Array.from(byId.values()); - const favoriteIds = new Set(favorites.map(f => f.template_id)); const ownedPrivateIds = rows .filter(r => r.is_private && creatorIdOf(r) === accountId) diff --git a/lib/agent_templates/getAgentTemplateForAccount.ts b/lib/agent_templates/getAgentTemplateForAccount.ts index 5338b15e4..e8c5e16d3 100644 --- a/lib/agent_templates/getAgentTemplateForAccount.ts +++ b/lib/agent_templates/getAgentTemplateForAccount.ts @@ -1,4 +1,4 @@ -import { selectAgentTemplateById } from "@/lib/supabase/agent_templates/selectAgentTemplateById"; +import { selectAgentTemplates } from "@/lib/supabase/agent_templates/selectAgentTemplates"; import { selectAgentTemplateFavorites } from "@/lib/supabase/agent_template_favorites/selectAgentTemplateFavorites"; import { buildAgentTemplateResponse, @@ -7,7 +7,7 @@ import { import { resolveSharedEmailsByTemplateId } from "@/lib/agent_templates/resolveSharedEmailsByTemplateId"; /** - * Fetches a single agent template by id, shaped for the API response with + * Fetches a single agent template by id, shaped for the API with * `is_favourite` (for `accountId`) and `shared_emails` (only when the caller * is the template's creator) populated. Returns `null` when the template * does not exist. @@ -16,16 +16,17 @@ export async function getAgentTemplateForAccount( templateId: string, accountId: string, ): Promise { - const row = await selectAgentTemplateById(templateId); + const [rows, favorites] = await Promise.all([ + selectAgentTemplates({ id: templateId }), + selectAgentTemplateFavorites(accountId), + ]); + const row = rows[0]; if (!row) return null; const creator = Array.isArray(row.creator) ? row.creator[0] : row.creator; const isOwner = creator?.id === accountId; - - const [favorites, sharedEmailsMap] = await Promise.all([ - selectAgentTemplateFavorites(accountId), - row.is_private && isOwner ? resolveSharedEmailsByTemplateId([row.id]) : Promise.resolve({}), - ]); + const sharedEmailsMap = + row.is_private && isOwner ? await resolveSharedEmailsByTemplateId([row.id]) : {}; return buildAgentTemplateResponse(row, { isFavourite: favorites.some(f => f.template_id === row.id), diff --git a/lib/agent_templates/validateDeleteAgentTemplateRequest.ts b/lib/agent_templates/validateDeleteAgentTemplateRequest.ts index 5e26f2174..c7dd4faa8 100644 --- a/lib/agent_templates/validateDeleteAgentTemplateRequest.ts +++ b/lib/agent_templates/validateDeleteAgentTemplateRequest.ts @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { validateAccountParams } from "@/lib/accounts/validateAccountParams"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { selectAgentTemplateById } from "@/lib/supabase/agent_templates/selectAgentTemplateById"; +import { selectAgentTemplates } from "@/lib/supabase/agent_templates/selectAgentTemplates"; export interface ValidatedDeleteAgentTemplateRequest { templateId: string; @@ -26,7 +26,7 @@ export async function validateDeleteAgentTemplateRequest( const templateId = validatedParams.id; const accountId = authResult.accountId; - const existing = await selectAgentTemplateById(templateId); + const [existing] = await selectAgentTemplates({ id: templateId }); if (!existing) { return NextResponse.json( { status: "error", error: "Agent template not found" }, diff --git a/lib/agent_templates/validateToggleFavoriteRequest.ts b/lib/agent_templates/validateToggleFavoriteRequest.ts index 32b9f096b..ff410eec7 100644 --- a/lib/agent_templates/validateToggleFavoriteRequest.ts +++ b/lib/agent_templates/validateToggleFavoriteRequest.ts @@ -4,7 +4,7 @@ import { validateAccountParams } from "@/lib/accounts/validateAccountParams"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { safeParseJson } from "@/lib/networking/safeParseJson"; -import { selectAgentTemplateById } from "@/lib/supabase/agent_templates/selectAgentTemplateById"; +import { selectAgentTemplates } from "@/lib/supabase/agent_templates/selectAgentTemplates"; import { selectAgentTemplateShares } from "@/lib/supabase/agent_template_shares/selectAgentTemplateShares"; export const toggleFavoriteBodySchema = z.object({ @@ -50,7 +50,7 @@ export async function validateToggleFavoriteRequest( const templateId = validatedParams.id; const accountId = authResult.accountId; - const existing = await selectAgentTemplateById(templateId); + const [existing] = await selectAgentTemplates({ id: templateId }); if (!existing) { return NextResponse.json( { status: "error", error: "Agent template not found" }, diff --git a/lib/agent_templates/validateUpdateAgentTemplateRequest.ts b/lib/agent_templates/validateUpdateAgentTemplateRequest.ts index 901c25986..d09bae779 100644 --- a/lib/agent_templates/validateUpdateAgentTemplateRequest.ts +++ b/lib/agent_templates/validateUpdateAgentTemplateRequest.ts @@ -4,7 +4,7 @@ import { validateAccountParams } from "@/lib/accounts/validateAccountParams"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { safeParseJson } from "@/lib/networking/safeParseJson"; -import { selectAgentTemplateById } from "@/lib/supabase/agent_templates/selectAgentTemplateById"; +import { selectAgentTemplates } from "@/lib/supabase/agent_templates/selectAgentTemplates"; export const updateAgentTemplateBodySchema = z .object({ @@ -58,7 +58,7 @@ export async function validateUpdateAgentTemplateRequest( const templateId = validatedParams.id; const accountId = authResult.accountId; - const existing = await selectAgentTemplateById(templateId); + const [existing] = await selectAgentTemplates({ id: templateId }); if (!existing) { return NextResponse.json( { status: "error", error: "Agent template not found" }, diff --git a/lib/supabase/agent_templates/agentTemplateWithCreatorSelect.ts b/lib/supabase/agent_templates/agentTemplateWithCreatorSelect.ts deleted file mode 100644 index 82af50469..000000000 --- a/lib/supabase/agent_templates/agentTemplateWithCreatorSelect.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { QueryData } from "@supabase/supabase-js"; -import supabase from "@/lib/supabase/serverClient"; - -export const AGENT_TEMPLATE_WITH_CREATOR_SELECT = ` - *, - creator:accounts!agent_templates_creator_fkey ( - id, - name, - account_info ( image ), - account_emails ( email ) - ) -` as const; - -const _typedQuery = supabase.from("agent_templates").select(AGENT_TEMPLATE_WITH_CREATOR_SELECT); - -export type AgentTemplateWithCreator = QueryData[number]; diff --git a/lib/supabase/agent_templates/selectAgentTemplateById.ts b/lib/supabase/agent_templates/selectAgentTemplateById.ts deleted file mode 100644 index ce18afb79..000000000 --- a/lib/supabase/agent_templates/selectAgentTemplateById.ts +++ /dev/null @@ -1,29 +0,0 @@ -import supabase from "@/lib/supabase/serverClient"; -import { - AGENT_TEMPLATE_WITH_CREATOR_SELECT, - type AgentTemplateWithCreator, -} from "@/lib/supabase/agent_templates/agentTemplateWithCreatorSelect"; - -/** - * Selects a single agent template by id, joined with the creator account - * (id, name, image, admin-email markers). - * - * Returns `null` only when the row does not exist. Database errors throw so - * callers can distinguish a real failure from a missing row. - */ -export async function selectAgentTemplateById( - id: string, -): Promise { - const { data, error } = await supabase - .from("agent_templates") - .select(AGENT_TEMPLATE_WITH_CREATOR_SELECT) - .eq("id", id) - .maybeSingle(); - - if (error) { - console.error("Error selecting agent template by id:", error); - throw new Error(`selectAgentTemplateById failed: ${error.message}`); - } - - return data; -} diff --git a/lib/supabase/agent_templates/selectAgentTemplates.ts b/lib/supabase/agent_templates/selectAgentTemplates.ts new file mode 100644 index 000000000..eb517ef7d --- /dev/null +++ b/lib/supabase/agent_templates/selectAgentTemplates.ts @@ -0,0 +1,84 @@ +import type { QueryData } from "@supabase/supabase-js"; +import supabase from "@/lib/supabase/serverClient"; + +const SELECT = ` + *, + creator:accounts!agent_templates_creator_fkey ( + id, + name, + account_info ( image ), + account_emails ( email ) + ) +` as const; + +const _typedQuery = supabase.from("agent_templates").select(SELECT); + +export type AgentTemplateWithCreator = QueryData[number]; + +type SelectAgentTemplatesParams = { id: string } | { accessibleTo: string }; + +/** + * Single entry point for reading agent_templates joined with the creator + * block (id, name, image, admin-email markers). + * + * - `{ id }` returns the row with that id, or empty when not found. + * - `{ accessibleTo }` returns every row the account can see: ones they + * own, public ones, and ones shared with them via agent_template_shares + * — deduped. + * + * Throws on database error so callers can distinguish a real failure from + * an empty result. + */ +export async function selectAgentTemplates( + params: SelectAgentTemplatesParams, +): Promise { + if ("id" in params) { + const { data, error } = await supabase + .from("agent_templates") + .select(SELECT) + .eq("id", params.id); + if (error) { + console.error("Error selecting agent_template by id:", error); + throw new Error(`selectAgentTemplates(id) failed: ${error.message}`); + } + return data ?? []; + } + + const accountId = params.accessibleTo; + const [ownedAndPublic, shared] = await Promise.all([ + supabase + .from("agent_templates") + .select(SELECT) + .or(`creator.eq.${accountId},is_private.eq.false`) + .order("title"), + supabase + .from("agent_template_shares") + .select(`template:agent_templates!agent_template_shares_template_id_fkey (${SELECT})`) + .eq("user_id", accountId), + ]); + + if (ownedAndPublic.error) { + console.error("Error selecting owned/public agent_templates:", ownedAndPublic.error); + throw new Error( + `selectAgentTemplates(accessibleTo) owned/public failed: ${ownedAndPublic.error.message}`, + ); + } + if (shared.error) { + console.error("Error selecting shared agent_templates:", shared.error); + throw new Error(`selectAgentTemplates(accessibleTo) shared failed: ${shared.error.message}`); + } + + const byId = new Map(); + (ownedAndPublic.data ?? []).forEach(row => byId.set(row.id, row)); + (shared.data ?? []).forEach(share => { + const { template } = share as { + template: AgentTemplateWithCreator | AgentTemplateWithCreator[] | null; + }; + if (!template) return; + const list = Array.isArray(template) ? template : [template]; + list.forEach(t => { + if (t && !byId.has(t.id)) byId.set(t.id, t); + }); + }); + return Array.from(byId.values()); +} diff --git a/lib/supabase/agent_templates/selectOwnedAndPublicAgentTemplates.ts b/lib/supabase/agent_templates/selectOwnedAndPublicAgentTemplates.ts deleted file mode 100644 index 0bcd86f8d..000000000 --- a/lib/supabase/agent_templates/selectOwnedAndPublicAgentTemplates.ts +++ /dev/null @@ -1,29 +0,0 @@ -import supabase from "@/lib/supabase/serverClient"; -import { - AGENT_TEMPLATE_WITH_CREATOR_SELECT, - type AgentTemplateWithCreator, -} from "@/lib/supabase/agent_templates/agentTemplateWithCreatorSelect"; - -/** - * Selects every agent template the account owns or that is public, - * joined with the creator block. Throws on database error. - * - * TODO: paginate. Subject to Supabase's default 1000-row cap; as public - * templates grow this will silently truncate. - */ -export async function selectOwnedAndPublicAgentTemplates( - accountId: string, -): Promise { - const { data, error } = await supabase - .from("agent_templates") - .select(AGENT_TEMPLATE_WITH_CREATOR_SELECT) - .or(`creator.eq.${accountId},is_private.eq.false`) - .order("title"); - - if (error) { - console.error("Error selecting owned/public agent_templates:", error); - throw new Error(`selectOwnedAndPublicAgentTemplates failed: ${error.message}`); - } - - return data ?? []; -} diff --git a/lib/supabase/agent_templates/selectSharedAgentTemplates.ts b/lib/supabase/agent_templates/selectSharedAgentTemplates.ts deleted file mode 100644 index 4d749412b..000000000 --- a/lib/supabase/agent_templates/selectSharedAgentTemplates.ts +++ /dev/null @@ -1,44 +0,0 @@ -import supabase from "@/lib/supabase/serverClient"; -import { - AGENT_TEMPLATE_WITH_CREATOR_SELECT, - type AgentTemplateWithCreator, -} from "@/lib/supabase/agent_templates/agentTemplateWithCreatorSelect"; - -/** - * Selects every agent template that has been shared with `accountId` via - * `agent_template_shares`, joined with the creator block. Throws on database - * error. - * - * The nested join is unwrapped to return `AgentTemplateWithCreator[]` rows - * directly — postgrest types the relation as an array per its - * `isOneToOne: false` foreign key, even though each share row points to a - * single template. - */ -export async function selectSharedAgentTemplates( - accountId: string, -): Promise { - const { data, error } = await supabase - .from("agent_template_shares") - .select( - `template:agent_templates!agent_template_shares_template_id_fkey ( - ${AGENT_TEMPLATE_WITH_CREATOR_SELECT} - )`, - ) - .eq("user_id", accountId); - - if (error) { - console.error("Error selecting shared agent_templates:", error); - throw new Error(`selectSharedAgentTemplates failed: ${error.message}`); - } - - const rows: AgentTemplateWithCreator[] = []; - (data ?? []).forEach(share => { - const { template } = share as { - template: AgentTemplateWithCreator | AgentTemplateWithCreator[] | null; - }; - if (!template) return; - if (Array.isArray(template)) rows.push(...template); - else rows.push(template); - }); - return rows; -} From 31f9bc3842c5a9808b16626ef3130393643c9677 Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Tue, 12 May 2026 01:24:29 +0530 Subject: [PATCH 06/32] refactor(api): inline list orchestration into the handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getAccessibleAgentTemplatesForAccount had a single caller and added nothing beyond a function call — fold its composition (selectAgentTemplates accessibleTo + favorites + shared emails for owned-private + buildAgentTemplateResponse) directly into listAgentTemplatesHandler. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../listAgentTemplatesHandler.test.ts | 67 ++++++++++++++++--- .../getAccessibleAgentTemplatesForAccount.ts | 42 ------------ .../listAgentTemplatesHandler.ts | 36 +++++++++- 3 files changed, 91 insertions(+), 54 deletions(-) delete mode 100644 lib/agent_templates/getAccessibleAgentTemplatesForAccount.ts diff --git a/lib/agent_templates/__tests__/listAgentTemplatesHandler.test.ts b/lib/agent_templates/__tests__/listAgentTemplatesHandler.test.ts index 021f9a3c6..a10d95715 100644 --- a/lib/agent_templates/__tests__/listAgentTemplatesHandler.test.ts +++ b/lib/agent_templates/__tests__/listAgentTemplatesHandler.test.ts @@ -9,14 +9,28 @@ vi.mock("@/lib/auth/validateAuthContext", () => ({ validateAuthContext: vi.fn(), })); -vi.mock("@/lib/agent_templates/getAccessibleAgentTemplatesForAccount", () => ({ - getAccessibleAgentTemplatesForAccount: vi.fn(), +vi.mock("@/lib/supabase/agent_templates/selectAgentTemplates", () => ({ + selectAgentTemplates: vi.fn(), +})); + +vi.mock("@/lib/supabase/agent_template_favorites/selectAgentTemplateFavorites", () => ({ + selectAgentTemplateFavorites: vi.fn(), +})); + +vi.mock("@/lib/agent_templates/resolveSharedEmailsByTemplateId", () => ({ + resolveSharedEmailsByTemplateId: vi.fn(), })); const { listAgentTemplatesHandler } = await import("../listAgentTemplatesHandler"); const { validateAuthContext } = await import("@/lib/auth/validateAuthContext"); -const { getAccessibleAgentTemplatesForAccount } = await import( - "@/lib/agent_templates/getAccessibleAgentTemplatesForAccount" +const { selectAgentTemplates } = await import( + "@/lib/supabase/agent_templates/selectAgentTemplates" +); +const { selectAgentTemplateFavorites } = await import( + "@/lib/supabase/agent_template_favorites/selectAgentTemplateFavorites" +); +const { resolveSharedEmailsByTemplateId } = await import( + "@/lib/agent_templates/resolveSharedEmailsByTemplateId" ); const ACCOUNT_ID = "11111111-1111-1111-1111-111111111111"; @@ -24,23 +38,56 @@ const ACCOUNT_ID = "11111111-1111-1111-1111-111111111111"; describe("listAgentTemplatesHandler", () => { beforeEach(() => vi.clearAllMocks()); - it("returns templates for the authenticated account", async () => { + it("returns shaped templates with is_favourite + shared_emails for the authenticated account", async () => { vi.mocked(validateAuthContext).mockResolvedValue({ accountId: ACCOUNT_ID, orgId: null, authToken: "k", }); - vi.mocked(getAccessibleAgentTemplatesForAccount).mockResolvedValue([ - { id: "t1", title: "T", shared_emails: [] } as never, + vi.mocked(selectAgentTemplates).mockResolvedValue([ + { + id: "t1", + title: "Public", + description: "", + prompt: "", + tags: [], + is_private: false, + favorites_count: 0, + created_at: "2026-01-01", + updated_at: null, + creator: { id: "other", name: null, account_info: [], account_emails: [] }, + } as never, + { + id: "t2", + title: "Mine", + description: "", + prompt: "", + tags: [], + is_private: true, + favorites_count: 0, + created_at: "2026-01-01", + updated_at: null, + creator: { id: ACCOUNT_ID, name: null, account_info: [], account_emails: [] }, + } as never, + ]); + vi.mocked(selectAgentTemplateFavorites).mockResolvedValue([ + { template_id: "t1", user_id: ACCOUNT_ID, created_at: null }, ]); + vi.mocked(resolveSharedEmailsByTemplateId).mockResolvedValue({ t2: ["a@x.com"] }); const res = await listAgentTemplatesHandler( new NextRequest("http://localhost/api/agent-templates", { headers: { "x-api-key": "k" } }), ); expect(res.status).toBe(200); - expect((await res.json()).templates).toHaveLength(1); - expect(getAccessibleAgentTemplatesForAccount).toHaveBeenCalledWith(ACCOUNT_ID); + const body = await res.json(); + expect(body.templates).toHaveLength(2); + expect(body.templates[0].is_favourite).toBe(true); + expect(body.templates[0].shared_emails).toEqual([]); + expect(body.templates[1].is_favourite).toBe(false); + expect(body.templates[1].shared_emails).toEqual(["a@x.com"]); + expect(selectAgentTemplates).toHaveBeenCalledWith({ accessibleTo: ACCOUNT_ID }); + expect(resolveSharedEmailsByTemplateId).toHaveBeenCalledWith(["t2"]); }); it("returns the auth NextResponse when authentication fails", async () => { @@ -52,6 +99,6 @@ describe("listAgentTemplatesHandler", () => { ); expect(res).toBe(failure); - expect(getAccessibleAgentTemplatesForAccount).not.toHaveBeenCalled(); + expect(selectAgentTemplates).not.toHaveBeenCalled(); }); }); diff --git a/lib/agent_templates/getAccessibleAgentTemplatesForAccount.ts b/lib/agent_templates/getAccessibleAgentTemplatesForAccount.ts deleted file mode 100644 index e04e4c1a9..000000000 --- a/lib/agent_templates/getAccessibleAgentTemplatesForAccount.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { selectAgentTemplates } from "@/lib/supabase/agent_templates/selectAgentTemplates"; -import type { AgentTemplateWithCreator } from "@/lib/supabase/agent_templates/selectAgentTemplates"; -import { selectAgentTemplateFavorites } from "@/lib/supabase/agent_template_favorites/selectAgentTemplateFavorites"; -import { - buildAgentTemplateResponse, - type AgentTemplateResponse, -} from "@/lib/agent_templates/buildAgentTemplateResponse"; -import { resolveSharedEmailsByTemplateId } from "@/lib/agent_templates/resolveSharedEmailsByTemplateId"; - -function creatorIdOf(row: AgentTemplateWithCreator): string | null { - const c = row.creator; - if (!c) return null; - return Array.isArray(c) ? (c[0]?.id ?? null) : c.id; -} - -/** - * Returns every agent template visible to `accountId` (own, public, shared), - * shaped for the API with `creator`, `is_favourite`, and `shared_emails` - * (only when the caller is the template's creator) populated. - */ -export async function getAccessibleAgentTemplatesForAccount( - accountId: string, -): Promise { - const [rows, favorites] = await Promise.all([ - selectAgentTemplates({ accessibleTo: accountId }), - selectAgentTemplateFavorites(accountId), - ]); - - const favoriteIds = new Set(favorites.map(f => f.template_id)); - const ownedPrivateIds = rows - .filter(r => r.is_private && creatorIdOf(r) === accountId) - .map(r => r.id); - const sharedEmailsMap = await resolveSharedEmailsByTemplateId(ownedPrivateIds); - - return rows.map(row => - buildAgentTemplateResponse(row, { - isFavourite: favoriteIds.has(row.id), - sharedEmails: - row.is_private && creatorIdOf(row) === accountId ? (sharedEmailsMap[row.id] ?? []) : [], - }), - ); -} diff --git a/lib/agent_templates/listAgentTemplatesHandler.ts b/lib/agent_templates/listAgentTemplatesHandler.ts index 22bb61a78..520d1a691 100644 --- a/lib/agent_templates/listAgentTemplatesHandler.ts +++ b/lib/agent_templates/listAgentTemplatesHandler.ts @@ -1,20 +1,52 @@ import { NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; -import { getAccessibleAgentTemplatesForAccount } from "@/lib/agent_templates/getAccessibleAgentTemplatesForAccount"; +import { + selectAgentTemplates, + type AgentTemplateWithCreator, +} from "@/lib/supabase/agent_templates/selectAgentTemplates"; +import { selectAgentTemplateFavorites } from "@/lib/supabase/agent_template_favorites/selectAgentTemplateFavorites"; +import { buildAgentTemplateResponse } from "@/lib/agent_templates/buildAgentTemplateResponse"; +import { resolveSharedEmailsByTemplateId } from "@/lib/agent_templates/resolveSharedEmailsByTemplateId"; + +function creatorIdOf(row: AgentTemplateWithCreator): string | null { + const c = row.creator; + if (!c) return null; + return Array.isArray(c) ? (c[0]?.id ?? null) : c.id; +} /** * Handler for GET /api/agent-templates. * * Returns every agent template the authenticated account can see (own, public, * shared) with `creator`, `is_favourite`, and `shared_emails` embedded. + * `shared_emails` is only populated for private templates the caller owns. */ export async function listAgentTemplatesHandler(request: NextRequest): Promise { try { const authResult = await validateAuthContext(request); if (authResult instanceof NextResponse) return authResult; - const templates = await getAccessibleAgentTemplatesForAccount(authResult.accountId); + const accountId = authResult.accountId; + + const [rows, favorites] = await Promise.all([ + selectAgentTemplates({ accessibleTo: accountId }), + selectAgentTemplateFavorites(accountId), + ]); + + const favoriteIds = new Set(favorites.map(f => f.template_id)); + const ownedPrivateIds = rows + .filter(r => r.is_private && creatorIdOf(r) === accountId) + .map(r => r.id); + const sharedEmailsMap = await resolveSharedEmailsByTemplateId(ownedPrivateIds); + + const templates = rows.map(row => + buildAgentTemplateResponse(row, { + isFavourite: favoriteIds.has(row.id), + sharedEmails: + row.is_private && creatorIdOf(row) === accountId ? (sharedEmailsMap[row.id] ?? []) : [], + }), + ); return NextResponse.json( { status: "success", templates }, From 550cd8925d5868d9cd6c2673eaf5edf1eaf4fe89 Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Tue, 12 May 2026 01:49:35 +0530 Subject: [PATCH 07/32] refactor(api): collapse shapers into selectAgentTemplates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit selectAgentTemplates now takes an optional forAccountId and returns rows fully shaped for the API: creator flattened with is_admin, is_favourite marked per the account, and shared_emails populated for owned-private templates. Validators omit the param so they skip the favorites/shares queries they don't need. Drops three helpers — buildAgentTemplateResponse, getAgentTemplateForAccount, resolveSharedEmailsByTemplateId — and the Array.isArray(creator) dance. Handlers shrink to one call each. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../createAgentTemplateHandler.test.ts | 11 +- .../listAgentTemplatesHandler.test.ts | 55 +------ .../updateAgentTemplateHandler.test.ts | 11 +- .../buildAgentTemplateResponse.ts | 54 ------- .../createAgentTemplateHandler.ts | 6 +- .../getAgentTemplateForAccount.ts | 35 ----- .../listAgentTemplatesHandler.ts | 41 +----- .../resolveSharedEmailsByTemplateId.ts | 40 ------ .../updateAgentTemplateHandler.ts | 16 +-- .../validateDeleteAgentTemplateRequest.ts | 3 +- .../validateToggleFavoriteRequest.ts | 3 +- .../validateUpdateAgentTemplateRequest.ts | 3 +- .../agent_templates/selectAgentTemplates.ts | 134 +++++++++++++++--- 13 files changed, 148 insertions(+), 264 deletions(-) delete mode 100644 lib/agent_templates/buildAgentTemplateResponse.ts delete mode 100644 lib/agent_templates/getAgentTemplateForAccount.ts delete mode 100644 lib/agent_templates/resolveSharedEmailsByTemplateId.ts diff --git a/lib/agent_templates/__tests__/createAgentTemplateHandler.test.ts b/lib/agent_templates/__tests__/createAgentTemplateHandler.test.ts index 371dfa361..6fc200b14 100644 --- a/lib/agent_templates/__tests__/createAgentTemplateHandler.test.ts +++ b/lib/agent_templates/__tests__/createAgentTemplateHandler.test.ts @@ -17,8 +17,8 @@ vi.mock("@/lib/supabase/agent_template_shares/insertAgentTemplateShares", () => insertAgentTemplateShares: vi.fn(), })); -vi.mock("@/lib/agent_templates/getAgentTemplateForAccount", () => ({ - getAgentTemplateForAccount: vi.fn(), +vi.mock("@/lib/supabase/agent_templates/selectAgentTemplates", () => ({ + selectAgentTemplates: vi.fn(), })); const { createAgentTemplateHandler } = await import("../createAgentTemplateHandler"); @@ -27,8 +27,8 @@ const { insertAgentTemplate } = await import("@/lib/supabase/agent_templates/ins const { insertAgentTemplateShares } = await import( "@/lib/supabase/agent_template_shares/insertAgentTemplateShares" ); -const { getAgentTemplateForAccount } = await import( - "@/lib/agent_templates/getAgentTemplateForAccount" +const { selectAgentTemplates } = await import( + "@/lib/supabase/agent_templates/selectAgentTemplates" ); const ACCOUNT_ID = "11111111-1111-1111-1111-111111111111"; @@ -64,7 +64,7 @@ describe("createAgentTemplateHandler", () => { mockAuthOk(); vi.mocked(insertAgentTemplate).mockResolvedValue({ id: TEMPLATE_ID } as never); vi.mocked(insertAgentTemplateShares).mockResolvedValue(1); - vi.mocked(getAgentTemplateForAccount).mockResolvedValue({ id: TEMPLATE_ID } as never); + vi.mocked(selectAgentTemplates).mockResolvedValue([{ id: TEMPLATE_ID } as never]); const res = await createAgentTemplateHandler( makeRequest({ ...validBody, is_private: true, share_emails: ["a@x.com"] }), @@ -76,6 +76,7 @@ describe("createAgentTemplateHandler", () => { expect.objectContaining({ creator: ACCOUNT_ID, is_private: true }), ); expect(insertAgentTemplateShares).toHaveBeenCalledWith(TEMPLATE_ID, ["a@x.com"]); + expect(selectAgentTemplates).toHaveBeenCalledWith({ id: TEMPLATE_ID }, ACCOUNT_ID); }); it("returns 400 when validation fails", async () => { diff --git a/lib/agent_templates/__tests__/listAgentTemplatesHandler.test.ts b/lib/agent_templates/__tests__/listAgentTemplatesHandler.test.ts index a10d95715..1248dc029 100644 --- a/lib/agent_templates/__tests__/listAgentTemplatesHandler.test.ts +++ b/lib/agent_templates/__tests__/listAgentTemplatesHandler.test.ts @@ -13,81 +13,34 @@ vi.mock("@/lib/supabase/agent_templates/selectAgentTemplates", () => ({ selectAgentTemplates: vi.fn(), })); -vi.mock("@/lib/supabase/agent_template_favorites/selectAgentTemplateFavorites", () => ({ - selectAgentTemplateFavorites: vi.fn(), -})); - -vi.mock("@/lib/agent_templates/resolveSharedEmailsByTemplateId", () => ({ - resolveSharedEmailsByTemplateId: vi.fn(), -})); - const { listAgentTemplatesHandler } = await import("../listAgentTemplatesHandler"); const { validateAuthContext } = await import("@/lib/auth/validateAuthContext"); const { selectAgentTemplates } = await import( "@/lib/supabase/agent_templates/selectAgentTemplates" ); -const { selectAgentTemplateFavorites } = await import( - "@/lib/supabase/agent_template_favorites/selectAgentTemplateFavorites" -); -const { resolveSharedEmailsByTemplateId } = await import( - "@/lib/agent_templates/resolveSharedEmailsByTemplateId" -); const ACCOUNT_ID = "11111111-1111-1111-1111-111111111111"; describe("listAgentTemplatesHandler", () => { beforeEach(() => vi.clearAllMocks()); - it("returns shaped templates with is_favourite + shared_emails for the authenticated account", async () => { + it("returns templates fetched via selectAgentTemplates for the authenticated account", async () => { vi.mocked(validateAuthContext).mockResolvedValue({ accountId: ACCOUNT_ID, orgId: null, authToken: "k", }); vi.mocked(selectAgentTemplates).mockResolvedValue([ - { - id: "t1", - title: "Public", - description: "", - prompt: "", - tags: [], - is_private: false, - favorites_count: 0, - created_at: "2026-01-01", - updated_at: null, - creator: { id: "other", name: null, account_info: [], account_emails: [] }, - } as never, - { - id: "t2", - title: "Mine", - description: "", - prompt: "", - tags: [], - is_private: true, - favorites_count: 0, - created_at: "2026-01-01", - updated_at: null, - creator: { id: ACCOUNT_ID, name: null, account_info: [], account_emails: [] }, - } as never, - ]); - vi.mocked(selectAgentTemplateFavorites).mockResolvedValue([ - { template_id: "t1", user_id: ACCOUNT_ID, created_at: null }, + { id: "t1", is_favourite: true, shared_emails: [] } as never, ]); - vi.mocked(resolveSharedEmailsByTemplateId).mockResolvedValue({ t2: ["a@x.com"] }); const res = await listAgentTemplatesHandler( new NextRequest("http://localhost/api/agent-templates", { headers: { "x-api-key": "k" } }), ); expect(res.status).toBe(200); - const body = await res.json(); - expect(body.templates).toHaveLength(2); - expect(body.templates[0].is_favourite).toBe(true); - expect(body.templates[0].shared_emails).toEqual([]); - expect(body.templates[1].is_favourite).toBe(false); - expect(body.templates[1].shared_emails).toEqual(["a@x.com"]); - expect(selectAgentTemplates).toHaveBeenCalledWith({ accessibleTo: ACCOUNT_ID }); - expect(resolveSharedEmailsByTemplateId).toHaveBeenCalledWith(["t2"]); + expect((await res.json()).templates).toHaveLength(1); + expect(selectAgentTemplates).toHaveBeenCalledWith({ accessibleTo: ACCOUNT_ID }, ACCOUNT_ID); }); it("returns the auth NextResponse when authentication fails", async () => { diff --git a/lib/agent_templates/__tests__/updateAgentTemplateHandler.test.ts b/lib/agent_templates/__tests__/updateAgentTemplateHandler.test.ts index 223979315..f6e07ae4c 100644 --- a/lib/agent_templates/__tests__/updateAgentTemplateHandler.test.ts +++ b/lib/agent_templates/__tests__/updateAgentTemplateHandler.test.ts @@ -21,8 +21,8 @@ vi.mock("@/lib/supabase/agent_template_shares/insertAgentTemplateShares", () => insertAgentTemplateShares: vi.fn(), })); -vi.mock("@/lib/agent_templates/getAgentTemplateForAccount", () => ({ - getAgentTemplateForAccount: vi.fn(), +vi.mock("@/lib/supabase/agent_templates/selectAgentTemplates", () => ({ + selectAgentTemplates: vi.fn(), })); const { updateAgentTemplateHandler } = await import("../updateAgentTemplateHandler"); @@ -36,8 +36,8 @@ const { deleteAgentTemplateShares } = await import( const { insertAgentTemplateShares } = await import( "@/lib/supabase/agent_template_shares/insertAgentTemplateShares" ); -const { getAgentTemplateForAccount } = await import( - "@/lib/agent_templates/getAgentTemplateForAccount" +const { selectAgentTemplates } = await import( + "@/lib/supabase/agent_templates/selectAgentTemplates" ); const ACCOUNT_ID = "11111111-1111-1111-1111-111111111111"; @@ -55,7 +55,7 @@ describe("updateAgentTemplateHandler", () => { vi.mocked(updateAgentTemplate).mockResolvedValue({ id: TEMPLATE_ID } as never); vi.mocked(deleteAgentTemplateShares).mockResolvedValue(undefined); vi.mocked(insertAgentTemplateShares).mockResolvedValue(1); - vi.mocked(getAgentTemplateForAccount).mockResolvedValue({ id: TEMPLATE_ID } as never); + vi.mocked(selectAgentTemplates).mockResolvedValue([{ id: TEMPLATE_ID } as never]); const req = new NextRequest(`http://localhost/api/agent-templates/${TEMPLATE_ID}`, { method: "PATCH", @@ -66,6 +66,7 @@ describe("updateAgentTemplateHandler", () => { expect(updateAgentTemplate).toHaveBeenCalledWith(TEMPLATE_ID, { title: "New Title" }); expect(deleteAgentTemplateShares).toHaveBeenCalledWith(TEMPLATE_ID); expect(insertAgentTemplateShares).toHaveBeenCalledWith(TEMPLATE_ID, ["x@y.com"]); + expect(selectAgentTemplates).toHaveBeenCalledWith({ id: TEMPLATE_ID }, ACCOUNT_ID); }); it("returns the validator error response when validation fails", async () => { diff --git a/lib/agent_templates/buildAgentTemplateResponse.ts b/lib/agent_templates/buildAgentTemplateResponse.ts deleted file mode 100644 index 2aa02ee1a..000000000 --- a/lib/agent_templates/buildAgentTemplateResponse.ts +++ /dev/null @@ -1,54 +0,0 @@ -import type { Tables } from "@/types/database.types"; -import { ADMIN_EMAILS } from "@/lib/const"; -import type { AgentTemplateWithCreator } from "@/lib/supabase/agent_templates/selectAgentTemplates"; - -export interface AgentTemplateCreator { - id: string; - name: string | null; - image: string | null; - is_admin: boolean; -} - -export type AgentTemplateResponse = Omit, "creator"> & { - creator: AgentTemplateCreator | null; - is_favourite: boolean; - shared_emails: string[]; -}; - -/** - * Flattens the joined creator block and computes `is_admin` by intersecting - * the creator's emails with `ADMIN_EMAILS`. - */ -function buildCreator(joined: AgentTemplateWithCreator["creator"]): AgentTemplateCreator | null { - if (!joined) return null; - const row = Array.isArray(joined) ? joined[0] : joined; - if (!row) return null; - - const emails = (row.account_emails ?? []) - .map(e => e.email) - .filter((e): e is string => typeof e === "string"); - - return { - id: row.id, - name: row.name ?? null, - image: row.account_info?.[0]?.image ?? null, - is_admin: emails.some(email => ADMIN_EMAILS.includes(email)), - }; -} - -/** - * Shapes a raw joined agent template row into the API response, layering in - * caller-specific signals (`is_favourite`, `shared_emails`) computed upstream. - */ -export function buildAgentTemplateResponse( - row: AgentTemplateWithCreator, - args: { isFavourite: boolean; sharedEmails: string[] }, -): AgentTemplateResponse { - const { creator, ...rest } = row; - return { - ...rest, - creator: buildCreator(creator), - is_favourite: args.isFavourite, - shared_emails: args.sharedEmails, - }; -} diff --git a/lib/agent_templates/createAgentTemplateHandler.ts b/lib/agent_templates/createAgentTemplateHandler.ts index 0ba4ad70d..7e534dc92 100644 --- a/lib/agent_templates/createAgentTemplateHandler.ts +++ b/lib/agent_templates/createAgentTemplateHandler.ts @@ -5,7 +5,7 @@ import { safeParseJson } from "@/lib/networking/safeParseJson"; import { validateCreateAgentTemplateBody } from "@/lib/agent_templates/validateCreateAgentTemplateBody"; import { insertAgentTemplate } from "@/lib/supabase/agent_templates/insertAgentTemplate"; import { insertAgentTemplateShares } from "@/lib/supabase/agent_template_shares/insertAgentTemplateShares"; -import { getAgentTemplateForAccount } from "@/lib/agent_templates/getAgentTemplateForAccount"; +import { selectAgentTemplates } from "@/lib/supabase/agent_templates/selectAgentTemplates"; /** * Handler for POST /api/agent-templates. @@ -45,10 +45,10 @@ export async function createAgentTemplateHandler(request: NextRequest): Promise< await insertAgentTemplateShares(inserted.id, parsedBody.share_emails); } - const template = await getAgentTemplateForAccount(inserted.id, accountId); + const [template] = await selectAgentTemplates({ id: inserted.id }, accountId); return NextResponse.json( - { status: "success", template }, + { status: "success", template: template ?? null }, { status: 201, headers: getCorsHeaders() }, ); } catch (error) { diff --git a/lib/agent_templates/getAgentTemplateForAccount.ts b/lib/agent_templates/getAgentTemplateForAccount.ts deleted file mode 100644 index e8c5e16d3..000000000 --- a/lib/agent_templates/getAgentTemplateForAccount.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { selectAgentTemplates } from "@/lib/supabase/agent_templates/selectAgentTemplates"; -import { selectAgentTemplateFavorites } from "@/lib/supabase/agent_template_favorites/selectAgentTemplateFavorites"; -import { - buildAgentTemplateResponse, - type AgentTemplateResponse, -} from "@/lib/agent_templates/buildAgentTemplateResponse"; -import { resolveSharedEmailsByTemplateId } from "@/lib/agent_templates/resolveSharedEmailsByTemplateId"; - -/** - * Fetches a single agent template by id, shaped for the API with - * `is_favourite` (for `accountId`) and `shared_emails` (only when the caller - * is the template's creator) populated. Returns `null` when the template - * does not exist. - */ -export async function getAgentTemplateForAccount( - templateId: string, - accountId: string, -): Promise { - const [rows, favorites] = await Promise.all([ - selectAgentTemplates({ id: templateId }), - selectAgentTemplateFavorites(accountId), - ]); - const row = rows[0]; - if (!row) return null; - - const creator = Array.isArray(row.creator) ? row.creator[0] : row.creator; - const isOwner = creator?.id === accountId; - const sharedEmailsMap = - row.is_private && isOwner ? await resolveSharedEmailsByTemplateId([row.id]) : {}; - - return buildAgentTemplateResponse(row, { - isFavourite: favorites.some(f => f.template_id === row.id), - sharedEmails: sharedEmailsMap[row.id] ?? [], - }); -} diff --git a/lib/agent_templates/listAgentTemplatesHandler.ts b/lib/agent_templates/listAgentTemplatesHandler.ts index 520d1a691..ed4f5882c 100644 --- a/lib/agent_templates/listAgentTemplatesHandler.ts +++ b/lib/agent_templates/listAgentTemplatesHandler.ts @@ -1,26 +1,15 @@ import { NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; -import { - selectAgentTemplates, - type AgentTemplateWithCreator, -} from "@/lib/supabase/agent_templates/selectAgentTemplates"; -import { selectAgentTemplateFavorites } from "@/lib/supabase/agent_template_favorites/selectAgentTemplateFavorites"; -import { buildAgentTemplateResponse } from "@/lib/agent_templates/buildAgentTemplateResponse"; -import { resolveSharedEmailsByTemplateId } from "@/lib/agent_templates/resolveSharedEmailsByTemplateId"; - -function creatorIdOf(row: AgentTemplateWithCreator): string | null { - const c = row.creator; - if (!c) return null; - return Array.isArray(c) ? (c[0]?.id ?? null) : c.id; -} +import { selectAgentTemplates } from "@/lib/supabase/agent_templates/selectAgentTemplates"; /** * Handler for GET /api/agent-templates. * - * Returns every agent template the authenticated account can see (own, public, - * shared) with `creator`, `is_favourite`, and `shared_emails` embedded. - * `shared_emails` is only populated for private templates the caller owns. + * Returns every agent template the authenticated account can see (own, + * public, shared) fully shaped: `creator` flat with `is_admin`, + * `is_favourite`, and `shared_emails` (only for templates the caller owns + * and that are private). */ export async function listAgentTemplatesHandler(request: NextRequest): Promise { try { @@ -28,25 +17,7 @@ export async function listAgentTemplatesHandler(request: NextRequest): Promise f.template_id)); - const ownedPrivateIds = rows - .filter(r => r.is_private && creatorIdOf(r) === accountId) - .map(r => r.id); - const sharedEmailsMap = await resolveSharedEmailsByTemplateId(ownedPrivateIds); - - const templates = rows.map(row => - buildAgentTemplateResponse(row, { - isFavourite: favoriteIds.has(row.id), - sharedEmails: - row.is_private && creatorIdOf(row) === accountId ? (sharedEmailsMap[row.id] ?? []) : [], - }), - ); + const templates = await selectAgentTemplates({ accessibleTo: accountId }, accountId); return NextResponse.json( { status: "success", templates }, diff --git a/lib/agent_templates/resolveSharedEmailsByTemplateId.ts b/lib/agent_templates/resolveSharedEmailsByTemplateId.ts deleted file mode 100644 index e78f41322..000000000 --- a/lib/agent_templates/resolveSharedEmailsByTemplateId.ts +++ /dev/null @@ -1,40 +0,0 @@ -import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmails"; -import { selectAgentTemplateShares } from "@/lib/supabase/agent_template_shares/selectAgentTemplateShares"; - -/** - * For each supplied template id, returns the list of emails it has been - * shared with — by joining `agent_template_shares` to `account_emails`. - * Empty input returns an empty map. - */ -export async function resolveSharedEmailsByTemplateId( - templateIds: string[], -): Promise> { - if (templateIds.length === 0) return {}; - - const shares = await selectAgentTemplateShares(templateIds); - if (shares.length === 0) return {}; - - const accountIds = Array.from(new Set(shares.map(s => s.user_id))); - const accountEmails = await selectAccountEmails({ accountIds }); - - const emailsByAccount = new Map(); - accountEmails.forEach(row => { - if (!row.account_id || !row.email) return; - const list = emailsByAccount.get(row.account_id) ?? []; - list.push(row.email); - emailsByAccount.set(row.account_id, list); - }); - - const result: Record = {}; - shares.forEach(share => { - const list = result[share.template_id] ?? []; - list.push(...(emailsByAccount.get(share.user_id) ?? [])); - result[share.template_id] = list; - }); - - Object.keys(result).forEach(id => { - result[id] = Array.from(new Set(result[id])); - }); - - return result; -} diff --git a/lib/agent_templates/updateAgentTemplateHandler.ts b/lib/agent_templates/updateAgentTemplateHandler.ts index 8f22f2252..3dc76b358 100644 --- a/lib/agent_templates/updateAgentTemplateHandler.ts +++ b/lib/agent_templates/updateAgentTemplateHandler.ts @@ -4,15 +4,14 @@ import { validateUpdateAgentTemplateRequest } from "@/lib/agent_templates/valida import { updateAgentTemplate } from "@/lib/supabase/agent_templates/updateAgentTemplate"; import { deleteAgentTemplateShares } from "@/lib/supabase/agent_template_shares/deleteAgentTemplateShares"; import { insertAgentTemplateShares } from "@/lib/supabase/agent_template_shares/insertAgentTemplateShares"; -import { getAgentTemplateForAccount } from "@/lib/agent_templates/getAgentTemplateForAccount"; +import { selectAgentTemplates } from "@/lib/supabase/agent_templates/selectAgentTemplates"; import type { TablesUpdate } from "@/types/database.types"; /** * Handler for PATCH /api/agent-templates/{id}. * * Applies a partial update to an agent template the caller owns. When - * `share_emails` is provided, existing shares are wiped and re-inserted from - * the resolved emails. + * `share_emails` is provided, existing shares are wiped and re-inserted. */ export async function updateAgentTemplateHandler( request: NextRequest, @@ -42,10 +41,9 @@ export async function updateAgentTemplateHandler( } } - // NOTE: delete-then-insert is not atomic. If the insert fails after the - // delete succeeds the template will end up with no shares. A real fix - // requires a Postgres RPC; for now both helpers throw on DB error so the - // outer catch returns a 500. + // NOTE: delete-then-insert is not atomic. A real fix requires a Postgres + // RPC; for now both helpers throw on DB error so the outer catch returns + // a 500. if (typeof body.share_emails !== "undefined") { await deleteAgentTemplateShares(templateId); if (body.share_emails.length > 0) { @@ -53,10 +51,10 @@ export async function updateAgentTemplateHandler( } } - const template = await getAgentTemplateForAccount(templateId, accountId); + const [template] = await selectAgentTemplates({ id: templateId }, accountId); return NextResponse.json( - { status: "success", template }, + { status: "success", template: template ?? null }, { status: 200, headers: getCorsHeaders() }, ); } catch (error) { diff --git a/lib/agent_templates/validateDeleteAgentTemplateRequest.ts b/lib/agent_templates/validateDeleteAgentTemplateRequest.ts index c7dd4faa8..159694f0e 100644 --- a/lib/agent_templates/validateDeleteAgentTemplateRequest.ts +++ b/lib/agent_templates/validateDeleteAgentTemplateRequest.ts @@ -34,8 +34,7 @@ export async function validateDeleteAgentTemplateRequest( ); } - const creator = Array.isArray(existing.creator) ? existing.creator[0] : existing.creator; - if (creator?.id !== accountId) { + if (existing.creator?.id !== accountId) { return NextResponse.json( { status: "error", error: "Forbidden" }, { status: 403, headers: getCorsHeaders() }, diff --git a/lib/agent_templates/validateToggleFavoriteRequest.ts b/lib/agent_templates/validateToggleFavoriteRequest.ts index ff410eec7..10cd94e98 100644 --- a/lib/agent_templates/validateToggleFavoriteRequest.ts +++ b/lib/agent_templates/validateToggleFavoriteRequest.ts @@ -58,8 +58,7 @@ export async function validateToggleFavoriteRequest( ); } - const creator = Array.isArray(existing.creator) ? existing.creator[0] : existing.creator; - const isOwner = creator?.id === accountId; + const isOwner = existing.creator?.id === accountId; let canAccess = isOwner || !existing.is_private; if (!canAccess) { const shares = await selectAgentTemplateShares([templateId]); diff --git a/lib/agent_templates/validateUpdateAgentTemplateRequest.ts b/lib/agent_templates/validateUpdateAgentTemplateRequest.ts index d09bae779..f5499cccd 100644 --- a/lib/agent_templates/validateUpdateAgentTemplateRequest.ts +++ b/lib/agent_templates/validateUpdateAgentTemplateRequest.ts @@ -66,8 +66,7 @@ export async function validateUpdateAgentTemplateRequest( ); } - const creator = Array.isArray(existing.creator) ? existing.creator[0] : existing.creator; - if (creator?.id !== accountId) { + if (existing.creator?.id !== accountId) { return NextResponse.json( { status: "error", error: "Forbidden" }, { status: 403, headers: getCorsHeaders() }, diff --git a/lib/supabase/agent_templates/selectAgentTemplates.ts b/lib/supabase/agent_templates/selectAgentTemplates.ts index eb517ef7d..b7a46bf58 100644 --- a/lib/supabase/agent_templates/selectAgentTemplates.ts +++ b/lib/supabase/agent_templates/selectAgentTemplates.ts @@ -1,5 +1,9 @@ import type { QueryData } from "@supabase/supabase-js"; import supabase from "@/lib/supabase/serverClient"; +import { ADMIN_EMAILS } from "@/lib/const"; +import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmails"; +import { selectAgentTemplateFavorites } from "@/lib/supabase/agent_template_favorites/selectAgentTemplateFavorites"; +import { selectAgentTemplateShares } from "@/lib/supabase/agent_template_shares/selectAgentTemplateShares"; const SELECT = ` *, @@ -13,25 +17,67 @@ const SELECT = ` const _typedQuery = supabase.from("agent_templates").select(SELECT); -export type AgentTemplateWithCreator = QueryData[number]; +type RawAgentTemplate = QueryData[number]; + +export interface AgentTemplateCreator { + id: string; + name: string | null; + image: string | null; + is_admin: boolean; +} + +export type AgentTemplate = Omit & { + creator: AgentTemplateCreator | null; + is_favourite: boolean; + shared_emails: string[]; +}; type SelectAgentTemplatesParams = { id: string } | { accessibleTo: string }; -/** - * Single entry point for reading agent_templates joined with the creator - * block (id, name, image, admin-email markers). - * - * - `{ id }` returns the row with that id, or empty when not found. - * - `{ accessibleTo }` returns every row the account can see: ones they - * own, public ones, and ones shared with them via agent_template_shares - * — deduped. - * - * Throws on database error so callers can distinguish a real failure from - * an empty result. - */ -export async function selectAgentTemplates( - params: SelectAgentTemplatesParams, -): Promise { +function flattenCreator(creator: RawAgentTemplate["creator"]): AgentTemplateCreator | null { + if (!creator) return null; + const row = Array.isArray(creator) ? creator[0] : creator; + if (!row) return null; + const emails = (row.account_emails ?? []) + .map(e => e.email) + .filter((e): e is string => typeof e === "string"); + return { + id: row.id, + name: row.name ?? null, + image: row.account_info?.[0]?.image ?? null, + is_admin: emails.some(email => ADMIN_EMAILS.includes(email)), + }; +} + +async function resolveSharedEmails(templateIds: string[]): Promise> { + if (templateIds.length === 0) return {}; + const shares = await selectAgentTemplateShares(templateIds); + if (shares.length === 0) return {}; + + const accountIds = Array.from(new Set(shares.map(s => s.user_id))); + const accountEmails = await selectAccountEmails({ accountIds }); + + const emailsByAccount = new Map(); + accountEmails.forEach(row => { + if (!row.account_id || !row.email) return; + const list = emailsByAccount.get(row.account_id) ?? []; + list.push(row.email); + emailsByAccount.set(row.account_id, list); + }); + + const result: Record = {}; + shares.forEach(share => { + const list = result[share.template_id] ?? []; + list.push(...(emailsByAccount.get(share.user_id) ?? [])); + result[share.template_id] = list; + }); + Object.keys(result).forEach(id => { + result[id] = Array.from(new Set(result[id])); + }); + return result; +} + +async function fetchRaw(params: SelectAgentTemplatesParams): Promise { if ("id" in params) { const { data, error } = await supabase .from("agent_templates") @@ -68,12 +114,10 @@ export async function selectAgentTemplates( throw new Error(`selectAgentTemplates(accessibleTo) shared failed: ${shared.error.message}`); } - const byId = new Map(); + const byId = new Map(); (ownedAndPublic.data ?? []).forEach(row => byId.set(row.id, row)); - (shared.data ?? []).forEach(share => { - const { template } = share as { - template: AgentTemplateWithCreator | AgentTemplateWithCreator[] | null; - }; + (shared.data ?? []).forEach(s => { + const { template } = s as { template: RawAgentTemplate | RawAgentTemplate[] | null }; if (!template) return; const list = Array.isArray(template) ? template : [template]; list.forEach(t => { @@ -82,3 +126,51 @@ export async function selectAgentTemplates( }); return Array.from(byId.values()); } + +/** + * Reads agent_templates and returns them fully shaped for the API: + * - creator block flattened to `{ id, name, image, is_admin }` + * - `is_favourite` populated against `forAccountId` (defaults to `false`) + * - `shared_emails` populated only for private templates `forAccountId` owns + * + * `{ id }` → row with that id, or empty array when not found. + * `{ accessibleTo }` → own + public + shared (deduped) for that account. + * + * Pass `forAccountId` whenever you need is_favourite / shared_emails marked. + * Internal callers (e.g. ownership validators) can omit it to skip the + * caller-specific enrichment queries. + * + * Throws on database error. + */ +export async function selectAgentTemplates( + params: SelectAgentTemplatesParams, + forAccountId?: string, +): Promise { + const rawRows = await fetchRaw(params); + if (rawRows.length === 0) return []; + + const flattened = rawRows.map(row => { + const { creator, ...rest } = row; + return { ...rest, creator: flattenCreator(creator) }; + }); + + if (!forAccountId) { + return flattened.map(row => ({ ...row, is_favourite: false, shared_emails: [] })); + } + + const ownedPrivateIds = flattened + .filter(r => r.is_private && r.creator?.id === forAccountId) + .map(r => r.id); + const [favorites, sharedEmailsMap] = await Promise.all([ + selectAgentTemplateFavorites(forAccountId), + resolveSharedEmails(ownedPrivateIds), + ]); + const favoriteIds = new Set(favorites.map(f => f.template_id)); + + return flattened.map(row => ({ + ...row, + is_favourite: favoriteIds.has(row.id), + shared_emails: + row.is_private && row.creator?.id === forAccountId ? (sharedEmailsMap[row.id] ?? []) : [], + })); +} From 3837ad84c66e75e4ae5015ba546def41326f3d8a Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Tue, 12 May 2026 06:00:04 +0530 Subject: [PATCH 08/32] refactor(api): rename agent-templates surface to templates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit URL: /api/agent-templates → /api/templates Folders: lib/agent_templates → lib/templates, lib/supabase/agent_templates → lib/supabase/templates, lib/supabase/agent_template_{favorites,shares} → lib/supabase/template_{favorites,shares} Identifiers: AgentTemplate* → Template*, selectAgentTemplate* → selectTemplate*, etc. DB table strings (.from("agent_templates"), Tables<"agent_template_*">) and FK names (agent_templates_creator_fkey, agent_template_shares_template_id_fkey) preserved — those mirror the actual schema in the database submodule. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../[id]/favorite/route.ts | 6 +- .../[id]/route.ts | 16 ++-- .../{agent-templates => templates}/route.ts | 16 ++-- .../deleteAgentTemplateHandler.test.ts | 56 ------------ ...toggleAgentTemplateFavoriteHandler.test.ts | 89 ------------------- .../updateAgentTemplateHandler.test.ts | 84 ----------------- lib/const.ts | 2 +- .../deleteTemplateFavorite.ts} | 6 +- .../insertTemplateFavorite.ts} | 6 +- .../selectTemplateFavorites.ts} | 6 +- .../deleteTemplateShares.ts} | 10 +-- .../insertTemplateShares.ts} | 13 ++- .../selectTemplateShares.ts} | 8 +- .../deleteTemplate.ts} | 8 +- .../insertTemplate.ts} | 10 +-- .../selectTemplates.ts} | 46 +++++----- .../updateTemplate.ts} | 8 +- .../__tests__/createTemplateHandler.test.ts} | 48 +++++----- .../__tests__/deleteTemplateHandler.test.ts | 56 ++++++++++++ .../__tests__/listTemplatesHandler.test.ts} | 28 +++--- .../toggleTemplateFavoriteHandler.test.ts | 87 ++++++++++++++++++ .../__tests__/updateTemplateHandler.test.ts | 82 +++++++++++++++++ .../createTemplateHandler.ts} | 28 +++--- .../deleteTemplateHandler.ts} | 18 ++-- .../listTemplatesHandler.ts} | 12 +-- .../toggleTemplateFavoriteHandler.ts} | 16 ++-- .../updateTemplateHandler.ts} | 30 +++---- .../validateCreateTemplateBody.ts} | 12 ++- .../validateDeleteTemplateRequest.ts} | 14 +-- .../validateToggleFavoriteRequest.ts | 12 +-- .../validateUpdateTemplateRequest.ts} | 22 ++--- 31 files changed, 420 insertions(+), 435 deletions(-) rename app/api/{agent-templates => templates}/[id]/favorite/route.ts (81%) rename app/api/{agent-templates => templates}/[id]/route.ts (71%) rename app/api/{agent-templates => templates}/route.ts (70%) delete mode 100644 lib/agent_templates/__tests__/deleteAgentTemplateHandler.test.ts delete mode 100644 lib/agent_templates/__tests__/toggleAgentTemplateFavoriteHandler.test.ts delete mode 100644 lib/agent_templates/__tests__/updateAgentTemplateHandler.test.ts rename lib/supabase/{agent_template_favorites/deleteAgentTemplateFavorite.ts => template_favorites/deleteTemplateFavorite.ts} (76%) rename lib/supabase/{agent_template_favorites/insertAgentTemplateFavorite.ts => template_favorites/insertTemplateFavorite.ts} (80%) rename lib/supabase/{agent_template_favorites/selectAgentTemplateFavorites.ts => template_favorites/selectTemplateFavorites.ts} (72%) rename lib/supabase/{agent_template_shares/deleteAgentTemplateShares.ts => template_shares/deleteTemplateShares.ts} (50%) rename lib/supabase/{agent_template_shares/insertAgentTemplateShares.ts => template_shares/insertTemplateShares.ts} (73%) rename lib/supabase/{agent_template_shares/selectAgentTemplateShares.ts => template_shares/selectTemplateShares.ts} (67%) rename lib/supabase/{agent_templates/deleteAgentTemplate.ts => templates/deleteTemplate.ts} (54%) rename lib/supabase/{agent_templates/insertAgentTemplate.ts => templates/insertTemplate.ts} (56%) rename lib/supabase/{agent_templates/selectAgentTemplates.ts => templates/selectTemplates.ts} (73%) rename lib/supabase/{agent_templates/updateAgentTemplate.ts => templates/updateTemplate.ts} (72%) rename lib/{agent_templates/__tests__/createAgentTemplateHandler.test.ts => templates/__tests__/createTemplateHandler.test.ts} (54%) create mode 100644 lib/templates/__tests__/deleteTemplateHandler.test.ts rename lib/{agent_templates/__tests__/listAgentTemplatesHandler.test.ts => templates/__tests__/listTemplatesHandler.test.ts} (55%) create mode 100644 lib/templates/__tests__/toggleTemplateFavoriteHandler.test.ts create mode 100644 lib/templates/__tests__/updateTemplateHandler.test.ts rename lib/{agent_templates/createAgentTemplateHandler.ts => templates/createTemplateHandler.ts} (56%) rename lib/{agent_templates/deleteAgentTemplateHandler.ts => templates/deleteTemplateHandler.ts} (58%) rename lib/{agent_templates/listAgentTemplatesHandler.ts => templates/listTemplatesHandler.ts} (65%) rename lib/{agent_templates/toggleAgentTemplateFavoriteHandler.ts => templates/toggleTemplateFavoriteHandler.ts} (66%) rename lib/{agent_templates/updateAgentTemplateHandler.ts => templates/updateTemplateHandler.ts} (59%) rename lib/{agent_templates/validateCreateAgentTemplateBody.ts => templates/validateCreateTemplateBody.ts} (78%) rename lib/{agent_templates/validateDeleteAgentTemplateRequest.ts => templates/validateDeleteTemplateRequest.ts} (68%) rename lib/{agent_templates => templates}/validateToggleFavoriteRequest.ts (82%) rename lib/{agent_templates/validateUpdateAgentTemplateRequest.ts => templates/validateUpdateTemplateRequest.ts} (73%) diff --git a/app/api/agent-templates/[id]/favorite/route.ts b/app/api/templates/[id]/favorite/route.ts similarity index 81% rename from app/api/agent-templates/[id]/favorite/route.ts rename to app/api/templates/[id]/favorite/route.ts index 31d9f40c2..8103ae004 100644 --- a/app/api/agent-templates/[id]/favorite/route.ts +++ b/app/api/templates/[id]/favorite/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { toggleAgentTemplateFavoriteHandler } from "@/lib/agent_templates/toggleAgentTemplateFavoriteHandler"; +import { toggleTemplateFavoriteHandler } from "@/lib/templates/toggleTemplateFavoriteHandler"; /** * OPTIONS handler for CORS preflight requests. @@ -15,7 +15,7 @@ export async function OPTIONS() { } /** - * PUT /api/agent-templates/{id}/favorite + * PUT /api/templates/{id}/favorite * * Idempotently sets whether the authenticated account has favorited the * template: `{ is_favourite: true }` upserts a row, `false` deletes it. @@ -29,7 +29,7 @@ export async function PUT( request: NextRequest, context: { params: Promise<{ id: string }> }, ): Promise { - return toggleAgentTemplateFavoriteHandler(request, context.params); + return toggleTemplateFavoriteHandler(request, context.params); } export const dynamic = "force-dynamic"; diff --git a/app/api/agent-templates/[id]/route.ts b/app/api/templates/[id]/route.ts similarity index 71% rename from app/api/agent-templates/[id]/route.ts rename to app/api/templates/[id]/route.ts index f4c342576..f6b6833dd 100644 --- a/app/api/agent-templates/[id]/route.ts +++ b/app/api/templates/[id]/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { updateAgentTemplateHandler } from "@/lib/agent_templates/updateAgentTemplateHandler"; -import { deleteAgentTemplateHandler } from "@/lib/agent_templates/deleteAgentTemplateHandler"; +import { updateTemplateHandler } from "@/lib/templates/updateTemplateHandler"; +import { deleteTemplateHandler } from "@/lib/templates/deleteTemplateHandler"; /** * OPTIONS handler for CORS preflight requests. @@ -16,9 +16,9 @@ export async function OPTIONS() { } /** - * PATCH /api/agent-templates/{id} + * PATCH /api/templates/{id} * - * Updates one or more fields on an agent template the authenticated account + * Updates one or more fields on an template the authenticated account * owns. Supplying `share_emails` replaces existing shares. * * @param request - Incoming request; body is JSON-encoded. @@ -30,13 +30,13 @@ export async function PATCH( request: NextRequest, context: { params: Promise<{ id: string }> }, ): Promise { - return updateAgentTemplateHandler(request, context.params); + return updateTemplateHandler(request, context.params); } /** - * DELETE /api/agent-templates/{id} + * DELETE /api/templates/{id} * - * Permanently removes an agent template the authenticated account owns. + * Permanently removes an template the authenticated account owns. * * @param request - Incoming request; auth is read from headers. * @param context - Route context. @@ -47,7 +47,7 @@ export async function DELETE( request: NextRequest, context: { params: Promise<{ id: string }> }, ): Promise { - return deleteAgentTemplateHandler(request, context.params); + return deleteTemplateHandler(request, context.params); } export const dynamic = "force-dynamic"; diff --git a/app/api/agent-templates/route.ts b/app/api/templates/route.ts similarity index 70% rename from app/api/agent-templates/route.ts rename to app/api/templates/route.ts index 6192eb4eb..161cfb84d 100644 --- a/app/api/agent-templates/route.ts +++ b/app/api/templates/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { listAgentTemplatesHandler } from "@/lib/agent_templates/listAgentTemplatesHandler"; -import { createAgentTemplateHandler } from "@/lib/agent_templates/createAgentTemplateHandler"; +import { listTemplatesHandler } from "@/lib/templates/listTemplatesHandler"; +import { createTemplateHandler } from "@/lib/templates/createTemplateHandler"; /** * OPTIONS handler for CORS preflight requests. @@ -16,9 +16,9 @@ export async function OPTIONS() { } /** - * GET /api/agent-templates + * GET /api/templates * - * Returns every agent template visible to the authenticated account (own, + * Returns every template visible to the authenticated account (own, * public, and shared) with an embedded creator block (id/name/image/is_admin), * the caller's `is_favourite` flag, and `shared_emails` for private templates. * @@ -26,13 +26,13 @@ export async function OPTIONS() { * @returns A 200 NextResponse with `{ status, templates }`. */ export async function GET(request: NextRequest): Promise { - return listAgentTemplatesHandler(request); + return listTemplatesHandler(request); } /** - * POST /api/agent-templates + * POST /api/templates * - * Creates a new agent template owned by the authenticated account. When + * Creates a new template owned by the authenticated account. When * `is_private=true`, `share_emails` recipients are upserted into the shares * table. * @@ -40,7 +40,7 @@ export async function GET(request: NextRequest): Promise { * @returns A 201 NextResponse with `{ status, template }` on success. */ export async function POST(request: NextRequest): Promise { - return createAgentTemplateHandler(request); + return createTemplateHandler(request); } export const dynamic = "force-dynamic"; diff --git a/lib/agent_templates/__tests__/deleteAgentTemplateHandler.test.ts b/lib/agent_templates/__tests__/deleteAgentTemplateHandler.test.ts deleted file mode 100644 index fe90b09e5..000000000 --- a/lib/agent_templates/__tests__/deleteAgentTemplateHandler.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { NextRequest, NextResponse } from "next/server"; - -vi.mock("@/lib/networking/getCorsHeaders", () => ({ - getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), -})); - -vi.mock("@/lib/agent_templates/validateDeleteAgentTemplateRequest", () => ({ - validateDeleteAgentTemplateRequest: vi.fn(), -})); - -vi.mock("@/lib/supabase/agent_templates/deleteAgentTemplate", () => ({ - deleteAgentTemplate: vi.fn(), -})); - -const { deleteAgentTemplateHandler } = await import("../deleteAgentTemplateHandler"); -const { validateDeleteAgentTemplateRequest } = await import( - "@/lib/agent_templates/validateDeleteAgentTemplateRequest" -); -const { deleteAgentTemplate } = await import("@/lib/supabase/agent_templates/deleteAgentTemplate"); - -const ACCOUNT_ID = "11111111-1111-1111-1111-111111111111"; -const TEMPLATE_ID = "22222222-2222-2222-2222-222222222222"; - -describe("deleteAgentTemplateHandler", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("returns success when delete succeeds", async () => { - vi.mocked(validateDeleteAgentTemplateRequest).mockResolvedValue({ - templateId: TEMPLATE_ID, - accountId: ACCOUNT_ID, - }); - vi.mocked(deleteAgentTemplate).mockResolvedValue(true); - - const req = new NextRequest(`http://localhost/api/agent-templates/${TEMPLATE_ID}`, { - method: "DELETE", - }); - const res = await deleteAgentTemplateHandler(req, Promise.resolve({ id: TEMPLATE_ID })); - expect(res.status).toBe(200); - expect(await res.json()).toEqual({ status: "success" }); - }); - - it("returns the validator error response when ownership fails", async () => { - const failure = NextResponse.json({ status: "error", error: "Forbidden" }, { status: 403 }); - vi.mocked(validateDeleteAgentTemplateRequest).mockResolvedValue(failure); - - const req = new NextRequest(`http://localhost/api/agent-templates/${TEMPLATE_ID}`, { - method: "DELETE", - }); - const res = await deleteAgentTemplateHandler(req, Promise.resolve({ id: TEMPLATE_ID })); - expect(res).toBe(failure); - expect(deleteAgentTemplate).not.toHaveBeenCalled(); - }); -}); diff --git a/lib/agent_templates/__tests__/toggleAgentTemplateFavoriteHandler.test.ts b/lib/agent_templates/__tests__/toggleAgentTemplateFavoriteHandler.test.ts deleted file mode 100644 index 02bd55c74..000000000 --- a/lib/agent_templates/__tests__/toggleAgentTemplateFavoriteHandler.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { NextRequest, NextResponse } from "next/server"; - -vi.mock("@/lib/networking/getCorsHeaders", () => ({ - getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), -})); - -vi.mock("@/lib/agent_templates/validateToggleFavoriteRequest", () => ({ - validateToggleFavoriteRequest: vi.fn(), -})); - -vi.mock("@/lib/supabase/agent_template_favorites/insertAgentTemplateFavorite", () => ({ - insertAgentTemplateFavorite: vi.fn(), -})); - -vi.mock("@/lib/supabase/agent_template_favorites/deleteAgentTemplateFavorite", () => ({ - deleteAgentTemplateFavorite: vi.fn(), -})); - -const { toggleAgentTemplateFavoriteHandler } = await import( - "../toggleAgentTemplateFavoriteHandler" -); -const { validateToggleFavoriteRequest } = await import( - "@/lib/agent_templates/validateToggleFavoriteRequest" -); -const { insertAgentTemplateFavorite } = await import( - "@/lib/supabase/agent_template_favorites/insertAgentTemplateFavorite" -); -const { deleteAgentTemplateFavorite } = await import( - "@/lib/supabase/agent_template_favorites/deleteAgentTemplateFavorite" -); - -const ACCOUNT_ID = "11111111-1111-1111-1111-111111111111"; -const TEMPLATE_ID = "22222222-2222-2222-2222-222222222222"; - -describe("toggleAgentTemplateFavoriteHandler", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("inserts a favorite when is_favourite is true", async () => { - vi.mocked(validateToggleFavoriteRequest).mockResolvedValue({ - templateId: TEMPLATE_ID, - accountId: ACCOUNT_ID, - isFavourite: true, - }); - vi.mocked(insertAgentTemplateFavorite).mockResolvedValue(true); - - const req = new NextRequest(`http://localhost/api/agent-templates/${TEMPLATE_ID}/favorite`, { - method: "PUT", - }); - const res = await toggleAgentTemplateFavoriteHandler(req, Promise.resolve({ id: TEMPLATE_ID })); - expect(res.status).toBe(200); - expect(await res.json()).toEqual({ status: "success" }); - expect(insertAgentTemplateFavorite).toHaveBeenCalledWith(TEMPLATE_ID, ACCOUNT_ID); - expect(deleteAgentTemplateFavorite).not.toHaveBeenCalled(); - }); - - it("deletes a favorite when is_favourite is false", async () => { - vi.mocked(validateToggleFavoriteRequest).mockResolvedValue({ - templateId: TEMPLATE_ID, - accountId: ACCOUNT_ID, - isFavourite: false, - }); - vi.mocked(deleteAgentTemplateFavorite).mockResolvedValue(true); - - const req = new NextRequest(`http://localhost/api/agent-templates/${TEMPLATE_ID}/favorite`, { - method: "PUT", - }); - const res = await toggleAgentTemplateFavoriteHandler(req, Promise.resolve({ id: TEMPLATE_ID })); - expect(res.status).toBe(200); - expect(deleteAgentTemplateFavorite).toHaveBeenCalledWith(TEMPLATE_ID, ACCOUNT_ID); - expect(insertAgentTemplateFavorite).not.toHaveBeenCalled(); - }); - - it("returns the validator error response on validation failure", async () => { - const failure = NextResponse.json( - { status: "error", error: "is_favourite is required" }, - { status: 400 }, - ); - vi.mocked(validateToggleFavoriteRequest).mockResolvedValue(failure); - - const req = new NextRequest(`http://localhost/api/agent-templates/${TEMPLATE_ID}/favorite`, { - method: "PUT", - }); - const res = await toggleAgentTemplateFavoriteHandler(req, Promise.resolve({ id: TEMPLATE_ID })); - expect(res).toBe(failure); - }); -}); diff --git a/lib/agent_templates/__tests__/updateAgentTemplateHandler.test.ts b/lib/agent_templates/__tests__/updateAgentTemplateHandler.test.ts deleted file mode 100644 index f6e07ae4c..000000000 --- a/lib/agent_templates/__tests__/updateAgentTemplateHandler.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { NextRequest, NextResponse } from "next/server"; - -vi.mock("@/lib/networking/getCorsHeaders", () => ({ - getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), -})); - -vi.mock("@/lib/agent_templates/validateUpdateAgentTemplateRequest", () => ({ - validateUpdateAgentTemplateRequest: vi.fn(), -})); - -vi.mock("@/lib/supabase/agent_templates/updateAgentTemplate", () => ({ - updateAgentTemplate: vi.fn(), -})); - -vi.mock("@/lib/supabase/agent_template_shares/deleteAgentTemplateShares", () => ({ - deleteAgentTemplateShares: vi.fn(), -})); - -vi.mock("@/lib/supabase/agent_template_shares/insertAgentTemplateShares", () => ({ - insertAgentTemplateShares: vi.fn(), -})); - -vi.mock("@/lib/supabase/agent_templates/selectAgentTemplates", () => ({ - selectAgentTemplates: vi.fn(), -})); - -const { updateAgentTemplateHandler } = await import("../updateAgentTemplateHandler"); -const { validateUpdateAgentTemplateRequest } = await import( - "@/lib/agent_templates/validateUpdateAgentTemplateRequest" -); -const { updateAgentTemplate } = await import("@/lib/supabase/agent_templates/updateAgentTemplate"); -const { deleteAgentTemplateShares } = await import( - "@/lib/supabase/agent_template_shares/deleteAgentTemplateShares" -); -const { insertAgentTemplateShares } = await import( - "@/lib/supabase/agent_template_shares/insertAgentTemplateShares" -); -const { selectAgentTemplates } = await import( - "@/lib/supabase/agent_templates/selectAgentTemplates" -); - -const ACCOUNT_ID = "11111111-1111-1111-1111-111111111111"; -const TEMPLATE_ID = "22222222-2222-2222-2222-222222222222"; - -describe("updateAgentTemplateHandler", () => { - beforeEach(() => vi.clearAllMocks()); - - it("updates the template and replaces shares when share_emails provided", async () => { - vi.mocked(validateUpdateAgentTemplateRequest).mockResolvedValue({ - templateId: TEMPLATE_ID, - accountId: ACCOUNT_ID, - body: { title: "New Title", share_emails: ["x@y.com"] }, - }); - vi.mocked(updateAgentTemplate).mockResolvedValue({ id: TEMPLATE_ID } as never); - vi.mocked(deleteAgentTemplateShares).mockResolvedValue(undefined); - vi.mocked(insertAgentTemplateShares).mockResolvedValue(1); - vi.mocked(selectAgentTemplates).mockResolvedValue([{ id: TEMPLATE_ID } as never]); - - const req = new NextRequest(`http://localhost/api/agent-templates/${TEMPLATE_ID}`, { - method: "PATCH", - }); - const res = await updateAgentTemplateHandler(req, Promise.resolve({ id: TEMPLATE_ID })); - - expect(res.status).toBe(200); - expect(updateAgentTemplate).toHaveBeenCalledWith(TEMPLATE_ID, { title: "New Title" }); - expect(deleteAgentTemplateShares).toHaveBeenCalledWith(TEMPLATE_ID); - expect(insertAgentTemplateShares).toHaveBeenCalledWith(TEMPLATE_ID, ["x@y.com"]); - expect(selectAgentTemplates).toHaveBeenCalledWith({ id: TEMPLATE_ID }, ACCOUNT_ID); - }); - - it("returns the validator error response when validation fails", async () => { - const failure = NextResponse.json({ status: "error", error: "Forbidden" }, { status: 403 }); - vi.mocked(validateUpdateAgentTemplateRequest).mockResolvedValue(failure); - - const req = new NextRequest(`http://localhost/api/agent-templates/${TEMPLATE_ID}`, { - method: "PATCH", - }); - const res = await updateAgentTemplateHandler(req, Promise.resolve({ id: TEMPLATE_ID })); - - expect(res).toBe(failure); - expect(updateAgentTemplate).not.toHaveBeenCalled(); - }); -}); diff --git a/lib/const.ts b/lib/const.ts index b06d4f6d6..8d4e1f43a 100644 --- a/lib/const.ts +++ b/lib/const.ts @@ -51,7 +51,7 @@ export const SNAPSHOT_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000; /** * Email addresses with platform-admin privileges. * - * Surfaced as `creator.is_admin` in `/api/agent-templates` so clients can flag + * Surfaced as `creator.is_admin` in `/api/templates` so clients can flag * official Recoup templates. Mirrors `chat/lib/admin.ts`. */ export const ADMIN_EMAILS: readonly string[] = ["sidney+1@recoupable.com"] as const; diff --git a/lib/supabase/agent_template_favorites/deleteAgentTemplateFavorite.ts b/lib/supabase/template_favorites/deleteTemplateFavorite.ts similarity index 76% rename from lib/supabase/agent_template_favorites/deleteAgentTemplateFavorite.ts rename to lib/supabase/template_favorites/deleteTemplateFavorite.ts index 887cb0118..ef592df01 100644 --- a/lib/supabase/agent_template_favorites/deleteAgentTemplateFavorite.ts +++ b/lib/supabase/template_favorites/deleteTemplateFavorite.ts @@ -3,11 +3,11 @@ import supabase from "@/lib/supabase/serverClient"; /** * Removes the favorite row for `(template_id, account)`. Idempotent. * - * @param templateId - The agent template UUID + * @param templateId - The template UUID * @param accountId - The account UUID whose favorite is being removed * @returns True on success, false on database error. */ -export async function deleteAgentTemplateFavorite( +export async function deleteTemplateFavorite( templateId: string, accountId: string, ): Promise { @@ -18,7 +18,7 @@ export async function deleteAgentTemplateFavorite( .eq("user_id", accountId); if (error) { - console.error("Error deleting agent_template_favorite:", error); + console.error("Error deleting template_favorite:", error); return false; } diff --git a/lib/supabase/agent_template_favorites/insertAgentTemplateFavorite.ts b/lib/supabase/template_favorites/insertTemplateFavorite.ts similarity index 80% rename from lib/supabase/agent_template_favorites/insertAgentTemplateFavorite.ts rename to lib/supabase/template_favorites/insertTemplateFavorite.ts index dba2868f2..75d23942c 100644 --- a/lib/supabase/agent_template_favorites/insertAgentTemplateFavorite.ts +++ b/lib/supabase/template_favorites/insertTemplateFavorite.ts @@ -4,11 +4,11 @@ import supabase from "@/lib/supabase/serverClient"; * Inserts a favorite row for `(template_id, account)`. Idempotent — a * pre-existing row (Postgres unique-violation 23505) is treated as success. * - * @param templateId - The agent template UUID + * @param templateId - The template UUID * @param accountId - The favoriting account UUID * @returns True if the favorite exists after the call, false on unexpected error. */ -export async function insertAgentTemplateFavorite( +export async function insertTemplateFavorite( templateId: string, accountId: string, ): Promise { @@ -19,7 +19,7 @@ export async function insertAgentTemplateFavorite( .maybeSingle(); if (error && error.code !== "23505") { - console.error("Error inserting agent_template_favorite:", error); + console.error("Error inserting template_favorite:", error); return false; } diff --git a/lib/supabase/agent_template_favorites/selectAgentTemplateFavorites.ts b/lib/supabase/template_favorites/selectTemplateFavorites.ts similarity index 72% rename from lib/supabase/agent_template_favorites/selectAgentTemplateFavorites.ts rename to lib/supabase/template_favorites/selectTemplateFavorites.ts index 46183bbf4..5290d8f64 100644 --- a/lib/supabase/agent_template_favorites/selectAgentTemplateFavorites.ts +++ b/lib/supabase/template_favorites/selectTemplateFavorites.ts @@ -2,12 +2,12 @@ import supabase from "@/lib/supabase/serverClient"; import type { Tables } from "@/types/database.types"; /** - * Selects raw `agent_template_favorites` rows for the given account. + * Selects raw `template_favorites` rows for the given account. * * Returns an empty array on database error (and logs it). Callers that need * a Set of template ids should compose it themselves. */ -export async function selectAgentTemplateFavorites( +export async function selectTemplateFavorites( accountId: string, ): Promise[]> { const { data, error } = await supabase @@ -16,7 +16,7 @@ export async function selectAgentTemplateFavorites( .eq("user_id", accountId); if (error) { - console.error("Error selecting agent_template_favorites:", error); + console.error("Error selecting template_favorites:", error); return []; } diff --git a/lib/supabase/agent_template_shares/deleteAgentTemplateShares.ts b/lib/supabase/template_shares/deleteTemplateShares.ts similarity index 50% rename from lib/supabase/agent_template_shares/deleteAgentTemplateShares.ts rename to lib/supabase/template_shares/deleteTemplateShares.ts index 478e64ac4..ee373eff9 100644 --- a/lib/supabase/agent_template_shares/deleteAgentTemplateShares.ts +++ b/lib/supabase/template_shares/deleteTemplateShares.ts @@ -1,22 +1,22 @@ import supabase from "@/lib/supabase/serverClient"; /** - * Deletes every `agent_template_shares` row for the given template id. + * Deletes every `template_shares` row for the given template id. * * Throws on database error so callers cannot silently continue with an * inconsistent share state. * - * @param templateId - The agent template UUID + * @param templateId - The template UUID * @throws If the Supabase delete fails. */ -export async function deleteAgentTemplateShares(templateId: string): Promise { +export async function deleteTemplateShares(templateId: string): Promise { const { error } = await supabase .from("agent_template_shares") .delete() .eq("template_id", templateId); if (error) { - console.error("Error deleting agent_template_shares:", error); - throw new Error(`deleteAgentTemplateShares failed: ${error.message}`); + console.error("Error deleting template_shares:", error); + throw new Error(`deleteTemplateShares failed: ${error.message}`); } } diff --git a/lib/supabase/agent_template_shares/insertAgentTemplateShares.ts b/lib/supabase/template_shares/insertTemplateShares.ts similarity index 73% rename from lib/supabase/agent_template_shares/insertAgentTemplateShares.ts rename to lib/supabase/template_shares/insertTemplateShares.ts index b8be6e51a..df01f1916 100644 --- a/lib/supabase/agent_template_shares/insertAgentTemplateShares.ts +++ b/lib/supabase/template_shares/insertTemplateShares.ts @@ -3,20 +3,17 @@ import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmai /** * Resolves the supplied emails to account ids and upserts an - * `agent_template_shares` row for each. Unknown emails are silently ignored. + * `template_shares` row for each. Unknown emails are silently ignored. * * Throws on database error so callers can distinguish a real write failure * from "nothing to insert" (the latter returns 0). * - * @param templateId - The agent template UUID + * @param templateId - The template UUID * @param emails - Email addresses to share with * @returns Number of shares inserted (pre-existing rows count as 0). * @throws If the Supabase upsert fails. */ -export async function insertAgentTemplateShares( - templateId: string, - emails: string[], -): Promise { +export async function insertTemplateShares(templateId: string, emails: string[]): Promise { if (!Array.isArray(emails) || emails.length === 0) return 0; const accountEmails = await selectAccountEmails({ emails }); @@ -35,8 +32,8 @@ export async function insertAgentTemplateShares( .select(); if (error) { - console.error("Error inserting agent_template_shares:", error); - throw new Error(`insertAgentTemplateShares failed: ${error.message}`); + console.error("Error inserting template_shares:", error); + throw new Error(`insertTemplateShares failed: ${error.message}`); } return data?.length ?? 0; diff --git a/lib/supabase/agent_template_shares/selectAgentTemplateShares.ts b/lib/supabase/template_shares/selectTemplateShares.ts similarity index 67% rename from lib/supabase/agent_template_shares/selectAgentTemplateShares.ts rename to lib/supabase/template_shares/selectTemplateShares.ts index aa7c3a3bc..e625da5ab 100644 --- a/lib/supabase/agent_template_shares/selectAgentTemplateShares.ts +++ b/lib/supabase/template_shares/selectTemplateShares.ts @@ -2,12 +2,12 @@ import supabase from "@/lib/supabase/serverClient"; import type { Tables } from "@/types/database.types"; /** - * Selects all agent_template_shares rows for the given template ids. + * Selects all template_shares rows for the given template ids. * - * @param templateIds - Array of agent template UUIDs + * @param templateIds - Array of template UUIDs * @returns Array of share rows (may be empty). */ -export async function selectAgentTemplateShares( +export async function selectTemplateShares( templateIds: string[], ): Promise[]> { if (!Array.isArray(templateIds) || templateIds.length === 0) return []; @@ -18,7 +18,7 @@ export async function selectAgentTemplateShares( .in("template_id", templateIds); if (error) { - console.error("Error selecting agent_template_shares:", error); + console.error("Error selecting template_shares:", error); return []; } diff --git a/lib/supabase/agent_templates/deleteAgentTemplate.ts b/lib/supabase/templates/deleteTemplate.ts similarity index 54% rename from lib/supabase/agent_templates/deleteAgentTemplate.ts rename to lib/supabase/templates/deleteTemplate.ts index 587a73f5e..9dbc5c834 100644 --- a/lib/supabase/agent_templates/deleteAgentTemplate.ts +++ b/lib/supabase/templates/deleteTemplate.ts @@ -1,17 +1,17 @@ import supabase from "@/lib/supabase/serverClient"; /** - * Deletes an agent template row by id. Cascades remove dependent shares / + * Deletes an template row by id. Cascades remove dependent shares / * favorites at the database level. * - * @param id - The agent template UUID + * @param id - The template UUID * @returns True if the delete succeeded, false otherwise. */ -export async function deleteAgentTemplate(id: string): Promise { +export async function deleteTemplate(id: string): Promise { const { error } = await supabase.from("agent_templates").delete().eq("id", id); if (error) { - console.error("Error deleting agent template:", error); + console.error("Error deleting template:", error); return false; } diff --git a/lib/supabase/agent_templates/insertAgentTemplate.ts b/lib/supabase/templates/insertTemplate.ts similarity index 56% rename from lib/supabase/agent_templates/insertAgentTemplate.ts rename to lib/supabase/templates/insertTemplate.ts index 901f036fe..49e51f634 100644 --- a/lib/supabase/agent_templates/insertAgentTemplate.ts +++ b/lib/supabase/templates/insertTemplate.ts @@ -2,18 +2,18 @@ import supabase from "@/lib/supabase/serverClient"; import type { Tables, TablesInsert } from "@/types/database.types"; /** - * Inserts a new agent template row. + * Inserts a new template row. * - * @param row - The agent_templates insert payload (must include creator). - * @returns The newly created agent_templates row, or null on error. + * @param row - The template insert payload (must include creator). + * @returns The newly created templates row, or null on error. */ -export async function insertAgentTemplate( +export async function insertTemplate( row: TablesInsert<"agent_templates">, ): Promise | null> { const { data, error } = await supabase.from("agent_templates").insert(row).select("*").single(); if (error) { - console.error("Error inserting agent template:", error); + console.error("Error inserting template:", error); return null; } diff --git a/lib/supabase/agent_templates/selectAgentTemplates.ts b/lib/supabase/templates/selectTemplates.ts similarity index 73% rename from lib/supabase/agent_templates/selectAgentTemplates.ts rename to lib/supabase/templates/selectTemplates.ts index b7a46bf58..3299362ee 100644 --- a/lib/supabase/agent_templates/selectAgentTemplates.ts +++ b/lib/supabase/templates/selectTemplates.ts @@ -2,8 +2,8 @@ import type { QueryData } from "@supabase/supabase-js"; import supabase from "@/lib/supabase/serverClient"; import { ADMIN_EMAILS } from "@/lib/const"; import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmails"; -import { selectAgentTemplateFavorites } from "@/lib/supabase/agent_template_favorites/selectAgentTemplateFavorites"; -import { selectAgentTemplateShares } from "@/lib/supabase/agent_template_shares/selectAgentTemplateShares"; +import { selectTemplateFavorites } from "@/lib/supabase/template_favorites/selectTemplateFavorites"; +import { selectTemplateShares } from "@/lib/supabase/template_shares/selectTemplateShares"; const SELECT = ` *, @@ -17,24 +17,24 @@ const SELECT = ` const _typedQuery = supabase.from("agent_templates").select(SELECT); -type RawAgentTemplate = QueryData[number]; +type RawTemplate = QueryData[number]; -export interface AgentTemplateCreator { +export interface TemplateCreator { id: string; name: string | null; image: string | null; is_admin: boolean; } -export type AgentTemplate = Omit & { - creator: AgentTemplateCreator | null; +export type Template = Omit & { + creator: TemplateCreator | null; is_favourite: boolean; shared_emails: string[]; }; -type SelectAgentTemplatesParams = { id: string } | { accessibleTo: string }; +type SelectTemplatesParams = { id: string } | { accessibleTo: string }; -function flattenCreator(creator: RawAgentTemplate["creator"]): AgentTemplateCreator | null { +function flattenCreator(creator: RawTemplate["creator"]): TemplateCreator | null { if (!creator) return null; const row = Array.isArray(creator) ? creator[0] : creator; if (!row) return null; @@ -51,7 +51,7 @@ function flattenCreator(creator: RawAgentTemplate["creator"]): AgentTemplateCrea async function resolveSharedEmails(templateIds: string[]): Promise> { if (templateIds.length === 0) return {}; - const shares = await selectAgentTemplateShares(templateIds); + const shares = await selectTemplateShares(templateIds); if (shares.length === 0) return {}; const accountIds = Array.from(new Set(shares.map(s => s.user_id))); @@ -77,15 +77,15 @@ async function resolveSharedEmails(templateIds: string[]): Promise { +async function fetchRaw(params: SelectTemplatesParams): Promise { if ("id" in params) { const { data, error } = await supabase .from("agent_templates") .select(SELECT) .eq("id", params.id); if (error) { - console.error("Error selecting agent_template by id:", error); - throw new Error(`selectAgentTemplates(id) failed: ${error.message}`); + console.error("Error selecting template by id:", error); + throw new Error(`selectTemplates(id) failed: ${error.message}`); } return data ?? []; } @@ -104,20 +104,20 @@ async function fetchRaw(params: SelectAgentTemplatesParams): Promise(); + const byId = new Map(); (ownedAndPublic.data ?? []).forEach(row => byId.set(row.id, row)); (shared.data ?? []).forEach(s => { - const { template } = s as { template: RawAgentTemplate | RawAgentTemplate[] | null }; + const { template } = s as { template: RawTemplate | RawTemplate[] | null }; if (!template) return; const list = Array.isArray(template) ? template : [template]; list.forEach(t => { @@ -128,7 +128,7 @@ async function fetchRaw(params: SelectAgentTemplatesParams): Promise { +): Promise { const rawRows = await fetchRaw(params); if (rawRows.length === 0) return []; @@ -162,7 +162,7 @@ export async function selectAgentTemplates( .filter(r => r.is_private && r.creator?.id === forAccountId) .map(r => r.id); const [favorites, sharedEmailsMap] = await Promise.all([ - selectAgentTemplateFavorites(forAccountId), + selectTemplateFavorites(forAccountId), resolveSharedEmails(ownedPrivateIds), ]); const favoriteIds = new Set(favorites.map(f => f.template_id)); diff --git a/lib/supabase/agent_templates/updateAgentTemplate.ts b/lib/supabase/templates/updateTemplate.ts similarity index 72% rename from lib/supabase/agent_templates/updateAgentTemplate.ts rename to lib/supabase/templates/updateTemplate.ts index bc91f518d..bc16e070a 100644 --- a/lib/supabase/agent_templates/updateAgentTemplate.ts +++ b/lib/supabase/templates/updateTemplate.ts @@ -2,13 +2,13 @@ import supabase from "@/lib/supabase/serverClient"; import type { Tables, TablesUpdate } from "@/types/database.types"; /** - * Applies a partial update to an agent template row, refreshing `updated_at`. + * Applies a partial update to an template row, refreshing `updated_at`. * - * @param id - The agent template UUID + * @param id - The template UUID * @param updates - Partial column updates * @returns The updated row, or null on error. */ -export async function updateAgentTemplate( +export async function updateTemplate( id: string, updates: TablesUpdate<"agent_templates">, ): Promise | null> { @@ -23,7 +23,7 @@ export async function updateAgentTemplate( .single(); if (error) { - console.error("Error updating agent template:", error); + console.error("Error updating template:", error); return null; } diff --git a/lib/agent_templates/__tests__/createAgentTemplateHandler.test.ts b/lib/templates/__tests__/createTemplateHandler.test.ts similarity index 54% rename from lib/agent_templates/__tests__/createAgentTemplateHandler.test.ts rename to lib/templates/__tests__/createTemplateHandler.test.ts index 6fc200b14..0def08cb0 100644 --- a/lib/agent_templates/__tests__/createAgentTemplateHandler.test.ts +++ b/lib/templates/__tests__/createTemplateHandler.test.ts @@ -9,27 +9,25 @@ vi.mock("@/lib/auth/validateAuthContext", () => ({ validateAuthContext: vi.fn(), })); -vi.mock("@/lib/supabase/agent_templates/insertAgentTemplate", () => ({ - insertAgentTemplate: vi.fn(), +vi.mock("@/lib/supabase/templates/insertTemplate", () => ({ + insertTemplate: vi.fn(), })); -vi.mock("@/lib/supabase/agent_template_shares/insertAgentTemplateShares", () => ({ - insertAgentTemplateShares: vi.fn(), +vi.mock("@/lib/supabase/template_shares/insertTemplateShares", () => ({ + insertTemplateShares: vi.fn(), })); -vi.mock("@/lib/supabase/agent_templates/selectAgentTemplates", () => ({ - selectAgentTemplates: vi.fn(), +vi.mock("@/lib/supabase/templates/selectTemplates", () => ({ + selectTemplates: vi.fn(), })); -const { createAgentTemplateHandler } = await import("../createAgentTemplateHandler"); +const { createTemplateHandler } = await import("../createTemplateHandler"); const { validateAuthContext } = await import("@/lib/auth/validateAuthContext"); -const { insertAgentTemplate } = await import("@/lib/supabase/agent_templates/insertAgentTemplate"); -const { insertAgentTemplateShares } = await import( - "@/lib/supabase/agent_template_shares/insertAgentTemplateShares" -); -const { selectAgentTemplates } = await import( - "@/lib/supabase/agent_templates/selectAgentTemplates" +const { insertTemplate } = await import("@/lib/supabase/templates/insertTemplate"); +const { insertTemplateShares } = await import( + "@/lib/supabase/template_shares/insertTemplateShares" ); +const { selectTemplates } = await import("@/lib/supabase/templates/selectTemplates"); const ACCOUNT_ID = "11111111-1111-1111-1111-111111111111"; const TEMPLATE_ID = "22222222-2222-2222-2222-222222222222"; @@ -42,7 +40,7 @@ const mockAuthOk = () => }); const makeRequest = (body: unknown) => - new NextRequest("http://localhost/api/agent-templates", { + new NextRequest("http://localhost/api/templates", { method: "POST", headers: { "x-api-key": "k", "content-type": "application/json" }, body: JSON.stringify(body), @@ -57,39 +55,39 @@ const validBody = { share_emails: [], }; -describe("createAgentTemplateHandler", () => { +describe("createTemplateHandler", () => { beforeEach(() => vi.clearAllMocks()); it("creates a template and shares emails when private", async () => { mockAuthOk(); - vi.mocked(insertAgentTemplate).mockResolvedValue({ id: TEMPLATE_ID } as never); - vi.mocked(insertAgentTemplateShares).mockResolvedValue(1); - vi.mocked(selectAgentTemplates).mockResolvedValue([{ id: TEMPLATE_ID } as never]); + vi.mocked(insertTemplate).mockResolvedValue({ id: TEMPLATE_ID } as never); + vi.mocked(insertTemplateShares).mockResolvedValue(1); + vi.mocked(selectTemplates).mockResolvedValue([{ id: TEMPLATE_ID } as never]); - const res = await createAgentTemplateHandler( + const res = await createTemplateHandler( makeRequest({ ...validBody, is_private: true, share_emails: ["a@x.com"] }), ); expect(res.status).toBe(201); expect((await res.json()).template.id).toBe(TEMPLATE_ID); - expect(insertAgentTemplate).toHaveBeenCalledWith( + expect(insertTemplate).toHaveBeenCalledWith( expect.objectContaining({ creator: ACCOUNT_ID, is_private: true }), ); - expect(insertAgentTemplateShares).toHaveBeenCalledWith(TEMPLATE_ID, ["a@x.com"]); - expect(selectAgentTemplates).toHaveBeenCalledWith({ id: TEMPLATE_ID }, ACCOUNT_ID); + expect(insertTemplateShares).toHaveBeenCalledWith(TEMPLATE_ID, ["a@x.com"]); + expect(selectTemplates).toHaveBeenCalledWith({ id: TEMPLATE_ID }, ACCOUNT_ID); }); it("returns 400 when validation fails", async () => { mockAuthOk(); - const res = await createAgentTemplateHandler(makeRequest({ title: "no" })); + const res = await createTemplateHandler(makeRequest({ title: "no" })); expect(res.status).toBe(400); - expect(insertAgentTemplate).not.toHaveBeenCalled(); + expect(insertTemplate).not.toHaveBeenCalled(); }); it("returns 401 when auth fails", async () => { const failure = NextResponse.json({ status: "error", error: "Unauthorized" }, { status: 401 }); vi.mocked(validateAuthContext).mockResolvedValue(failure); - const res = await createAgentTemplateHandler(makeRequest(validBody)); + const res = await createTemplateHandler(makeRequest(validBody)); expect(res).toBe(failure); }); }); diff --git a/lib/templates/__tests__/deleteTemplateHandler.test.ts b/lib/templates/__tests__/deleteTemplateHandler.test.ts new file mode 100644 index 000000000..b4d191f6c --- /dev/null +++ b/lib/templates/__tests__/deleteTemplateHandler.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("@/lib/templates/validateDeleteTemplateRequest", () => ({ + validateDeleteTemplateRequest: vi.fn(), +})); + +vi.mock("@/lib/supabase/templates/deleteTemplate", () => ({ + deleteTemplate: vi.fn(), +})); + +const { deleteTemplateHandler } = await import("../deleteTemplateHandler"); +const { validateDeleteTemplateRequest } = await import( + "@/lib/templates/validateDeleteTemplateRequest" +); +const { deleteTemplate } = await import("@/lib/supabase/templates/deleteTemplate"); + +const ACCOUNT_ID = "11111111-1111-1111-1111-111111111111"; +const TEMPLATE_ID = "22222222-2222-2222-2222-222222222222"; + +describe("deleteTemplateHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns success when delete succeeds", async () => { + vi.mocked(validateDeleteTemplateRequest).mockResolvedValue({ + templateId: TEMPLATE_ID, + accountId: ACCOUNT_ID, + }); + vi.mocked(deleteTemplate).mockResolvedValue(true); + + const req = new NextRequest(`http://localhost/api/templates/${TEMPLATE_ID}`, { + method: "DELETE", + }); + const res = await deleteTemplateHandler(req, Promise.resolve({ id: TEMPLATE_ID })); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ status: "success" }); + }); + + it("returns the validator error response when ownership fails", async () => { + const failure = NextResponse.json({ status: "error", error: "Forbidden" }, { status: 403 }); + vi.mocked(validateDeleteTemplateRequest).mockResolvedValue(failure); + + const req = new NextRequest(`http://localhost/api/templates/${TEMPLATE_ID}`, { + method: "DELETE", + }); + const res = await deleteTemplateHandler(req, Promise.resolve({ id: TEMPLATE_ID })); + expect(res).toBe(failure); + expect(deleteTemplate).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/agent_templates/__tests__/listAgentTemplatesHandler.test.ts b/lib/templates/__tests__/listTemplatesHandler.test.ts similarity index 55% rename from lib/agent_templates/__tests__/listAgentTemplatesHandler.test.ts rename to lib/templates/__tests__/listTemplatesHandler.test.ts index 1248dc029..488c8485d 100644 --- a/lib/agent_templates/__tests__/listAgentTemplatesHandler.test.ts +++ b/lib/templates/__tests__/listTemplatesHandler.test.ts @@ -9,49 +9,45 @@ vi.mock("@/lib/auth/validateAuthContext", () => ({ validateAuthContext: vi.fn(), })); -vi.mock("@/lib/supabase/agent_templates/selectAgentTemplates", () => ({ - selectAgentTemplates: vi.fn(), +vi.mock("@/lib/supabase/templates/selectTemplates", () => ({ + selectTemplates: vi.fn(), })); -const { listAgentTemplatesHandler } = await import("../listAgentTemplatesHandler"); +const { listTemplatesHandler } = await import("../listTemplatesHandler"); const { validateAuthContext } = await import("@/lib/auth/validateAuthContext"); -const { selectAgentTemplates } = await import( - "@/lib/supabase/agent_templates/selectAgentTemplates" -); +const { selectTemplates } = await import("@/lib/supabase/templates/selectTemplates"); const ACCOUNT_ID = "11111111-1111-1111-1111-111111111111"; -describe("listAgentTemplatesHandler", () => { +describe("listTemplatesHandler", () => { beforeEach(() => vi.clearAllMocks()); - it("returns templates fetched via selectAgentTemplates for the authenticated account", async () => { + it("returns templates fetched via selectTemplates for the authenticated account", async () => { vi.mocked(validateAuthContext).mockResolvedValue({ accountId: ACCOUNT_ID, orgId: null, authToken: "k", }); - vi.mocked(selectAgentTemplates).mockResolvedValue([ + vi.mocked(selectTemplates).mockResolvedValue([ { id: "t1", is_favourite: true, shared_emails: [] } as never, ]); - const res = await listAgentTemplatesHandler( - new NextRequest("http://localhost/api/agent-templates", { headers: { "x-api-key": "k" } }), + const res = await listTemplatesHandler( + new NextRequest("http://localhost/api/templates", { headers: { "x-api-key": "k" } }), ); expect(res.status).toBe(200); expect((await res.json()).templates).toHaveLength(1); - expect(selectAgentTemplates).toHaveBeenCalledWith({ accessibleTo: ACCOUNT_ID }, ACCOUNT_ID); + expect(selectTemplates).toHaveBeenCalledWith({ accessibleTo: ACCOUNT_ID }, ACCOUNT_ID); }); it("returns the auth NextResponse when authentication fails", async () => { const failure = NextResponse.json({ status: "error", error: "Unauthorized" }, { status: 401 }); vi.mocked(validateAuthContext).mockResolvedValue(failure); - const res = await listAgentTemplatesHandler( - new NextRequest("http://localhost/api/agent-templates"), - ); + const res = await listTemplatesHandler(new NextRequest("http://localhost/api/templates")); expect(res).toBe(failure); - expect(selectAgentTemplates).not.toHaveBeenCalled(); + expect(selectTemplates).not.toHaveBeenCalled(); }); }); diff --git a/lib/templates/__tests__/toggleTemplateFavoriteHandler.test.ts b/lib/templates/__tests__/toggleTemplateFavoriteHandler.test.ts new file mode 100644 index 000000000..0ad38617c --- /dev/null +++ b/lib/templates/__tests__/toggleTemplateFavoriteHandler.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("@/lib/templates/validateToggleFavoriteRequest", () => ({ + validateToggleFavoriteRequest: vi.fn(), +})); + +vi.mock("@/lib/supabase/template_favorites/insertTemplateFavorite", () => ({ + insertTemplateFavorite: vi.fn(), +})); + +vi.mock("@/lib/supabase/template_favorites/deleteTemplateFavorite", () => ({ + deleteTemplateFavorite: vi.fn(), +})); + +const { toggleTemplateFavoriteHandler } = await import("../toggleTemplateFavoriteHandler"); +const { validateToggleFavoriteRequest } = await import( + "@/lib/templates/validateToggleFavoriteRequest" +); +const { insertTemplateFavorite } = await import( + "@/lib/supabase/template_favorites/insertTemplateFavorite" +); +const { deleteTemplateFavorite } = await import( + "@/lib/supabase/template_favorites/deleteTemplateFavorite" +); + +const ACCOUNT_ID = "11111111-1111-1111-1111-111111111111"; +const TEMPLATE_ID = "22222222-2222-2222-2222-222222222222"; + +describe("toggleTemplateFavoriteHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("inserts a favorite when is_favourite is true", async () => { + vi.mocked(validateToggleFavoriteRequest).mockResolvedValue({ + templateId: TEMPLATE_ID, + accountId: ACCOUNT_ID, + isFavourite: true, + }); + vi.mocked(insertTemplateFavorite).mockResolvedValue(true); + + const req = new NextRequest(`http://localhost/api/templates/${TEMPLATE_ID}/favorite`, { + method: "PUT", + }); + const res = await toggleTemplateFavoriteHandler(req, Promise.resolve({ id: TEMPLATE_ID })); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ status: "success" }); + expect(insertTemplateFavorite).toHaveBeenCalledWith(TEMPLATE_ID, ACCOUNT_ID); + expect(deleteTemplateFavorite).not.toHaveBeenCalled(); + }); + + it("deletes a favorite when is_favourite is false", async () => { + vi.mocked(validateToggleFavoriteRequest).mockResolvedValue({ + templateId: TEMPLATE_ID, + accountId: ACCOUNT_ID, + isFavourite: false, + }); + vi.mocked(deleteTemplateFavorite).mockResolvedValue(true); + + const req = new NextRequest(`http://localhost/api/templates/${TEMPLATE_ID}/favorite`, { + method: "PUT", + }); + const res = await toggleTemplateFavoriteHandler(req, Promise.resolve({ id: TEMPLATE_ID })); + expect(res.status).toBe(200); + expect(deleteTemplateFavorite).toHaveBeenCalledWith(TEMPLATE_ID, ACCOUNT_ID); + expect(insertTemplateFavorite).not.toHaveBeenCalled(); + }); + + it("returns the validator error response on validation failure", async () => { + const failure = NextResponse.json( + { status: "error", error: "is_favourite is required" }, + { status: 400 }, + ); + vi.mocked(validateToggleFavoriteRequest).mockResolvedValue(failure); + + const req = new NextRequest(`http://localhost/api/templates/${TEMPLATE_ID}/favorite`, { + method: "PUT", + }); + const res = await toggleTemplateFavoriteHandler(req, Promise.resolve({ id: TEMPLATE_ID })); + expect(res).toBe(failure); + }); +}); diff --git a/lib/templates/__tests__/updateTemplateHandler.test.ts b/lib/templates/__tests__/updateTemplateHandler.test.ts new file mode 100644 index 000000000..ba15f7083 --- /dev/null +++ b/lib/templates/__tests__/updateTemplateHandler.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("@/lib/templates/validateUpdateTemplateRequest", () => ({ + validateUpdateTemplateRequest: vi.fn(), +})); + +vi.mock("@/lib/supabase/templates/updateTemplate", () => ({ + updateTemplate: vi.fn(), +})); + +vi.mock("@/lib/supabase/template_shares/deleteTemplateShares", () => ({ + deleteTemplateShares: vi.fn(), +})); + +vi.mock("@/lib/supabase/template_shares/insertTemplateShares", () => ({ + insertTemplateShares: vi.fn(), +})); + +vi.mock("@/lib/supabase/templates/selectTemplates", () => ({ + selectTemplates: vi.fn(), +})); + +const { updateTemplateHandler } = await import("../updateTemplateHandler"); +const { validateUpdateTemplateRequest } = await import( + "@/lib/templates/validateUpdateTemplateRequest" +); +const { updateTemplate } = await import("@/lib/supabase/templates/updateTemplate"); +const { deleteTemplateShares } = await import( + "@/lib/supabase/template_shares/deleteTemplateShares" +); +const { insertTemplateShares } = await import( + "@/lib/supabase/template_shares/insertTemplateShares" +); +const { selectTemplates } = await import("@/lib/supabase/templates/selectTemplates"); + +const ACCOUNT_ID = "11111111-1111-1111-1111-111111111111"; +const TEMPLATE_ID = "22222222-2222-2222-2222-222222222222"; + +describe("updateTemplateHandler", () => { + beforeEach(() => vi.clearAllMocks()); + + it("updates the template and replaces shares when share_emails provided", async () => { + vi.mocked(validateUpdateTemplateRequest).mockResolvedValue({ + templateId: TEMPLATE_ID, + accountId: ACCOUNT_ID, + body: { title: "New Title", share_emails: ["x@y.com"] }, + }); + vi.mocked(updateTemplate).mockResolvedValue({ id: TEMPLATE_ID } as never); + vi.mocked(deleteTemplateShares).mockResolvedValue(undefined); + vi.mocked(insertTemplateShares).mockResolvedValue(1); + vi.mocked(selectTemplates).mockResolvedValue([{ id: TEMPLATE_ID } as never]); + + const req = new NextRequest(`http://localhost/api/templates/${TEMPLATE_ID}`, { + method: "PATCH", + }); + const res = await updateTemplateHandler(req, Promise.resolve({ id: TEMPLATE_ID })); + + expect(res.status).toBe(200); + expect(updateTemplate).toHaveBeenCalledWith(TEMPLATE_ID, { title: "New Title" }); + expect(deleteTemplateShares).toHaveBeenCalledWith(TEMPLATE_ID); + expect(insertTemplateShares).toHaveBeenCalledWith(TEMPLATE_ID, ["x@y.com"]); + expect(selectTemplates).toHaveBeenCalledWith({ id: TEMPLATE_ID }, ACCOUNT_ID); + }); + + it("returns the validator error response when validation fails", async () => { + const failure = NextResponse.json({ status: "error", error: "Forbidden" }, { status: 403 }); + vi.mocked(validateUpdateTemplateRequest).mockResolvedValue(failure); + + const req = new NextRequest(`http://localhost/api/templates/${TEMPLATE_ID}`, { + method: "PATCH", + }); + const res = await updateTemplateHandler(req, Promise.resolve({ id: TEMPLATE_ID })); + + expect(res).toBe(failure); + expect(updateTemplate).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/agent_templates/createAgentTemplateHandler.ts b/lib/templates/createTemplateHandler.ts similarity index 56% rename from lib/agent_templates/createAgentTemplateHandler.ts rename to lib/templates/createTemplateHandler.ts index 7e534dc92..369c57794 100644 --- a/lib/agent_templates/createAgentTemplateHandler.ts +++ b/lib/templates/createTemplateHandler.ts @@ -2,30 +2,30 @@ import { NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import { safeParseJson } from "@/lib/networking/safeParseJson"; -import { validateCreateAgentTemplateBody } from "@/lib/agent_templates/validateCreateAgentTemplateBody"; -import { insertAgentTemplate } from "@/lib/supabase/agent_templates/insertAgentTemplate"; -import { insertAgentTemplateShares } from "@/lib/supabase/agent_template_shares/insertAgentTemplateShares"; -import { selectAgentTemplates } from "@/lib/supabase/agent_templates/selectAgentTemplates"; +import { validateCreateTemplateBody } from "@/lib/templates/validateCreateTemplateBody"; +import { insertTemplate } from "@/lib/supabase/templates/insertTemplate"; +import { insertTemplateShares } from "@/lib/supabase/template_shares/insertTemplateShares"; +import { selectTemplates } from "@/lib/supabase/templates/selectTemplates"; /** - * Handler for POST /api/agent-templates. + * Handler for POST /api/templates. * - * Creates an agent template owned by the authenticated account. When + * Creates an template owned by the authenticated account. When * `is_private=true`, supplied `share_emails` are resolved to accounts and - * upserted into `agent_template_shares`. + * upserted into template_shares. */ -export async function createAgentTemplateHandler(request: NextRequest): Promise { +export async function createTemplateHandler(request: NextRequest): Promise { try { const authResult = await validateAuthContext(request); if (authResult instanceof NextResponse) return authResult; const body = await safeParseJson(request); - const parsedBody = validateCreateAgentTemplateBody(body); + const parsedBody = validateCreateTemplateBody(body); if (parsedBody instanceof NextResponse) return parsedBody; const accountId = authResult.accountId; - const inserted = await insertAgentTemplate({ + const inserted = await insertTemplate({ title: parsedBody.title, description: parsedBody.description, prompt: parsedBody.prompt, @@ -36,23 +36,23 @@ export async function createAgentTemplateHandler(request: NextRequest): Promise< if (!inserted) { return NextResponse.json( - { status: "error", error: "Failed to create agent template" }, + { status: "error", error: "Failed to create template" }, { status: 500, headers: getCorsHeaders() }, ); } if (parsedBody.is_private && parsedBody.share_emails.length > 0) { - await insertAgentTemplateShares(inserted.id, parsedBody.share_emails); + await insertTemplateShares(inserted.id, parsedBody.share_emails); } - const [template] = await selectAgentTemplates({ id: inserted.id }, accountId); + const [template] = await selectTemplates({ id: inserted.id }, accountId); return NextResponse.json( { status: "success", template: template ?? null }, { status: 201, headers: getCorsHeaders() }, ); } catch (error) { - console.error("[ERROR] createAgentTemplateHandler:", error); + console.error("[ERROR] createTemplateHandler:", error); return NextResponse.json( { status: "error", error: "Internal server error" }, { status: 500, headers: getCorsHeaders() }, diff --git a/lib/agent_templates/deleteAgentTemplateHandler.ts b/lib/templates/deleteTemplateHandler.ts similarity index 58% rename from lib/agent_templates/deleteAgentTemplateHandler.ts rename to lib/templates/deleteTemplateHandler.ts index 1f7ed0554..a55fea259 100644 --- a/lib/agent_templates/deleteAgentTemplateHandler.ts +++ b/lib/templates/deleteTemplateHandler.ts @@ -1,38 +1,38 @@ import { NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { validateDeleteAgentTemplateRequest } from "@/lib/agent_templates/validateDeleteAgentTemplateRequest"; -import { deleteAgentTemplate } from "@/lib/supabase/agent_templates/deleteAgentTemplate"; +import { validateDeleteTemplateRequest } from "@/lib/templates/validateDeleteTemplateRequest"; +import { deleteTemplate } from "@/lib/supabase/templates/deleteTemplate"; /** - * Handler for DELETE /api/agent-templates/{id}. + * Handler for DELETE /api/templates/{id}. * - * Permanently removes the agent template. Caller must be the template's + * Permanently removes the template. Caller must be the template's * creator. * * @param request - The incoming request * @param params - Route params containing the template id * @returns A 200 NextResponse with `{ status: "success" }`, or an error. */ -export async function deleteAgentTemplateHandler( +export async function deleteTemplateHandler( request: NextRequest, params: Promise<{ id: string }>, ): Promise { try { const { id } = await params; - const validated = await validateDeleteAgentTemplateRequest(request, id); + const validated = await validateDeleteTemplateRequest(request, id); if (validated instanceof NextResponse) return validated; - const ok = await deleteAgentTemplate(validated.templateId); + const ok = await deleteTemplate(validated.templateId); if (!ok) { return NextResponse.json( - { status: "error", error: "Failed to delete agent template" }, + { status: "error", error: "Failed to delete template" }, { status: 500, headers: getCorsHeaders() }, ); } return NextResponse.json({ status: "success" }, { status: 200, headers: getCorsHeaders() }); } catch (error) { - console.error("[ERROR] deleteAgentTemplateHandler:", error); + console.error("[ERROR] deleteTemplateHandler:", error); return NextResponse.json( { status: "error", error: "Internal server error" }, { status: 500, headers: getCorsHeaders() }, diff --git a/lib/agent_templates/listAgentTemplatesHandler.ts b/lib/templates/listTemplatesHandler.ts similarity index 65% rename from lib/agent_templates/listAgentTemplatesHandler.ts rename to lib/templates/listTemplatesHandler.ts index ed4f5882c..9b852091b 100644 --- a/lib/agent_templates/listAgentTemplatesHandler.ts +++ b/lib/templates/listTemplatesHandler.ts @@ -1,30 +1,30 @@ import { NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; -import { selectAgentTemplates } from "@/lib/supabase/agent_templates/selectAgentTemplates"; +import { selectTemplates } from "@/lib/supabase/templates/selectTemplates"; /** - * Handler for GET /api/agent-templates. + * Handler for GET /api/templates. * - * Returns every agent template the authenticated account can see (own, + * Returns every template the authenticated account can see (own, * public, shared) fully shaped: `creator` flat with `is_admin`, * `is_favourite`, and `shared_emails` (only for templates the caller owns * and that are private). */ -export async function listAgentTemplatesHandler(request: NextRequest): Promise { +export async function listTemplatesHandler(request: NextRequest): Promise { try { const authResult = await validateAuthContext(request); if (authResult instanceof NextResponse) return authResult; const accountId = authResult.accountId; - const templates = await selectAgentTemplates({ accessibleTo: accountId }, accountId); + const templates = await selectTemplates({ accessibleTo: accountId }, accountId); return NextResponse.json( { status: "success", templates }, { status: 200, headers: getCorsHeaders() }, ); } catch (error) { - console.error("[ERROR] listAgentTemplatesHandler:", error); + console.error("[ERROR] listTemplatesHandler:", error); return NextResponse.json( { status: "error", error: "Internal server error" }, { status: 500, headers: getCorsHeaders() }, diff --git a/lib/agent_templates/toggleAgentTemplateFavoriteHandler.ts b/lib/templates/toggleTemplateFavoriteHandler.ts similarity index 66% rename from lib/agent_templates/toggleAgentTemplateFavoriteHandler.ts rename to lib/templates/toggleTemplateFavoriteHandler.ts index 3c60c98b1..8a1119e2e 100644 --- a/lib/agent_templates/toggleAgentTemplateFavoriteHandler.ts +++ b/lib/templates/toggleTemplateFavoriteHandler.ts @@ -1,11 +1,11 @@ import { NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { validateToggleFavoriteRequest } from "@/lib/agent_templates/validateToggleFavoriteRequest"; -import { insertAgentTemplateFavorite } from "@/lib/supabase/agent_template_favorites/insertAgentTemplateFavorite"; -import { deleteAgentTemplateFavorite } from "@/lib/supabase/agent_template_favorites/deleteAgentTemplateFavorite"; +import { validateToggleFavoriteRequest } from "@/lib/templates/validateToggleFavoriteRequest"; +import { insertTemplateFavorite } from "@/lib/supabase/template_favorites/insertTemplateFavorite"; +import { deleteTemplateFavorite } from "@/lib/supabase/template_favorites/deleteTemplateFavorite"; /** - * Handler for PUT /api/agent-templates/{id}/favorite. + * Handler for PUT /api/templates/{id}/favorite. * * Idempotently toggles the caller's favorite status for the template: * - `is_favourite=true` upserts the favorite row @@ -15,7 +15,7 @@ import { deleteAgentTemplateFavorite } from "@/lib/supabase/agent_template_favor * @param params - Route params containing the template id * @returns A 200 NextResponse with `{ status: "success" }`, or an error. */ -export async function toggleAgentTemplateFavoriteHandler( +export async function toggleTemplateFavoriteHandler( request: NextRequest, params: Promise<{ id: string }>, ): Promise { @@ -27,8 +27,8 @@ export async function toggleAgentTemplateFavoriteHandler( const { templateId, accountId, isFavourite } = validated; const ok = isFavourite - ? await insertAgentTemplateFavorite(templateId, accountId) - : await deleteAgentTemplateFavorite(templateId, accountId); + ? await insertTemplateFavorite(templateId, accountId) + : await deleteTemplateFavorite(templateId, accountId); if (!ok) { return NextResponse.json( @@ -39,7 +39,7 @@ export async function toggleAgentTemplateFavoriteHandler( return NextResponse.json({ status: "success" }, { status: 200, headers: getCorsHeaders() }); } catch (error) { - console.error("[ERROR] toggleAgentTemplateFavoriteHandler:", error); + console.error("[ERROR] toggleTemplateFavoriteHandler:", error); return NextResponse.json( { status: "error", error: "Internal server error" }, { status: 500, headers: getCorsHeaders() }, diff --git a/lib/agent_templates/updateAgentTemplateHandler.ts b/lib/templates/updateTemplateHandler.ts similarity index 59% rename from lib/agent_templates/updateAgentTemplateHandler.ts rename to lib/templates/updateTemplateHandler.ts index 3dc76b358..796b79d79 100644 --- a/lib/agent_templates/updateAgentTemplateHandler.ts +++ b/lib/templates/updateTemplateHandler.ts @@ -1,25 +1,25 @@ import { NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { validateUpdateAgentTemplateRequest } from "@/lib/agent_templates/validateUpdateAgentTemplateRequest"; -import { updateAgentTemplate } from "@/lib/supabase/agent_templates/updateAgentTemplate"; -import { deleteAgentTemplateShares } from "@/lib/supabase/agent_template_shares/deleteAgentTemplateShares"; -import { insertAgentTemplateShares } from "@/lib/supabase/agent_template_shares/insertAgentTemplateShares"; -import { selectAgentTemplates } from "@/lib/supabase/agent_templates/selectAgentTemplates"; +import { validateUpdateTemplateRequest } from "@/lib/templates/validateUpdateTemplateRequest"; +import { updateTemplate } from "@/lib/supabase/templates/updateTemplate"; +import { deleteTemplateShares } from "@/lib/supabase/template_shares/deleteTemplateShares"; +import { insertTemplateShares } from "@/lib/supabase/template_shares/insertTemplateShares"; +import { selectTemplates } from "@/lib/supabase/templates/selectTemplates"; import type { TablesUpdate } from "@/types/database.types"; /** - * Handler for PATCH /api/agent-templates/{id}. + * Handler for PATCH /api/templates/{id}. * - * Applies a partial update to an agent template the caller owns. When + * Applies a partial update to an template the caller owns. When * `share_emails` is provided, existing shares are wiped and re-inserted. */ -export async function updateAgentTemplateHandler( +export async function updateTemplateHandler( request: NextRequest, params: Promise<{ id: string }>, ): Promise { try { const { id } = await params; - const validated = await validateUpdateAgentTemplateRequest(request, id); + const validated = await validateUpdateTemplateRequest(request, id); if (validated instanceof NextResponse) return validated; const { templateId, accountId, body } = validated; @@ -32,10 +32,10 @@ export async function updateAgentTemplateHandler( if (typeof body.is_private !== "undefined") updates.is_private = body.is_private; if (Object.keys(updates).length > 0) { - const updated = await updateAgentTemplate(templateId, updates); + const updated = await updateTemplate(templateId, updates); if (!updated) { return NextResponse.json( - { status: "error", error: "Failed to update agent template" }, + { status: "error", error: "Failed to update template" }, { status: 500, headers: getCorsHeaders() }, ); } @@ -45,20 +45,20 @@ export async function updateAgentTemplateHandler( // RPC; for now both helpers throw on DB error so the outer catch returns // a 500. if (typeof body.share_emails !== "undefined") { - await deleteAgentTemplateShares(templateId); + await deleteTemplateShares(templateId); if (body.share_emails.length > 0) { - await insertAgentTemplateShares(templateId, body.share_emails); + await insertTemplateShares(templateId, body.share_emails); } } - const [template] = await selectAgentTemplates({ id: templateId }, accountId); + const [template] = await selectTemplates({ id: templateId }, accountId); return NextResponse.json( { status: "success", template: template ?? null }, { status: 200, headers: getCorsHeaders() }, ); } catch (error) { - console.error("[ERROR] updateAgentTemplateHandler:", error); + console.error("[ERROR] updateTemplateHandler:", error); return NextResponse.json( { status: "error", error: "Internal server error" }, { status: 500, headers: getCorsHeaders() }, diff --git a/lib/agent_templates/validateCreateAgentTemplateBody.ts b/lib/templates/validateCreateTemplateBody.ts similarity index 78% rename from lib/agent_templates/validateCreateAgentTemplateBody.ts rename to lib/templates/validateCreateTemplateBody.ts index fcccf2f4b..ffdeb29c7 100644 --- a/lib/agent_templates/validateCreateAgentTemplateBody.ts +++ b/lib/templates/validateCreateTemplateBody.ts @@ -2,7 +2,7 @@ import { NextResponse } from "next/server"; import { z } from "zod"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -export const createAgentTemplateBodySchema = z.object({ +export const createTemplateBodySchema = z.object({ title: z .string({ message: "title is required" }) .min(3, "title must be at least 3 characters") @@ -22,18 +22,16 @@ export const createAgentTemplateBodySchema = z.object({ .default([]), }); -export type CreateAgentTemplateBody = z.infer; +export type CreateTemplateBody = z.infer; /** - * Validates the JSON body for POST /api/agent-templates. + * Validates the JSON body for POST /api/templates. * * @param body - The raw JSON body * @returns A NextResponse with a 400 error or the parsed body on success. */ -export function validateCreateAgentTemplateBody( - body: unknown, -): NextResponse | CreateAgentTemplateBody { - const result = createAgentTemplateBodySchema.safeParse(body); +export function validateCreateTemplateBody(body: unknown): NextResponse | CreateTemplateBody { + const result = createTemplateBodySchema.safeParse(body); if (!result.success) { const firstError = result.error.issues[0]; diff --git a/lib/agent_templates/validateDeleteAgentTemplateRequest.ts b/lib/templates/validateDeleteTemplateRequest.ts similarity index 68% rename from lib/agent_templates/validateDeleteAgentTemplateRequest.ts rename to lib/templates/validateDeleteTemplateRequest.ts index 159694f0e..720981911 100644 --- a/lib/agent_templates/validateDeleteAgentTemplateRequest.ts +++ b/lib/templates/validateDeleteTemplateRequest.ts @@ -2,21 +2,21 @@ import { NextRequest, NextResponse } from "next/server"; import { validateAccountParams } from "@/lib/accounts/validateAccountParams"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { selectAgentTemplates } from "@/lib/supabase/agent_templates/selectAgentTemplates"; +import { selectTemplates } from "@/lib/supabase/templates/selectTemplates"; -export interface ValidatedDeleteAgentTemplateRequest { +export interface ValidatedDeleteTemplateRequest { templateId: string; accountId: string; } /** - * Validates DELETE /api/agent-templates/{id}: auth, id format, and that the + * Validates DELETE /api/templates/{id}: auth, id format, and that the * caller is the template's creator. */ -export async function validateDeleteAgentTemplateRequest( +export async function validateDeleteTemplateRequest( request: NextRequest, id: string, -): Promise { +): Promise { const authResult = await validateAuthContext(request); if (authResult instanceof NextResponse) return authResult; @@ -26,10 +26,10 @@ export async function validateDeleteAgentTemplateRequest( const templateId = validatedParams.id; const accountId = authResult.accountId; - const [existing] = await selectAgentTemplates({ id: templateId }); + const [existing] = await selectTemplates({ id: templateId }); if (!existing) { return NextResponse.json( - { status: "error", error: "Agent template not found" }, + { status: "error", error: "Template not found" }, { status: 404, headers: getCorsHeaders() }, ); } diff --git a/lib/agent_templates/validateToggleFavoriteRequest.ts b/lib/templates/validateToggleFavoriteRequest.ts similarity index 82% rename from lib/agent_templates/validateToggleFavoriteRequest.ts rename to lib/templates/validateToggleFavoriteRequest.ts index 10cd94e98..3c3a1eeb8 100644 --- a/lib/agent_templates/validateToggleFavoriteRequest.ts +++ b/lib/templates/validateToggleFavoriteRequest.ts @@ -4,8 +4,8 @@ import { validateAccountParams } from "@/lib/accounts/validateAccountParams"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { safeParseJson } from "@/lib/networking/safeParseJson"; -import { selectAgentTemplates } from "@/lib/supabase/agent_templates/selectAgentTemplates"; -import { selectAgentTemplateShares } from "@/lib/supabase/agent_template_shares/selectAgentTemplateShares"; +import { selectTemplates } from "@/lib/supabase/templates/selectTemplates"; +import { selectTemplateShares } from "@/lib/supabase/template_shares/selectTemplateShares"; export const toggleFavoriteBodySchema = z.object({ is_favourite: z.boolean({ message: "is_favourite is required" }), @@ -20,7 +20,7 @@ export interface ValidatedToggleFavoriteRequest { } /** - * Validates PUT /api/agent-templates/{id}/favorite: auth, id format, body, + * Validates PUT /api/templates/{id}/favorite: auth, id format, body, * and that the caller can see the template (own, public, or shared). */ export async function validateToggleFavoriteRequest( @@ -50,10 +50,10 @@ export async function validateToggleFavoriteRequest( const templateId = validatedParams.id; const accountId = authResult.accountId; - const [existing] = await selectAgentTemplates({ id: templateId }); + const [existing] = await selectTemplates({ id: templateId }); if (!existing) { return NextResponse.json( - { status: "error", error: "Agent template not found" }, + { status: "error", error: "Template not found" }, { status: 404, headers: getCorsHeaders() }, ); } @@ -61,7 +61,7 @@ export async function validateToggleFavoriteRequest( const isOwner = existing.creator?.id === accountId; let canAccess = isOwner || !existing.is_private; if (!canAccess) { - const shares = await selectAgentTemplateShares([templateId]); + const shares = await selectTemplateShares([templateId]); canAccess = shares.some(s => s.user_id === accountId); } if (!canAccess) { diff --git a/lib/agent_templates/validateUpdateAgentTemplateRequest.ts b/lib/templates/validateUpdateTemplateRequest.ts similarity index 73% rename from lib/agent_templates/validateUpdateAgentTemplateRequest.ts rename to lib/templates/validateUpdateTemplateRequest.ts index f5499cccd..4147a90f0 100644 --- a/lib/agent_templates/validateUpdateAgentTemplateRequest.ts +++ b/lib/templates/validateUpdateTemplateRequest.ts @@ -4,9 +4,9 @@ import { validateAccountParams } from "@/lib/accounts/validateAccountParams"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { safeParseJson } from "@/lib/networking/safeParseJson"; -import { selectAgentTemplates } from "@/lib/supabase/agent_templates/selectAgentTemplates"; +import { selectTemplates } from "@/lib/supabase/templates/selectTemplates"; -export const updateAgentTemplateBodySchema = z +export const updateTemplateBodySchema = z .object({ title: z.string().min(3).max(50).optional(), description: z.string().min(10).max(200).optional(), @@ -19,22 +19,22 @@ export const updateAgentTemplateBodySchema = z message: "At least one field to update must be provided", }); -export type UpdateAgentTemplateBody = z.infer; +export type UpdateTemplateBody = z.infer; -export interface ValidatedUpdateAgentTemplateRequest { +export interface ValidatedUpdateTemplateRequest { templateId: string; accountId: string; - body: UpdateAgentTemplateBody; + body: UpdateTemplateBody; } /** - * Validates PATCH /api/agent-templates/{id}: auth, id format, body, and that + * Validates PATCH /api/templates/{id}: auth, id format, body, and that * the caller is the template's creator. */ -export async function validateUpdateAgentTemplateRequest( +export async function validateUpdateTemplateRequest( request: NextRequest, id: string, -): Promise { +): Promise { const authResult = await validateAuthContext(request); if (authResult instanceof NextResponse) return authResult; @@ -42,7 +42,7 @@ export async function validateUpdateAgentTemplateRequest( if (validatedParams instanceof NextResponse) return validatedParams; const body = await safeParseJson(request); - const parsedBody = updateAgentTemplateBodySchema.safeParse(body); + const parsedBody = updateTemplateBodySchema.safeParse(body); if (!parsedBody.success) { const firstError = parsedBody.error.issues[0]; return NextResponse.json( @@ -58,10 +58,10 @@ export async function validateUpdateAgentTemplateRequest( const templateId = validatedParams.id; const accountId = authResult.accountId; - const [existing] = await selectAgentTemplates({ id: templateId }); + const [existing] = await selectTemplates({ id: templateId }); if (!existing) { return NextResponse.json( - { status: "error", error: "Agent template not found" }, + { status: "error", error: "Template not found" }, { status: 404, headers: getCorsHeaders() }, ); } From 513b81231b0771f9f32a1b069fbe63ef9b5df1c7 Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Tue, 12 May 2026 06:05:26 +0530 Subject: [PATCH 09/32] refactor(api): nest route under /api/agents/templates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit URL: /api/templates → /api/agents/templates Route folder: app/api/templates → app/api/agents/templates Lib structure unchanged (lib/templates, lib/supabase/templates, lib/supabase/template_{favorites,shares}) — those are code-organization folders, not URL paths. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/api/{ => agents}/templates/[id]/favorite/route.ts | 2 +- app/api/{ => agents}/templates/[id]/route.ts | 4 ++-- app/api/{ => agents}/templates/route.ts | 4 ++-- lib/const.ts | 2 +- lib/templates/__tests__/createTemplateHandler.test.ts | 2 +- lib/templates/__tests__/deleteTemplateHandler.test.ts | 4 ++-- lib/templates/__tests__/listTemplatesHandler.test.ts | 6 ++++-- .../__tests__/toggleTemplateFavoriteHandler.test.ts | 6 +++--- lib/templates/__tests__/updateTemplateHandler.test.ts | 4 ++-- lib/templates/createTemplateHandler.ts | 2 +- lib/templates/deleteTemplateHandler.ts | 2 +- lib/templates/listTemplatesHandler.ts | 2 +- lib/templates/toggleTemplateFavoriteHandler.ts | 2 +- lib/templates/updateTemplateHandler.ts | 2 +- lib/templates/validateCreateTemplateBody.ts | 2 +- lib/templates/validateDeleteTemplateRequest.ts | 2 +- lib/templates/validateToggleFavoriteRequest.ts | 2 +- lib/templates/validateUpdateTemplateRequest.ts | 2 +- 18 files changed, 27 insertions(+), 25 deletions(-) rename app/api/{ => agents}/templates/[id]/favorite/route.ts (96%) rename app/api/{ => agents}/templates/[id]/route.ts (95%) rename app/api/{ => agents}/templates/route.ts (96%) diff --git a/app/api/templates/[id]/favorite/route.ts b/app/api/agents/templates/[id]/favorite/route.ts similarity index 96% rename from app/api/templates/[id]/favorite/route.ts rename to app/api/agents/templates/[id]/favorite/route.ts index 8103ae004..e663a929a 100644 --- a/app/api/templates/[id]/favorite/route.ts +++ b/app/api/agents/templates/[id]/favorite/route.ts @@ -15,7 +15,7 @@ export async function OPTIONS() { } /** - * PUT /api/templates/{id}/favorite + * PUT /api/agents/templates/{id}/favorite * * Idempotently sets whether the authenticated account has favorited the * template: `{ is_favourite: true }` upserts a row, `false` deletes it. diff --git a/app/api/templates/[id]/route.ts b/app/api/agents/templates/[id]/route.ts similarity index 95% rename from app/api/templates/[id]/route.ts rename to app/api/agents/templates/[id]/route.ts index f6b6833dd..a170de32b 100644 --- a/app/api/templates/[id]/route.ts +++ b/app/api/agents/templates/[id]/route.ts @@ -16,7 +16,7 @@ export async function OPTIONS() { } /** - * PATCH /api/templates/{id} + * PATCH /api/agents/templates/{id} * * Updates one or more fields on an template the authenticated account * owns. Supplying `share_emails` replaces existing shares. @@ -34,7 +34,7 @@ export async function PATCH( } /** - * DELETE /api/templates/{id} + * DELETE /api/agents/templates/{id} * * Permanently removes an template the authenticated account owns. * diff --git a/app/api/templates/route.ts b/app/api/agents/templates/route.ts similarity index 96% rename from app/api/templates/route.ts rename to app/api/agents/templates/route.ts index 161cfb84d..b8d6e4fec 100644 --- a/app/api/templates/route.ts +++ b/app/api/agents/templates/route.ts @@ -16,7 +16,7 @@ export async function OPTIONS() { } /** - * GET /api/templates + * GET /api/agents/templates * * Returns every template visible to the authenticated account (own, * public, and shared) with an embedded creator block (id/name/image/is_admin), @@ -30,7 +30,7 @@ export async function GET(request: NextRequest): Promise { } /** - * POST /api/templates + * POST /api/agents/templates * * Creates a new template owned by the authenticated account. When * `is_private=true`, `share_emails` recipients are upserted into the shares diff --git a/lib/const.ts b/lib/const.ts index 8d4e1f43a..c9b3388a7 100644 --- a/lib/const.ts +++ b/lib/const.ts @@ -51,7 +51,7 @@ export const SNAPSHOT_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000; /** * Email addresses with platform-admin privileges. * - * Surfaced as `creator.is_admin` in `/api/templates` so clients can flag + * Surfaced as `creator.is_admin` in `/api/agents/templates` so clients can flag * official Recoup templates. Mirrors `chat/lib/admin.ts`. */ export const ADMIN_EMAILS: readonly string[] = ["sidney+1@recoupable.com"] as const; diff --git a/lib/templates/__tests__/createTemplateHandler.test.ts b/lib/templates/__tests__/createTemplateHandler.test.ts index 0def08cb0..fd6ded3ef 100644 --- a/lib/templates/__tests__/createTemplateHandler.test.ts +++ b/lib/templates/__tests__/createTemplateHandler.test.ts @@ -40,7 +40,7 @@ const mockAuthOk = () => }); const makeRequest = (body: unknown) => - new NextRequest("http://localhost/api/templates", { + new NextRequest("http://localhost/api/agents/templates", { method: "POST", headers: { "x-api-key": "k", "content-type": "application/json" }, body: JSON.stringify(body), diff --git a/lib/templates/__tests__/deleteTemplateHandler.test.ts b/lib/templates/__tests__/deleteTemplateHandler.test.ts index b4d191f6c..e078fbb3e 100644 --- a/lib/templates/__tests__/deleteTemplateHandler.test.ts +++ b/lib/templates/__tests__/deleteTemplateHandler.test.ts @@ -34,7 +34,7 @@ describe("deleteTemplateHandler", () => { }); vi.mocked(deleteTemplate).mockResolvedValue(true); - const req = new NextRequest(`http://localhost/api/templates/${TEMPLATE_ID}`, { + const req = new NextRequest(`http://localhost/api/agents/templates/${TEMPLATE_ID}`, { method: "DELETE", }); const res = await deleteTemplateHandler(req, Promise.resolve({ id: TEMPLATE_ID })); @@ -46,7 +46,7 @@ describe("deleteTemplateHandler", () => { const failure = NextResponse.json({ status: "error", error: "Forbidden" }, { status: 403 }); vi.mocked(validateDeleteTemplateRequest).mockResolvedValue(failure); - const req = new NextRequest(`http://localhost/api/templates/${TEMPLATE_ID}`, { + const req = new NextRequest(`http://localhost/api/agents/templates/${TEMPLATE_ID}`, { method: "DELETE", }); const res = await deleteTemplateHandler(req, Promise.resolve({ id: TEMPLATE_ID })); diff --git a/lib/templates/__tests__/listTemplatesHandler.test.ts b/lib/templates/__tests__/listTemplatesHandler.test.ts index 488c8485d..b096f6fd9 100644 --- a/lib/templates/__tests__/listTemplatesHandler.test.ts +++ b/lib/templates/__tests__/listTemplatesHandler.test.ts @@ -33,7 +33,7 @@ describe("listTemplatesHandler", () => { ]); const res = await listTemplatesHandler( - new NextRequest("http://localhost/api/templates", { headers: { "x-api-key": "k" } }), + new NextRequest("http://localhost/api/agents/templates", { headers: { "x-api-key": "k" } }), ); expect(res.status).toBe(200); @@ -45,7 +45,9 @@ describe("listTemplatesHandler", () => { const failure = NextResponse.json({ status: "error", error: "Unauthorized" }, { status: 401 }); vi.mocked(validateAuthContext).mockResolvedValue(failure); - const res = await listTemplatesHandler(new NextRequest("http://localhost/api/templates")); + const res = await listTemplatesHandler( + new NextRequest("http://localhost/api/agents/templates"), + ); expect(res).toBe(failure); expect(selectTemplates).not.toHaveBeenCalled(); diff --git a/lib/templates/__tests__/toggleTemplateFavoriteHandler.test.ts b/lib/templates/__tests__/toggleTemplateFavoriteHandler.test.ts index 0ad38617c..d26a5036b 100644 --- a/lib/templates/__tests__/toggleTemplateFavoriteHandler.test.ts +++ b/lib/templates/__tests__/toggleTemplateFavoriteHandler.test.ts @@ -44,7 +44,7 @@ describe("toggleTemplateFavoriteHandler", () => { }); vi.mocked(insertTemplateFavorite).mockResolvedValue(true); - const req = new NextRequest(`http://localhost/api/templates/${TEMPLATE_ID}/favorite`, { + const req = new NextRequest(`http://localhost/api/agents/templates/${TEMPLATE_ID}/favorite`, { method: "PUT", }); const res = await toggleTemplateFavoriteHandler(req, Promise.resolve({ id: TEMPLATE_ID })); @@ -62,7 +62,7 @@ describe("toggleTemplateFavoriteHandler", () => { }); vi.mocked(deleteTemplateFavorite).mockResolvedValue(true); - const req = new NextRequest(`http://localhost/api/templates/${TEMPLATE_ID}/favorite`, { + const req = new NextRequest(`http://localhost/api/agents/templates/${TEMPLATE_ID}/favorite`, { method: "PUT", }); const res = await toggleTemplateFavoriteHandler(req, Promise.resolve({ id: TEMPLATE_ID })); @@ -78,7 +78,7 @@ describe("toggleTemplateFavoriteHandler", () => { ); vi.mocked(validateToggleFavoriteRequest).mockResolvedValue(failure); - const req = new NextRequest(`http://localhost/api/templates/${TEMPLATE_ID}/favorite`, { + const req = new NextRequest(`http://localhost/api/agents/templates/${TEMPLATE_ID}/favorite`, { method: "PUT", }); const res = await toggleTemplateFavoriteHandler(req, Promise.resolve({ id: TEMPLATE_ID })); diff --git a/lib/templates/__tests__/updateTemplateHandler.test.ts b/lib/templates/__tests__/updateTemplateHandler.test.ts index ba15f7083..15d0039d6 100644 --- a/lib/templates/__tests__/updateTemplateHandler.test.ts +++ b/lib/templates/__tests__/updateTemplateHandler.test.ts @@ -55,7 +55,7 @@ describe("updateTemplateHandler", () => { vi.mocked(insertTemplateShares).mockResolvedValue(1); vi.mocked(selectTemplates).mockResolvedValue([{ id: TEMPLATE_ID } as never]); - const req = new NextRequest(`http://localhost/api/templates/${TEMPLATE_ID}`, { + const req = new NextRequest(`http://localhost/api/agents/templates/${TEMPLATE_ID}`, { method: "PATCH", }); const res = await updateTemplateHandler(req, Promise.resolve({ id: TEMPLATE_ID })); @@ -71,7 +71,7 @@ describe("updateTemplateHandler", () => { const failure = NextResponse.json({ status: "error", error: "Forbidden" }, { status: 403 }); vi.mocked(validateUpdateTemplateRequest).mockResolvedValue(failure); - const req = new NextRequest(`http://localhost/api/templates/${TEMPLATE_ID}`, { + const req = new NextRequest(`http://localhost/api/agents/templates/${TEMPLATE_ID}`, { method: "PATCH", }); const res = await updateTemplateHandler(req, Promise.resolve({ id: TEMPLATE_ID })); diff --git a/lib/templates/createTemplateHandler.ts b/lib/templates/createTemplateHandler.ts index 369c57794..e0baf9187 100644 --- a/lib/templates/createTemplateHandler.ts +++ b/lib/templates/createTemplateHandler.ts @@ -8,7 +8,7 @@ import { insertTemplateShares } from "@/lib/supabase/template_shares/insertTempl import { selectTemplates } from "@/lib/supabase/templates/selectTemplates"; /** - * Handler for POST /api/templates. + * Handler for POST /api/agents/templates. * * Creates an template owned by the authenticated account. When * `is_private=true`, supplied `share_emails` are resolved to accounts and diff --git a/lib/templates/deleteTemplateHandler.ts b/lib/templates/deleteTemplateHandler.ts index a55fea259..b12acda0d 100644 --- a/lib/templates/deleteTemplateHandler.ts +++ b/lib/templates/deleteTemplateHandler.ts @@ -4,7 +4,7 @@ import { validateDeleteTemplateRequest } from "@/lib/templates/validateDeleteTem import { deleteTemplate } from "@/lib/supabase/templates/deleteTemplate"; /** - * Handler for DELETE /api/templates/{id}. + * Handler for DELETE /api/agents/templates/{id}. * * Permanently removes the template. Caller must be the template's * creator. diff --git a/lib/templates/listTemplatesHandler.ts b/lib/templates/listTemplatesHandler.ts index 9b852091b..87e7a5519 100644 --- a/lib/templates/listTemplatesHandler.ts +++ b/lib/templates/listTemplatesHandler.ts @@ -4,7 +4,7 @@ import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import { selectTemplates } from "@/lib/supabase/templates/selectTemplates"; /** - * Handler for GET /api/templates. + * Handler for GET /api/agents/templates. * * Returns every template the authenticated account can see (own, * public, shared) fully shaped: `creator` flat with `is_admin`, diff --git a/lib/templates/toggleTemplateFavoriteHandler.ts b/lib/templates/toggleTemplateFavoriteHandler.ts index 8a1119e2e..c37076e34 100644 --- a/lib/templates/toggleTemplateFavoriteHandler.ts +++ b/lib/templates/toggleTemplateFavoriteHandler.ts @@ -5,7 +5,7 @@ import { insertTemplateFavorite } from "@/lib/supabase/template_favorites/insert import { deleteTemplateFavorite } from "@/lib/supabase/template_favorites/deleteTemplateFavorite"; /** - * Handler for PUT /api/templates/{id}/favorite. + * Handler for PUT /api/agents/templates/{id}/favorite. * * Idempotently toggles the caller's favorite status for the template: * - `is_favourite=true` upserts the favorite row diff --git a/lib/templates/updateTemplateHandler.ts b/lib/templates/updateTemplateHandler.ts index 796b79d79..8599b61c5 100644 --- a/lib/templates/updateTemplateHandler.ts +++ b/lib/templates/updateTemplateHandler.ts @@ -8,7 +8,7 @@ import { selectTemplates } from "@/lib/supabase/templates/selectTemplates"; import type { TablesUpdate } from "@/types/database.types"; /** - * Handler for PATCH /api/templates/{id}. + * Handler for PATCH /api/agents/templates/{id}. * * Applies a partial update to an template the caller owns. When * `share_emails` is provided, existing shares are wiped and re-inserted. diff --git a/lib/templates/validateCreateTemplateBody.ts b/lib/templates/validateCreateTemplateBody.ts index ffdeb29c7..4147dd522 100644 --- a/lib/templates/validateCreateTemplateBody.ts +++ b/lib/templates/validateCreateTemplateBody.ts @@ -25,7 +25,7 @@ export const createTemplateBodySchema = z.object({ export type CreateTemplateBody = z.infer; /** - * Validates the JSON body for POST /api/templates. + * Validates the JSON body for POST /api/agents/templates. * * @param body - The raw JSON body * @returns A NextResponse with a 400 error or the parsed body on success. diff --git a/lib/templates/validateDeleteTemplateRequest.ts b/lib/templates/validateDeleteTemplateRequest.ts index 720981911..1594b8677 100644 --- a/lib/templates/validateDeleteTemplateRequest.ts +++ b/lib/templates/validateDeleteTemplateRequest.ts @@ -10,7 +10,7 @@ export interface ValidatedDeleteTemplateRequest { } /** - * Validates DELETE /api/templates/{id}: auth, id format, and that the + * Validates DELETE /api/agents/templates/{id}: auth, id format, and that the * caller is the template's creator. */ export async function validateDeleteTemplateRequest( diff --git a/lib/templates/validateToggleFavoriteRequest.ts b/lib/templates/validateToggleFavoriteRequest.ts index 3c3a1eeb8..be6f095e8 100644 --- a/lib/templates/validateToggleFavoriteRequest.ts +++ b/lib/templates/validateToggleFavoriteRequest.ts @@ -20,7 +20,7 @@ export interface ValidatedToggleFavoriteRequest { } /** - * Validates PUT /api/templates/{id}/favorite: auth, id format, body, + * Validates PUT /api/agents/templates/{id}/favorite: auth, id format, body, * and that the caller can see the template (own, public, or shared). */ export async function validateToggleFavoriteRequest( diff --git a/lib/templates/validateUpdateTemplateRequest.ts b/lib/templates/validateUpdateTemplateRequest.ts index 4147a90f0..c02bab548 100644 --- a/lib/templates/validateUpdateTemplateRequest.ts +++ b/lib/templates/validateUpdateTemplateRequest.ts @@ -28,7 +28,7 @@ export interface ValidatedUpdateTemplateRequest { } /** - * Validates PATCH /api/templates/{id}: auth, id format, body, and that + * Validates PATCH /api/agents/templates/{id}: auth, id format, body, and that * the caller is the template's creator. */ export async function validateUpdateTemplateRequest( From 62ff368537584e2e11fe825bbd2625ab05b7c4ee Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Tue, 12 May 2026 07:02:39 +0530 Subject: [PATCH 10/32] refactor(templates): throw on DB error in shares/favorites selects; sort merged result Three findings from the PR review, all about silent failure modes: 1. selectTemplateFavorites was swallowing DB errors and returning [], inconsistent with selectTemplates which throws. Now throws. 2. selectTemplateShares had the same issue, with a worse consequence: in validateToggleFavoriteRequest a DB error during the visibility check would set canAccess=false and 403 a legitimate sharee. Now throws so the handler's outer catch returns a real 500. 3. selectTemplates({accessibleTo}) merges owned/public (.order("title")) with shared rows via a Map; the dedup interleaving broke alphabetical order. Re-sort the combined result by title before returning. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../template_favorites/selectTemplateFavorites.ts | 9 ++++----- lib/supabase/template_shares/selectTemplateShares.ts | 7 +++++-- lib/supabase/templates/selectTemplates.ts | 5 ++++- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/lib/supabase/template_favorites/selectTemplateFavorites.ts b/lib/supabase/template_favorites/selectTemplateFavorites.ts index 5290d8f64..fa48a52b2 100644 --- a/lib/supabase/template_favorites/selectTemplateFavorites.ts +++ b/lib/supabase/template_favorites/selectTemplateFavorites.ts @@ -2,10 +2,9 @@ import supabase from "@/lib/supabase/serverClient"; import type { Tables } from "@/types/database.types"; /** - * Selects raw `template_favorites` rows for the given account. - * - * Returns an empty array on database error (and logs it). Callers that need - * a Set of template ids should compose it themselves. + * Selects raw `template_favorites` rows for the given account. Throws on + * database error so callers can distinguish a real failure from "account + * has no favorites". */ export async function selectTemplateFavorites( accountId: string, @@ -17,7 +16,7 @@ export async function selectTemplateFavorites( if (error) { console.error("Error selecting template_favorites:", error); - return []; + throw new Error(`selectTemplateFavorites failed: ${error.message}`); } return data ?? []; diff --git a/lib/supabase/template_shares/selectTemplateShares.ts b/lib/supabase/template_shares/selectTemplateShares.ts index e625da5ab..824eb4154 100644 --- a/lib/supabase/template_shares/selectTemplateShares.ts +++ b/lib/supabase/template_shares/selectTemplateShares.ts @@ -2,7 +2,10 @@ import supabase from "@/lib/supabase/serverClient"; import type { Tables } from "@/types/database.types"; /** - * Selects all template_shares rows for the given template ids. + * Selects all template_shares rows for the given template ids. Throws on + * database error so callers cannot misread a DB failure as "no shares" and + * deny access (e.g. the toggle-favorite visibility check would otherwise + * 403 a legitimate sharee on a transient query failure). * * @param templateIds - Array of template UUIDs * @returns Array of share rows (may be empty). @@ -19,7 +22,7 @@ export async function selectTemplateShares( if (error) { console.error("Error selecting template_shares:", error); - return []; + throw new Error(`selectTemplateShares failed: ${error.message}`); } return data ?? []; diff --git a/lib/supabase/templates/selectTemplates.ts b/lib/supabase/templates/selectTemplates.ts index 3299362ee..0d693ec36 100644 --- a/lib/supabase/templates/selectTemplates.ts +++ b/lib/supabase/templates/selectTemplates.ts @@ -124,7 +124,10 @@ async function fetchRaw(params: SelectTemplatesParams): Promise { if (t && !byId.has(t.id)) byId.set(t.id, t); }); }); - return Array.from(byId.values()); + // Re-sort: owned/public came in title order, but shared rows merged in + // afterward break the order. Sort the combined set so the response is + // consistently alphabetised regardless of dedup interleaving. + return Array.from(byId.values()).sort((a, b) => a.title.localeCompare(b.title)); } /** From 23996d48966a8cd672b518b2e47b6c1dd0851217 Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Tue, 12 May 2026 22:20:36 +0530 Subject: [PATCH 11/32] refactor(templates): address @sweetman review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SRP — extract private helpers into own files: - lib/supabase/templates/flattenCreator.ts - lib/supabase/templates/resolveSharedEmails.ts - lib/supabase/templates/fetchRawTemplates.ts - lib/supabase/templates/templateWithCreatorSelect.ts (SELECT + RawTemplate) SRP — single validate call in createTemplateHandler: - new lib/templates/validateCreateTemplateRequest.ts wraps auth + safeParseJson + body validation; handler drops three direct calls for one - mirrors validateUpdateTemplateRequest / validateDeleteTemplateRequest DRY — drop ADMIN_EMAILS duplicate, use existing admin abstraction: - "admin" is org membership of RECOUP_ORG_ID (see lib/admins/checkIsAdmin) - new lib/supabase/account_organization_ids/selectOrgMemberAccountIds.ts for batch lookup - new lib/admins/getAdminAccountIds.ts wraps it with RECOUP_ORG_ID - selectTemplates passes admin Set to flattenCreator - ADMIN_EMAILS removed from lib/const.ts - account_emails join dropped from creator SELECT (was only feeding the ADMIN_EMAILS intersection) Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/admins/getAdminAccountIds.ts | 12 ++ lib/const.ts | 8 - .../selectOrgMemberAccountIds.ts | 30 +++ lib/supabase/templates/fetchRawTemplates.ts | 68 +++++++ lib/supabase/templates/flattenCreator.ts | 29 +++ lib/supabase/templates/resolveSharedEmails.ts | 38 ++++ lib/supabase/templates/selectTemplates.ts | 188 ++++-------------- .../templates/templateWithCreatorSelect.ts | 21 ++ .../__tests__/createTemplateHandler.test.ts | 58 +++--- lib/templates/createTemplateHandler.ts | 34 ++-- .../validateCreateTemplateRequest.ts | 29 +++ 11 files changed, 311 insertions(+), 204 deletions(-) create mode 100644 lib/admins/getAdminAccountIds.ts create mode 100644 lib/supabase/account_organization_ids/selectOrgMemberAccountIds.ts create mode 100644 lib/supabase/templates/fetchRawTemplates.ts create mode 100644 lib/supabase/templates/flattenCreator.ts create mode 100644 lib/supabase/templates/resolveSharedEmails.ts create mode 100644 lib/supabase/templates/templateWithCreatorSelect.ts create mode 100644 lib/templates/validateCreateTemplateRequest.ts diff --git a/lib/admins/getAdminAccountIds.ts b/lib/admins/getAdminAccountIds.ts new file mode 100644 index 000000000..3f7c7abb8 --- /dev/null +++ b/lib/admins/getAdminAccountIds.ts @@ -0,0 +1,12 @@ +import { RECOUP_ORG_ID } from "@/lib/const"; +import { selectOrgMemberAccountIds } from "@/lib/supabase/account_organization_ids/selectOrgMemberAccountIds"; + +/** + * Returns the subset of `accountIds` that are Recoup admins — i.e. members + * of the Recoup organization. Mirrors `checkIsAdmin`'s single-account check, + * batched for use when many accounts need to be classified at once. + */ +export async function getAdminAccountIds(accountIds: string[]): Promise> { + const ids = await selectOrgMemberAccountIds(RECOUP_ORG_ID, accountIds); + return new Set(ids); +} diff --git a/lib/const.ts b/lib/const.ts index c9b3388a7..2362805ef 100644 --- a/lib/const.ts +++ b/lib/const.ts @@ -48,14 +48,6 @@ export const FLAMINGO_GENERATE_URL = /** Snapshot expiration duration (7 days) */ export const SNAPSHOT_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000; -/** - * Email addresses with platform-admin privileges. - * - * Surfaced as `creator.is_admin` in `/api/agents/templates` so clients can flag - * official Recoup templates. Mirrors `chat/lib/admin.ts`. - */ -export const ADMIN_EMAILS: readonly string[] = ["sidney+1@recoupable.com"] as const; - // EVALS export const EVAL_ACCOUNT_ID = "fb678396-a68f-4294-ae50-b8cacf9ce77b"; export const EVAL_ACCESS_TOKEN = process.env.EVAL_ACCESS_TOKEN || ""; diff --git a/lib/supabase/account_organization_ids/selectOrgMemberAccountIds.ts b/lib/supabase/account_organization_ids/selectOrgMemberAccountIds.ts new file mode 100644 index 000000000..6c3d95993 --- /dev/null +++ b/lib/supabase/account_organization_ids/selectOrgMemberAccountIds.ts @@ -0,0 +1,30 @@ +import supabase from "@/lib/supabase/serverClient"; + +/** + * For a given organization, returns the subset of `accountIds` that are + * members of it. Used for batch admin checks (e.g. "which of these template + * creators are members of the Recoup org"). + * + * Returns an empty array on database error or empty input. + */ +export async function selectOrgMemberAccountIds( + organizationId: string, + accountIds: string[], +): Promise { + if (!organizationId || accountIds.length === 0) return []; + + const { data, error } = await supabase + .from("account_organization_ids") + .select("account_id") + .eq("organization_id", organizationId) + .in("account_id", accountIds); + + if (error) { + console.error("Error selecting org members:", error); + throw new Error(`selectOrgMemberAccountIds failed: ${error.message}`); + } + + return (data ?? []) + .map(row => row.account_id) + .filter((id): id is string => typeof id === "string"); +} diff --git a/lib/supabase/templates/fetchRawTemplates.ts b/lib/supabase/templates/fetchRawTemplates.ts new file mode 100644 index 000000000..a6e198eed --- /dev/null +++ b/lib/supabase/templates/fetchRawTemplates.ts @@ -0,0 +1,68 @@ +import supabase from "@/lib/supabase/serverClient"; +import { + TEMPLATE_WITH_CREATOR_SELECT, + type RawTemplate, +} from "@/lib/supabase/templates/templateWithCreatorSelect"; + +export type FetchRawTemplatesParams = { id: string } | { accessibleTo: string }; + +/** + * Raw fetch of agent_templates joined with the creator account. + * + * `{ id }` → returns the row with that id (or empty array). + * `{ accessibleTo }` → returns own + public + shared rows for the account, + * deduplicated by id. Caller decides ordering. + * + * Throws on database error. + */ +export async function fetchRawTemplates(params: FetchRawTemplatesParams): Promise { + if ("id" in params) { + const { data, error } = await supabase + .from("agent_templates") + .select(TEMPLATE_WITH_CREATOR_SELECT) + .eq("id", params.id); + if (error) { + console.error("Error selecting template by id:", error); + throw new Error(`fetchRawTemplates(id) failed: ${error.message}`); + } + return data ?? []; + } + + const accountId = params.accessibleTo; + const [ownedAndPublic, shared] = await Promise.all([ + supabase + .from("agent_templates") + .select(TEMPLATE_WITH_CREATOR_SELECT) + .or(`creator.eq.${accountId},is_private.eq.false`) + .order("title"), + supabase + .from("agent_template_shares") + .select( + `template:agent_templates!agent_template_shares_template_id_fkey (${TEMPLATE_WITH_CREATOR_SELECT})`, + ) + .eq("user_id", accountId), + ]); + + if (ownedAndPublic.error) { + console.error("Error selecting owned/public templates:", ownedAndPublic.error); + throw new Error( + `fetchRawTemplates(accessibleTo) owned/public failed: ${ownedAndPublic.error.message}`, + ); + } + if (shared.error) { + console.error("Error selecting shared templates:", shared.error); + throw new Error(`fetchRawTemplates(accessibleTo) shared failed: ${shared.error.message}`); + } + + const byId = new Map(); + (ownedAndPublic.data ?? []).forEach(row => byId.set(row.id, row)); + (shared.data ?? []).forEach(s => { + const { template } = s as { template: RawTemplate | RawTemplate[] | null }; + if (!template) return; + const list = Array.isArray(template) ? template : [template]; + list.forEach(t => { + if (t && !byId.has(t.id)) byId.set(t.id, t); + }); + }); + return Array.from(byId.values()); +} diff --git a/lib/supabase/templates/flattenCreator.ts b/lib/supabase/templates/flattenCreator.ts new file mode 100644 index 000000000..276a891d6 --- /dev/null +++ b/lib/supabase/templates/flattenCreator.ts @@ -0,0 +1,29 @@ +import type { RawTemplate } from "@/lib/supabase/templates/templateWithCreatorSelect"; + +export interface TemplateCreator { + id: string; + name: string | null; + image: string | null; + is_admin: boolean; +} + +/** + * Flattens the joined creator block on a raw template row into the API + * response shape. `is_admin` is taken from the supplied set so the caller + * can batch-compute admin membership across many rows in one query + * (see `getAdminAccountIds`). + */ +export function flattenCreator( + creator: RawTemplate["creator"], + adminAccountIds: Set, +): TemplateCreator | null { + if (!creator) return null; + const row = Array.isArray(creator) ? creator[0] : creator; + if (!row) return null; + return { + id: row.id, + name: row.name ?? null, + image: row.account_info?.[0]?.image ?? null, + is_admin: adminAccountIds.has(row.id), + }; +} diff --git a/lib/supabase/templates/resolveSharedEmails.ts b/lib/supabase/templates/resolveSharedEmails.ts new file mode 100644 index 000000000..8d7c49a63 --- /dev/null +++ b/lib/supabase/templates/resolveSharedEmails.ts @@ -0,0 +1,38 @@ +import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmails"; +import { selectTemplateShares } from "@/lib/supabase/template_shares/selectTemplateShares"; + +/** + * For each supplied template id, returns the list of emails it has been + * shared with — by joining `template_shares` to `account_emails`. Empty + * input returns an empty map. + */ +export async function resolveSharedEmails( + templateIds: string[], +): Promise> { + if (templateIds.length === 0) return {}; + + const shares = await selectTemplateShares(templateIds); + if (shares.length === 0) return {}; + + const accountIds = Array.from(new Set(shares.map(s => s.user_id))); + const accountEmails = await selectAccountEmails({ accountIds }); + + const emailsByAccount = new Map(); + accountEmails.forEach(row => { + if (!row.account_id || !row.email) return; + const list = emailsByAccount.get(row.account_id) ?? []; + list.push(row.email); + emailsByAccount.set(row.account_id, list); + }); + + const result: Record = {}; + shares.forEach(share => { + const list = result[share.template_id] ?? []; + list.push(...(emailsByAccount.get(share.user_id) ?? [])); + result[share.template_id] = list; + }); + Object.keys(result).forEach(id => { + result[id] = Array.from(new Set(result[id])); + }); + return result; +} diff --git a/lib/supabase/templates/selectTemplates.ts b/lib/supabase/templates/selectTemplates.ts index 0d693ec36..85da41658 100644 --- a/lib/supabase/templates/selectTemplates.ts +++ b/lib/supabase/templates/selectTemplates.ts @@ -1,147 +1,33 @@ -import type { QueryData } from "@supabase/supabase-js"; -import supabase from "@/lib/supabase/serverClient"; -import { ADMIN_EMAILS } from "@/lib/const"; -import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmails"; +import type { Tables } from "@/types/database.types"; +import { getAdminAccountIds } from "@/lib/admins/getAdminAccountIds"; import { selectTemplateFavorites } from "@/lib/supabase/template_favorites/selectTemplateFavorites"; -import { selectTemplateShares } from "@/lib/supabase/template_shares/selectTemplateShares"; +import { fetchRawTemplates } from "@/lib/supabase/templates/fetchRawTemplates"; +import { flattenCreator, type TemplateCreator } from "@/lib/supabase/templates/flattenCreator"; +import { resolveSharedEmails } from "@/lib/supabase/templates/resolveSharedEmails"; +import type { RawTemplate } from "@/lib/supabase/templates/templateWithCreatorSelect"; -const SELECT = ` - *, - creator:accounts!agent_templates_creator_fkey ( - id, - name, - account_info ( image ), - account_emails ( email ) - ) -` as const; +export type { TemplateCreator } from "@/lib/supabase/templates/flattenCreator"; -const _typedQuery = supabase.from("agent_templates").select(SELECT); - -type RawTemplate = QueryData[number]; - -export interface TemplateCreator { - id: string; - name: string | null; - image: string | null; - is_admin: boolean; -} - -export type Template = Omit & { +export type Template = Omit, "creator"> & { creator: TemplateCreator | null; is_favourite: boolean; shared_emails: string[]; }; -type SelectTemplatesParams = { id: string } | { accessibleTo: string }; - -function flattenCreator(creator: RawTemplate["creator"]): TemplateCreator | null { - if (!creator) return null; - const row = Array.isArray(creator) ? creator[0] : creator; - if (!row) return null; - const emails = (row.account_emails ?? []) - .map(e => e.email) - .filter((e): e is string => typeof e === "string"); - return { - id: row.id, - name: row.name ?? null, - image: row.account_info?.[0]?.image ?? null, - is_admin: emails.some(email => ADMIN_EMAILS.includes(email)), - }; -} - -async function resolveSharedEmails(templateIds: string[]): Promise> { - if (templateIds.length === 0) return {}; - const shares = await selectTemplateShares(templateIds); - if (shares.length === 0) return {}; - - const accountIds = Array.from(new Set(shares.map(s => s.user_id))); - const accountEmails = await selectAccountEmails({ accountIds }); - - const emailsByAccount = new Map(); - accountEmails.forEach(row => { - if (!row.account_id || !row.email) return; - const list = emailsByAccount.get(row.account_id) ?? []; - list.push(row.email); - emailsByAccount.set(row.account_id, list); - }); - - const result: Record = {}; - shares.forEach(share => { - const list = result[share.template_id] ?? []; - list.push(...(emailsByAccount.get(share.user_id) ?? [])); - result[share.template_id] = list; - }); - Object.keys(result).forEach(id => { - result[id] = Array.from(new Set(result[id])); - }); - return result; -} - -async function fetchRaw(params: SelectTemplatesParams): Promise { - if ("id" in params) { - const { data, error } = await supabase - .from("agent_templates") - .select(SELECT) - .eq("id", params.id); - if (error) { - console.error("Error selecting template by id:", error); - throw new Error(`selectTemplates(id) failed: ${error.message}`); - } - return data ?? []; - } - - const accountId = params.accessibleTo; - const [ownedAndPublic, shared] = await Promise.all([ - supabase - .from("agent_templates") - .select(SELECT) - .or(`creator.eq.${accountId},is_private.eq.false`) - .order("title"), - supabase - .from("agent_template_shares") - .select(`template:agent_templates!agent_template_shares_template_id_fkey (${SELECT})`) - .eq("user_id", accountId), - ]); - - if (ownedAndPublic.error) { - console.error("Error selecting owned/public templates:", ownedAndPublic.error); - throw new Error( - `selectTemplates(accessibleTo) owned/public failed: ${ownedAndPublic.error.message}`, - ); - } - if (shared.error) { - console.error("Error selecting shared templates:", shared.error); - throw new Error(`selectTemplates(accessibleTo) shared failed: ${shared.error.message}`); - } - - const byId = new Map(); - (ownedAndPublic.data ?? []).forEach(row => byId.set(row.id, row)); - (shared.data ?? []).forEach(s => { - const { template } = s as { template: RawTemplate | RawTemplate[] | null }; - if (!template) return; - const list = Array.isArray(template) ? template : [template]; - list.forEach(t => { - if (t && !byId.has(t.id)) byId.set(t.id, t); - }); - }); - // Re-sort: owned/public came in title order, but shared rows merged in - // afterward break the order. Sort the combined set so the response is - // consistently alphabetised regardless of dedup interleaving. - return Array.from(byId.values()).sort((a, b) => a.title.localeCompare(b.title)); -} +export type SelectTemplatesParams = { id: string } | { accessibleTo: string }; /** - * Reads templates and returns them fully shaped for the API: - * - creator block flattened to `{ id, name, image, is_admin }` - * - `is_favourite` populated against `forAccountId` (defaults to `false`) - * - `shared_emails` populated only for private templates `forAccountId` owns + * Returns agent templates fully shaped for the API: + * - creator block flattened to `{ id, name, image, is_admin }` + * - `is_admin` derived from Recoup org membership (see `getAdminAccountIds`) + * - `is_favourite` populated against `forAccountId` (defaults to `false`) + * - `shared_emails` populated only for private templates `forAccountId` owns * - * `{ id }` → row with that id, or empty array when not found. - * `{ accessibleTo }` → own + public + shared (deduped) for that account. + * `{ id }` → row with that id, or empty array. + * `{ accessibleTo }` → own + public + shared (deduped, sorted by title). * - * Pass `forAccountId` whenever you need is_favourite / shared_emails marked. - * Internal callers (e.g. ownership validators) can omit it to skip the - * caller-specific enrichment queries. + * Omit `forAccountId` for internal callers (e.g. ownership validators) that + * only need the creator block and want to skip the per-caller enrichment. * * Throws on database error. */ @@ -149,13 +35,14 @@ export async function selectTemplates( params: SelectTemplatesParams, forAccountId?: string, ): Promise { - const rawRows = await fetchRaw(params); - if (rawRows.length === 0) return []; + const rows = await fetchRawTemplates(params); + if (rows.length === 0) return []; - const flattened = rawRows.map(row => { - const { creator, ...rest } = row; - return { ...rest, creator: flattenCreator(creator) }; - }); + const adminAccountIds = await getAdminAccountIds(uniqueCreatorIds(rows)); + const flattened = rows.map(row => ({ + ...row, + creator: flattenCreator(row.creator, adminAccountIds), + })); if (!forAccountId) { return flattened.map(row => ({ ...row, is_favourite: false, shared_emails: [] })); @@ -170,10 +57,23 @@ export async function selectTemplates( ]); const favoriteIds = new Set(favorites.map(f => f.template_id)); - return flattened.map(row => ({ - ...row, - is_favourite: favoriteIds.has(row.id), - shared_emails: - row.is_private && row.creator?.id === forAccountId ? (sharedEmailsMap[row.id] ?? []) : [], - })); + return flattened + .map(row => ({ + ...row, + is_favourite: favoriteIds.has(row.id), + shared_emails: + row.is_private && row.creator?.id === forAccountId ? (sharedEmailsMap[row.id] ?? []) : [], + })) + .sort((a, b) => a.title.localeCompare(b.title)); +} + +function uniqueCreatorIds(rows: RawTemplate[]): string[] { + const ids = new Set(); + rows.forEach(row => { + const c = row.creator; + if (!c) return; + const single = Array.isArray(c) ? c[0] : c; + if (single?.id) ids.add(single.id); + }); + return Array.from(ids); } diff --git a/lib/supabase/templates/templateWithCreatorSelect.ts b/lib/supabase/templates/templateWithCreatorSelect.ts new file mode 100644 index 000000000..dfef1cf37 --- /dev/null +++ b/lib/supabase/templates/templateWithCreatorSelect.ts @@ -0,0 +1,21 @@ +import type { QueryData } from "@supabase/supabase-js"; +import supabase from "@/lib/supabase/serverClient"; + +/** + * Shared SELECT fragment for reading agent_templates with the creator + * account joined (id, name, image). `is_admin` is no longer derived from + * embedded emails — callers compute it from organization membership via + * `getAdminAccountIds` and attach it during shaping. + */ +export const TEMPLATE_WITH_CREATOR_SELECT = ` + *, + creator:accounts!agent_templates_creator_fkey ( + id, + name, + account_info ( image ) + ) +` as const; + +const _typedQuery = supabase.from("agent_templates").select(TEMPLATE_WITH_CREATOR_SELECT); + +export type RawTemplate = QueryData[number]; diff --git a/lib/templates/__tests__/createTemplateHandler.test.ts b/lib/templates/__tests__/createTemplateHandler.test.ts index fd6ded3ef..07cab1434 100644 --- a/lib/templates/__tests__/createTemplateHandler.test.ts +++ b/lib/templates/__tests__/createTemplateHandler.test.ts @@ -5,8 +5,8 @@ vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), })); -vi.mock("@/lib/auth/validateAuthContext", () => ({ - validateAuthContext: vi.fn(), +vi.mock("@/lib/templates/validateCreateTemplateRequest", () => ({ + validateCreateTemplateRequest: vi.fn(), })); vi.mock("@/lib/supabase/templates/insertTemplate", () => ({ @@ -22,7 +22,9 @@ vi.mock("@/lib/supabase/templates/selectTemplates", () => ({ })); const { createTemplateHandler } = await import("../createTemplateHandler"); -const { validateAuthContext } = await import("@/lib/auth/validateAuthContext"); +const { validateCreateTemplateRequest } = await import( + "@/lib/templates/validateCreateTemplateRequest" +); const { insertTemplate } = await import("@/lib/supabase/templates/insertTemplate"); const { insertTemplateShares } = await import( "@/lib/supabase/template_shares/insertTemplateShares" @@ -32,21 +34,7 @@ const { selectTemplates } = await import("@/lib/supabase/templates/selectTemplat const ACCOUNT_ID = "11111111-1111-1111-1111-111111111111"; const TEMPLATE_ID = "22222222-2222-2222-2222-222222222222"; -const mockAuthOk = () => - vi.mocked(validateAuthContext).mockResolvedValue({ - accountId: ACCOUNT_ID, - orgId: null, - authToken: "k", - }); - -const makeRequest = (body: unknown) => - new NextRequest("http://localhost/api/agents/templates", { - method: "POST", - headers: { "x-api-key": "k", "content-type": "application/json" }, - body: JSON.stringify(body), - }); - -const validBody = { +const baseBody = { title: "Valid title", description: "valid description", prompt: "Valid prompt content for template", @@ -55,18 +43,26 @@ const validBody = { share_emails: [], }; +const makeRequest = () => + new NextRequest("http://localhost/api/agents/templates", { + method: "POST", + headers: { "x-api-key": "k", "content-type": "application/json" }, + body: JSON.stringify(baseBody), + }); + describe("createTemplateHandler", () => { beforeEach(() => vi.clearAllMocks()); it("creates a template and shares emails when private", async () => { - mockAuthOk(); + vi.mocked(validateCreateTemplateRequest).mockResolvedValue({ + accountId: ACCOUNT_ID, + body: { ...baseBody, is_private: true, share_emails: ["a@x.com"] }, + }); vi.mocked(insertTemplate).mockResolvedValue({ id: TEMPLATE_ID } as never); vi.mocked(insertTemplateShares).mockResolvedValue(1); vi.mocked(selectTemplates).mockResolvedValue([{ id: TEMPLATE_ID } as never]); - const res = await createTemplateHandler( - makeRequest({ ...validBody, is_private: true, share_emails: ["a@x.com"] }), - ); + const res = await createTemplateHandler(makeRequest()); expect(res.status).toBe(201); expect((await res.json()).template.id).toBe(TEMPLATE_ID); @@ -77,17 +73,15 @@ describe("createTemplateHandler", () => { expect(selectTemplates).toHaveBeenCalledWith({ id: TEMPLATE_ID }, ACCOUNT_ID); }); - it("returns 400 when validation fails", async () => { - mockAuthOk(); - const res = await createTemplateHandler(makeRequest({ title: "no" })); - expect(res.status).toBe(400); - expect(insertTemplate).not.toHaveBeenCalled(); - }); + it("returns the validator error response when validation fails", async () => { + const failure = NextResponse.json( + { status: "error", error: "title must be at least 3 characters" }, + { status: 400 }, + ); + vi.mocked(validateCreateTemplateRequest).mockResolvedValue(failure); - it("returns 401 when auth fails", async () => { - const failure = NextResponse.json({ status: "error", error: "Unauthorized" }, { status: 401 }); - vi.mocked(validateAuthContext).mockResolvedValue(failure); - const res = await createTemplateHandler(makeRequest(validBody)); + const res = await createTemplateHandler(makeRequest()); expect(res).toBe(failure); + expect(insertTemplate).not.toHaveBeenCalled(); }); }); diff --git a/lib/templates/createTemplateHandler.ts b/lib/templates/createTemplateHandler.ts index e0baf9187..32b2dcd63 100644 --- a/lib/templates/createTemplateHandler.ts +++ b/lib/templates/createTemplateHandler.ts @@ -1,8 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { validateAuthContext } from "@/lib/auth/validateAuthContext"; -import { safeParseJson } from "@/lib/networking/safeParseJson"; -import { validateCreateTemplateBody } from "@/lib/templates/validateCreateTemplateBody"; +import { validateCreateTemplateRequest } from "@/lib/templates/validateCreateTemplateRequest"; import { insertTemplate } from "@/lib/supabase/templates/insertTemplate"; import { insertTemplateShares } from "@/lib/supabase/template_shares/insertTemplateShares"; import { selectTemplates } from "@/lib/supabase/templates/selectTemplates"; @@ -10,27 +8,23 @@ import { selectTemplates } from "@/lib/supabase/templates/selectTemplates"; /** * Handler for POST /api/agents/templates. * - * Creates an template owned by the authenticated account. When - * `is_private=true`, supplied `share_emails` are resolved to accounts and - * upserted into template_shares. + * Creates a template owned by the authenticated account. When `is_private` + * is true, supplied `share_emails` are resolved to accounts and upserted + * into template_shares. */ export async function createTemplateHandler(request: NextRequest): Promise { try { - const authResult = await validateAuthContext(request); - if (authResult instanceof NextResponse) return authResult; + const validated = await validateCreateTemplateRequest(request); + if (validated instanceof NextResponse) return validated; - const body = await safeParseJson(request); - const parsedBody = validateCreateTemplateBody(body); - if (parsedBody instanceof NextResponse) return parsedBody; - - const accountId = authResult.accountId; + const { accountId, body } = validated; const inserted = await insertTemplate({ - title: parsedBody.title, - description: parsedBody.description, - prompt: parsedBody.prompt, - tags: parsedBody.tags, - is_private: parsedBody.is_private, + title: body.title, + description: body.description, + prompt: body.prompt, + tags: body.tags, + is_private: body.is_private, creator: accountId, }); @@ -41,8 +35,8 @@ export async function createTemplateHandler(request: NextRequest): Promise 0) { - await insertTemplateShares(inserted.id, parsedBody.share_emails); + if (body.is_private && body.share_emails.length > 0) { + await insertTemplateShares(inserted.id, body.share_emails); } const [template] = await selectTemplates({ id: inserted.id }, accountId); diff --git a/lib/templates/validateCreateTemplateRequest.ts b/lib/templates/validateCreateTemplateRequest.ts new file mode 100644 index 000000000..8a38dbbad --- /dev/null +++ b/lib/templates/validateCreateTemplateRequest.ts @@ -0,0 +1,29 @@ +import { NextRequest, NextResponse } from "next/server"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { safeParseJson } from "@/lib/networking/safeParseJson"; +import { + validateCreateTemplateBody, + type CreateTemplateBody, +} from "@/lib/templates/validateCreateTemplateBody"; + +export interface ValidatedCreateTemplateRequest { + accountId: string; + body: CreateTemplateBody; +} + +/** + * Validates POST /api/agents/templates: auth and JSON body. Mirrors the + * one-call validate pattern used by the other template request validators. + */ +export async function validateCreateTemplateRequest( + request: NextRequest, +): Promise { + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) return authResult; + + const rawBody = await safeParseJson(request); + const parsedBody = validateCreateTemplateBody(rawBody); + if (parsedBody instanceof NextResponse) return parsedBody; + + return { accountId: authResult.accountId, body: parsedBody }; +} From 1d6b127a214fada5a8697aaf11eb6b0eb711bbf9 Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Tue, 12 May 2026 22:32:44 +0530 Subject: [PATCH 12/32] refactor(templates): resolveSharedEmails uses one embedded query MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Was: two queries — selectTemplateShares + selectAccountEmails — then build a map in JS. Now: one PostgREST query embedding share → accounts → account_emails, then group + dedup in JS. PostgREST can't array-aggregate, so the in-memory grouping stays. The roundtrip count drops from 2 to 1 and the function no longer depends on selectTemplateShares / selectAccountEmails for this path. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/supabase/templates/resolveSharedEmails.ts | 55 ++++++++++++------- 1 file changed, 34 insertions(+), 21 deletions(-) diff --git a/lib/supabase/templates/resolveSharedEmails.ts b/lib/supabase/templates/resolveSharedEmails.ts index 8d7c49a63..d32e1dd6b 100644 --- a/lib/supabase/templates/resolveSharedEmails.ts +++ b/lib/supabase/templates/resolveSharedEmails.ts @@ -1,35 +1,48 @@ -import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmails"; -import { selectTemplateShares } from "@/lib/supabase/template_shares/selectTemplateShares"; +import supabase from "@/lib/supabase/serverClient"; /** - * For each supplied template id, returns the list of emails it has been - * shared with — by joining `template_shares` to `account_emails`. Empty - * input returns an empty map. + * For each supplied template id, returns the emails it has been shared with. + * One round trip: embeds the share's `user_id → accounts → account_emails` + * chain in a single PostgREST query, then groups + dedupes the emails by + * template id in memory. Empty input returns an empty map. + * + * PostgREST doesn't support array aggregation in select strings, so the + * grouping step has to live here (or in a Postgres view/RPC, which would + * be a schema change). */ export async function resolveSharedEmails( templateIds: string[], ): Promise> { if (templateIds.length === 0) return {}; - const shares = await selectTemplateShares(templateIds); - if (shares.length === 0) return {}; + const { data, error } = await supabase + .from("agent_template_shares") + .select( + ` + template_id, + sharee:accounts!agent_template_shares_user_id_fkey ( + account_emails ( email ) + ) + `, + ) + .in("template_id", templateIds); - const accountIds = Array.from(new Set(shares.map(s => s.user_id))); - const accountEmails = await selectAccountEmails({ accountIds }); - - const emailsByAccount = new Map(); - accountEmails.forEach(row => { - if (!row.account_id || !row.email) return; - const list = emailsByAccount.get(row.account_id) ?? []; - list.push(row.email); - emailsByAccount.set(row.account_id, list); - }); + if (error) { + console.error("Error selecting template shares with emails:", error); + throw new Error(`resolveSharedEmails failed: ${error.message}`); + } const result: Record = {}; - shares.forEach(share => { - const list = result[share.template_id] ?? []; - list.push(...(emailsByAccount.get(share.user_id) ?? [])); - result[share.template_id] = list; + (data ?? []).forEach(row => { + const shareeList = Array.isArray(row.sharee) ? row.sharee : row.sharee ? [row.sharee] : []; + shareeList.forEach(sharee => { + sharee.account_emails?.forEach(ae => { + if (!ae?.email) return; + const list = result[row.template_id] ?? []; + list.push(ae.email); + result[row.template_id] = list; + }); + }); }); Object.keys(result).forEach(id => { result[id] = Array.from(new Set(result[id])); From 875b2161c74cdd8fe1213a408530c6e01eabb4a1 Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Tue, 12 May 2026 22:37:32 +0530 Subject: [PATCH 13/32] refactor(templates): drop selectTemplateShares, use targeted exists check After resolveSharedEmails moved to an embedded query, the only remaining caller of selectTemplateShares was validateToggleFavoriteRequest, which was fetching every share row for the template just to find one user_id match. Replace with isTemplateSharedWithAccount(templateId, accountId): a single .eq().eq().maybeSingle() that returns boolean. Throws on DB error so a query failure can't silently 403 a legitimate sharee. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../isTemplateSharedWithAccount.ts | 25 ++++++++++++++++ .../template_shares/selectTemplateShares.ts | 29 ------------------- .../validateToggleFavoriteRequest.ts | 5 ++-- 3 files changed, 27 insertions(+), 32 deletions(-) create mode 100644 lib/supabase/template_shares/isTemplateSharedWithAccount.ts delete mode 100644 lib/supabase/template_shares/selectTemplateShares.ts diff --git a/lib/supabase/template_shares/isTemplateSharedWithAccount.ts b/lib/supabase/template_shares/isTemplateSharedWithAccount.ts new file mode 100644 index 000000000..bf877b5f3 --- /dev/null +++ b/lib/supabase/template_shares/isTemplateSharedWithAccount.ts @@ -0,0 +1,25 @@ +import supabase from "@/lib/supabase/serverClient"; + +/** + * Returns true iff `accountId` has been granted access to `templateId` via + * `agent_template_shares`. Throws on database error so callers can't + * misread a query failure as "not shared" and 403 a legitimate sharee. + */ +export async function isTemplateSharedWithAccount( + templateId: string, + accountId: string, +): Promise { + const { data, error } = await supabase + .from("agent_template_shares") + .select("template_id") + .eq("template_id", templateId) + .eq("user_id", accountId) + .maybeSingle(); + + if (error) { + console.error("Error checking template share:", error); + throw new Error(`isTemplateSharedWithAccount failed: ${error.message}`); + } + + return data !== null; +} diff --git a/lib/supabase/template_shares/selectTemplateShares.ts b/lib/supabase/template_shares/selectTemplateShares.ts deleted file mode 100644 index 824eb4154..000000000 --- a/lib/supabase/template_shares/selectTemplateShares.ts +++ /dev/null @@ -1,29 +0,0 @@ -import supabase from "@/lib/supabase/serverClient"; -import type { Tables } from "@/types/database.types"; - -/** - * Selects all template_shares rows for the given template ids. Throws on - * database error so callers cannot misread a DB failure as "no shares" and - * deny access (e.g. the toggle-favorite visibility check would otherwise - * 403 a legitimate sharee on a transient query failure). - * - * @param templateIds - Array of template UUIDs - * @returns Array of share rows (may be empty). - */ -export async function selectTemplateShares( - templateIds: string[], -): Promise[]> { - if (!Array.isArray(templateIds) || templateIds.length === 0) return []; - - const { data, error } = await supabase - .from("agent_template_shares") - .select("*") - .in("template_id", templateIds); - - if (error) { - console.error("Error selecting template_shares:", error); - throw new Error(`selectTemplateShares failed: ${error.message}`); - } - - return data ?? []; -} diff --git a/lib/templates/validateToggleFavoriteRequest.ts b/lib/templates/validateToggleFavoriteRequest.ts index be6f095e8..4769bd866 100644 --- a/lib/templates/validateToggleFavoriteRequest.ts +++ b/lib/templates/validateToggleFavoriteRequest.ts @@ -5,7 +5,7 @@ import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { safeParseJson } from "@/lib/networking/safeParseJson"; import { selectTemplates } from "@/lib/supabase/templates/selectTemplates"; -import { selectTemplateShares } from "@/lib/supabase/template_shares/selectTemplateShares"; +import { isTemplateSharedWithAccount } from "@/lib/supabase/template_shares/isTemplateSharedWithAccount"; export const toggleFavoriteBodySchema = z.object({ is_favourite: z.boolean({ message: "is_favourite is required" }), @@ -61,8 +61,7 @@ export async function validateToggleFavoriteRequest( const isOwner = existing.creator?.id === accountId; let canAccess = isOwner || !existing.is_private; if (!canAccess) { - const shares = await selectTemplateShares([templateId]); - canAccess = shares.some(s => s.user_id === accountId); + canAccess = await isTemplateSharedWithAccount(templateId, accountId); } if (!canAccess) { return NextResponse.json( From 0ab533f14192076533b716d8e346742e4c818568 Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Tue, 12 May 2026 22:48:58 +0530 Subject: [PATCH 14/32] refactor(templates): inline SELECT + RawTemplate into fetchRawTemplates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit templateWithCreatorSelect.ts was scaffolding from when multiple selectors each needed the same SELECT fragment. Now there's one fetch path and one select path — the constant and the SDK-derived row type live with the function that uses them. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/supabase/templates/fetchRawTemplates.ts | 30 +++++++++++++------ lib/supabase/templates/flattenCreator.ts | 2 +- lib/supabase/templates/selectTemplates.ts | 2 +- .../templates/templateWithCreatorSelect.ts | 21 ------------- 4 files changed, 23 insertions(+), 32 deletions(-) delete mode 100644 lib/supabase/templates/templateWithCreatorSelect.ts diff --git a/lib/supabase/templates/fetchRawTemplates.ts b/lib/supabase/templates/fetchRawTemplates.ts index a6e198eed..f12d80d5a 100644 --- a/lib/supabase/templates/fetchRawTemplates.ts +++ b/lib/supabase/templates/fetchRawTemplates.ts @@ -1,8 +1,22 @@ +import type { QueryData } from "@supabase/supabase-js"; import supabase from "@/lib/supabase/serverClient"; -import { - TEMPLATE_WITH_CREATOR_SELECT, - type RawTemplate, -} from "@/lib/supabase/templates/templateWithCreatorSelect"; + +/** + * The one SELECT for reading templates. The creator account is always + * joined — there's no "template without creator" path in this codebase. + */ +const SELECT = ` + *, + creator:accounts!agent_templates_creator_fkey ( + id, + name, + account_info ( image ) + ) +` as const; + +const _typedQuery = supabase.from("agent_templates").select(SELECT); + +export type RawTemplate = QueryData[number]; export type FetchRawTemplatesParams = { id: string } | { accessibleTo: string }; @@ -19,7 +33,7 @@ export async function fetchRawTemplates(params: FetchRawTemplatesParams): Promis if ("id" in params) { const { data, error } = await supabase .from("agent_templates") - .select(TEMPLATE_WITH_CREATOR_SELECT) + .select(SELECT) .eq("id", params.id); if (error) { console.error("Error selecting template by id:", error); @@ -32,14 +46,12 @@ export async function fetchRawTemplates(params: FetchRawTemplatesParams): Promis const [ownedAndPublic, shared] = await Promise.all([ supabase .from("agent_templates") - .select(TEMPLATE_WITH_CREATOR_SELECT) + .select(SELECT) .or(`creator.eq.${accountId},is_private.eq.false`) .order("title"), supabase .from("agent_template_shares") - .select( - `template:agent_templates!agent_template_shares_template_id_fkey (${TEMPLATE_WITH_CREATOR_SELECT})`, - ) + .select(`template:agent_templates!agent_template_shares_template_id_fkey (${SELECT})`) .eq("user_id", accountId), ]); diff --git a/lib/supabase/templates/flattenCreator.ts b/lib/supabase/templates/flattenCreator.ts index 276a891d6..7e46f1d40 100644 --- a/lib/supabase/templates/flattenCreator.ts +++ b/lib/supabase/templates/flattenCreator.ts @@ -1,4 +1,4 @@ -import type { RawTemplate } from "@/lib/supabase/templates/templateWithCreatorSelect"; +import type { RawTemplate } from "@/lib/supabase/templates/fetchRawTemplates"; export interface TemplateCreator { id: string; diff --git a/lib/supabase/templates/selectTemplates.ts b/lib/supabase/templates/selectTemplates.ts index 85da41658..06c4e897e 100644 --- a/lib/supabase/templates/selectTemplates.ts +++ b/lib/supabase/templates/selectTemplates.ts @@ -4,7 +4,7 @@ import { selectTemplateFavorites } from "@/lib/supabase/template_favorites/selec import { fetchRawTemplates } from "@/lib/supabase/templates/fetchRawTemplates"; import { flattenCreator, type TemplateCreator } from "@/lib/supabase/templates/flattenCreator"; import { resolveSharedEmails } from "@/lib/supabase/templates/resolveSharedEmails"; -import type { RawTemplate } from "@/lib/supabase/templates/templateWithCreatorSelect"; +import type { RawTemplate } from "@/lib/supabase/templates/fetchRawTemplates"; export type { TemplateCreator } from "@/lib/supabase/templates/flattenCreator"; diff --git a/lib/supabase/templates/templateWithCreatorSelect.ts b/lib/supabase/templates/templateWithCreatorSelect.ts deleted file mode 100644 index dfef1cf37..000000000 --- a/lib/supabase/templates/templateWithCreatorSelect.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { QueryData } from "@supabase/supabase-js"; -import supabase from "@/lib/supabase/serverClient"; - -/** - * Shared SELECT fragment for reading agent_templates with the creator - * account joined (id, name, image). `is_admin` is no longer derived from - * embedded emails — callers compute it from organization membership via - * `getAdminAccountIds` and attach it during shaping. - */ -export const TEMPLATE_WITH_CREATOR_SELECT = ` - *, - creator:accounts!agent_templates_creator_fkey ( - id, - name, - account_info ( image ) - ) -` as const; - -const _typedQuery = supabase.from("agent_templates").select(TEMPLATE_WITH_CREATOR_SELECT); - -export type RawTemplate = QueryData[number]; From a03945870cd3d062e9062064b6d077d57b076dc4 Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Tue, 12 May 2026 22:54:32 +0530 Subject: [PATCH 15/32] refactor(templates): inline fetchRawTemplates back into selectTemplates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fetchRawTemplates had a single caller (selectTemplates). The split was a literal reading of the SRP review note rather than a real reuse boundary — shipping it as a private function inside selectTemplates is honest about that. flattenCreator still imports RawTemplate; the type is now exported from selectTemplates. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/supabase/templates/fetchRawTemplates.ts | 80 --------------------- lib/supabase/templates/flattenCreator.ts | 2 +- lib/supabase/templates/selectTemplates.ts | 73 ++++++++++++++++++- 3 files changed, 71 insertions(+), 84 deletions(-) delete mode 100644 lib/supabase/templates/fetchRawTemplates.ts diff --git a/lib/supabase/templates/fetchRawTemplates.ts b/lib/supabase/templates/fetchRawTemplates.ts deleted file mode 100644 index f12d80d5a..000000000 --- a/lib/supabase/templates/fetchRawTemplates.ts +++ /dev/null @@ -1,80 +0,0 @@ -import type { QueryData } from "@supabase/supabase-js"; -import supabase from "@/lib/supabase/serverClient"; - -/** - * The one SELECT for reading templates. The creator account is always - * joined — there's no "template without creator" path in this codebase. - */ -const SELECT = ` - *, - creator:accounts!agent_templates_creator_fkey ( - id, - name, - account_info ( image ) - ) -` as const; - -const _typedQuery = supabase.from("agent_templates").select(SELECT); - -export type RawTemplate = QueryData[number]; - -export type FetchRawTemplatesParams = { id: string } | { accessibleTo: string }; - -/** - * Raw fetch of agent_templates joined with the creator account. - * - * `{ id }` → returns the row with that id (or empty array). - * `{ accessibleTo }` → returns own + public + shared rows for the account, - * deduplicated by id. Caller decides ordering. - * - * Throws on database error. - */ -export async function fetchRawTemplates(params: FetchRawTemplatesParams): Promise { - if ("id" in params) { - const { data, error } = await supabase - .from("agent_templates") - .select(SELECT) - .eq("id", params.id); - if (error) { - console.error("Error selecting template by id:", error); - throw new Error(`fetchRawTemplates(id) failed: ${error.message}`); - } - return data ?? []; - } - - const accountId = params.accessibleTo; - const [ownedAndPublic, shared] = await Promise.all([ - supabase - .from("agent_templates") - .select(SELECT) - .or(`creator.eq.${accountId},is_private.eq.false`) - .order("title"), - supabase - .from("agent_template_shares") - .select(`template:agent_templates!agent_template_shares_template_id_fkey (${SELECT})`) - .eq("user_id", accountId), - ]); - - if (ownedAndPublic.error) { - console.error("Error selecting owned/public templates:", ownedAndPublic.error); - throw new Error( - `fetchRawTemplates(accessibleTo) owned/public failed: ${ownedAndPublic.error.message}`, - ); - } - if (shared.error) { - console.error("Error selecting shared templates:", shared.error); - throw new Error(`fetchRawTemplates(accessibleTo) shared failed: ${shared.error.message}`); - } - - const byId = new Map(); - (ownedAndPublic.data ?? []).forEach(row => byId.set(row.id, row)); - (shared.data ?? []).forEach(s => { - const { template } = s as { template: RawTemplate | RawTemplate[] | null }; - if (!template) return; - const list = Array.isArray(template) ? template : [template]; - list.forEach(t => { - if (t && !byId.has(t.id)) byId.set(t.id, t); - }); - }); - return Array.from(byId.values()); -} diff --git a/lib/supabase/templates/flattenCreator.ts b/lib/supabase/templates/flattenCreator.ts index 7e46f1d40..fedf6e5d9 100644 --- a/lib/supabase/templates/flattenCreator.ts +++ b/lib/supabase/templates/flattenCreator.ts @@ -1,4 +1,4 @@ -import type { RawTemplate } from "@/lib/supabase/templates/fetchRawTemplates"; +import type { RawTemplate } from "@/lib/supabase/templates/selectTemplates"; export interface TemplateCreator { id: string; diff --git a/lib/supabase/templates/selectTemplates.ts b/lib/supabase/templates/selectTemplates.ts index 06c4e897e..14b43c0f8 100644 --- a/lib/supabase/templates/selectTemplates.ts +++ b/lib/supabase/templates/selectTemplates.ts @@ -1,13 +1,30 @@ +import type { QueryData } from "@supabase/supabase-js"; +import supabase from "@/lib/supabase/serverClient"; import type { Tables } from "@/types/database.types"; import { getAdminAccountIds } from "@/lib/admins/getAdminAccountIds"; import { selectTemplateFavorites } from "@/lib/supabase/template_favorites/selectTemplateFavorites"; -import { fetchRawTemplates } from "@/lib/supabase/templates/fetchRawTemplates"; import { flattenCreator, type TemplateCreator } from "@/lib/supabase/templates/flattenCreator"; import { resolveSharedEmails } from "@/lib/supabase/templates/resolveSharedEmails"; -import type { RawTemplate } from "@/lib/supabase/templates/fetchRawTemplates"; export type { TemplateCreator } from "@/lib/supabase/templates/flattenCreator"; +/** + * The one SELECT for reading templates. Creator is always joined — there's + * no "template without creator" path in this codebase. + */ +const SELECT = ` + *, + creator:accounts!agent_templates_creator_fkey ( + id, + name, + account_info ( image ) + ) +` as const; + +const _typedQuery = supabase.from("agent_templates").select(SELECT); + +export type RawTemplate = QueryData[number]; + export type Template = Omit, "creator"> & { creator: TemplateCreator | null; is_favourite: boolean; @@ -35,7 +52,7 @@ export async function selectTemplates( params: SelectTemplatesParams, forAccountId?: string, ): Promise { - const rows = await fetchRawTemplates(params); + const rows = await fetchRaw(params); if (rows.length === 0) return []; const adminAccountIds = await getAdminAccountIds(uniqueCreatorIds(rows)); @@ -67,6 +84,56 @@ export async function selectTemplates( .sort((a, b) => a.title.localeCompare(b.title)); } +async function fetchRaw(params: SelectTemplatesParams): Promise { + if ("id" in params) { + const { data, error } = await supabase + .from("agent_templates") + .select(SELECT) + .eq("id", params.id); + if (error) { + console.error("Error selecting template by id:", error); + throw new Error(`selectTemplates(id) failed: ${error.message}`); + } + return data ?? []; + } + + const accountId = params.accessibleTo; + const [ownedAndPublic, shared] = await Promise.all([ + supabase + .from("agent_templates") + .select(SELECT) + .or(`creator.eq.${accountId},is_private.eq.false`) + .order("title"), + supabase + .from("agent_template_shares") + .select(`template:agent_templates!agent_template_shares_template_id_fkey (${SELECT})`) + .eq("user_id", accountId), + ]); + + if (ownedAndPublic.error) { + console.error("Error selecting owned/public templates:", ownedAndPublic.error); + throw new Error( + `selectTemplates(accessibleTo) owned/public failed: ${ownedAndPublic.error.message}`, + ); + } + if (shared.error) { + console.error("Error selecting shared templates:", shared.error); + throw new Error(`selectTemplates(accessibleTo) shared failed: ${shared.error.message}`); + } + + const byId = new Map(); + (ownedAndPublic.data ?? []).forEach(row => byId.set(row.id, row)); + (shared.data ?? []).forEach(s => { + const { template } = s as { template: RawTemplate | RawTemplate[] | null }; + if (!template) return; + const list = Array.isArray(template) ? template : [template]; + list.forEach(t => { + if (t && !byId.has(t.id)) byId.set(t.id, t); + }); + }); + return Array.from(byId.values()); +} + function uniqueCreatorIds(rows: RawTemplate[]): string[] { const ids = new Set(); rows.forEach(row => { From 8d932bc6a466557946f182ac6aeeac23b87b17ca Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Tue, 12 May 2026 22:56:38 +0530 Subject: [PATCH 16/32] refactor(templates): inline fetchRaw into selectTemplates body MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fetchRaw was an internal helper inside selectTemplates with no second caller. Folding it into the function body removes one layer of indirection — selectTemplates now reads top-to-bottom: fetch (by id or accessibleTo), shape creator, optionally enrich with caller-specific fields. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/supabase/templates/selectTemplates.ts | 122 +++++++++++----------- 1 file changed, 59 insertions(+), 63 deletions(-) diff --git a/lib/supabase/templates/selectTemplates.ts b/lib/supabase/templates/selectTemplates.ts index 14b43c0f8..a62e39778 100644 --- a/lib/supabase/templates/selectTemplates.ts +++ b/lib/supabase/templates/selectTemplates.ts @@ -52,10 +52,67 @@ export async function selectTemplates( params: SelectTemplatesParams, forAccountId?: string, ): Promise { - const rows = await fetchRaw(params); + let rows: RawTemplate[]; + + if ("id" in params) { + const { data, error } = await supabase + .from("agent_templates") + .select(SELECT) + .eq("id", params.id); + if (error) { + console.error("Error selecting template by id:", error); + throw new Error(`selectTemplates(id) failed: ${error.message}`); + } + rows = data ?? []; + } else { + const accountId = params.accessibleTo; + const [ownedAndPublic, shared] = await Promise.all([ + supabase + .from("agent_templates") + .select(SELECT) + .or(`creator.eq.${accountId},is_private.eq.false`) + .order("title"), + supabase + .from("agent_template_shares") + .select(`template:agent_templates!agent_template_shares_template_id_fkey (${SELECT})`) + .eq("user_id", accountId), + ]); + + if (ownedAndPublic.error) { + console.error("Error selecting owned/public templates:", ownedAndPublic.error); + throw new Error( + `selectTemplates(accessibleTo) owned/public failed: ${ownedAndPublic.error.message}`, + ); + } + if (shared.error) { + console.error("Error selecting shared templates:", shared.error); + throw new Error(`selectTemplates(accessibleTo) shared failed: ${shared.error.message}`); + } + + const byId = new Map(); + (ownedAndPublic.data ?? []).forEach(row => byId.set(row.id, row)); + (shared.data ?? []).forEach(s => { + const { template } = s as { template: RawTemplate | RawTemplate[] | null }; + if (!template) return; + const list = Array.isArray(template) ? template : [template]; + list.forEach(t => { + if (t && !byId.has(t.id)) byId.set(t.id, t); + }); + }); + rows = Array.from(byId.values()); + } + if (rows.length === 0) return []; - const adminAccountIds = await getAdminAccountIds(uniqueCreatorIds(rows)); + const creatorIds = new Set(); + rows.forEach(row => { + const c = row.creator; + if (!c) return; + const single = Array.isArray(c) ? c[0] : c; + if (single?.id) creatorIds.add(single.id); + }); + const adminAccountIds = await getAdminAccountIds(Array.from(creatorIds)); + const flattened = rows.map(row => ({ ...row, creator: flattenCreator(row.creator, adminAccountIds), @@ -83,64 +140,3 @@ export async function selectTemplates( })) .sort((a, b) => a.title.localeCompare(b.title)); } - -async function fetchRaw(params: SelectTemplatesParams): Promise { - if ("id" in params) { - const { data, error } = await supabase - .from("agent_templates") - .select(SELECT) - .eq("id", params.id); - if (error) { - console.error("Error selecting template by id:", error); - throw new Error(`selectTemplates(id) failed: ${error.message}`); - } - return data ?? []; - } - - const accountId = params.accessibleTo; - const [ownedAndPublic, shared] = await Promise.all([ - supabase - .from("agent_templates") - .select(SELECT) - .or(`creator.eq.${accountId},is_private.eq.false`) - .order("title"), - supabase - .from("agent_template_shares") - .select(`template:agent_templates!agent_template_shares_template_id_fkey (${SELECT})`) - .eq("user_id", accountId), - ]); - - if (ownedAndPublic.error) { - console.error("Error selecting owned/public templates:", ownedAndPublic.error); - throw new Error( - `selectTemplates(accessibleTo) owned/public failed: ${ownedAndPublic.error.message}`, - ); - } - if (shared.error) { - console.error("Error selecting shared templates:", shared.error); - throw new Error(`selectTemplates(accessibleTo) shared failed: ${shared.error.message}`); - } - - const byId = new Map(); - (ownedAndPublic.data ?? []).forEach(row => byId.set(row.id, row)); - (shared.data ?? []).forEach(s => { - const { template } = s as { template: RawTemplate | RawTemplate[] | null }; - if (!template) return; - const list = Array.isArray(template) ? template : [template]; - list.forEach(t => { - if (t && !byId.has(t.id)) byId.set(t.id, t); - }); - }); - return Array.from(byId.values()); -} - -function uniqueCreatorIds(rows: RawTemplate[]): string[] { - const ids = new Set(); - rows.forEach(row => { - const c = row.creator; - if (!c) return; - const single = Array.isArray(c) ? c[0] : c; - if (single?.id) ids.add(single.id); - }); - return Array.from(ids); -} From b056ccac92ce3414a6ed10a77204266a907b4c90 Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Tue, 12 May 2026 23:00:50 +0530 Subject: [PATCH 17/32] refactor(templates): tighten selectTemplates body MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - All caller-/admin-dependent fetches (admin set, favorites, shared_emails) run in one Promise.all instead of two sequential phases - Two-step Map dedup replaced with concat + once-through filter - Pulled Supabase error rethrow into a small throwOn helper to keep the fetch branches readable - Single map+sort pass at the end builds the response in one place One function (~80-line body), top-to-bottom: fetch → side queries → shape. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/supabase/templates/selectTemplates.ts | 128 +++++++++------------- 1 file changed, 54 insertions(+), 74 deletions(-) diff --git a/lib/supabase/templates/selectTemplates.ts b/lib/supabase/templates/selectTemplates.ts index a62e39778..8d0cdc5d8 100644 --- a/lib/supabase/templates/selectTemplates.ts +++ b/lib/supabase/templates/selectTemplates.ts @@ -6,12 +6,8 @@ import { selectTemplateFavorites } from "@/lib/supabase/template_favorites/selec import { flattenCreator, type TemplateCreator } from "@/lib/supabase/templates/flattenCreator"; import { resolveSharedEmails } from "@/lib/supabase/templates/resolveSharedEmails"; -export type { TemplateCreator } from "@/lib/supabase/templates/flattenCreator"; +export type { TemplateCreator }; -/** - * The one SELECT for reading templates. Creator is always joined — there's - * no "template without creator" path in this codebase. - */ const SELECT = ` *, creator:accounts!agent_templates_creator_fkey ( @@ -22,7 +18,6 @@ const SELECT = ` ` as const; const _typedQuery = supabase.from("agent_templates").select(SELECT); - export type RawTemplate = QueryData[number]; export type Template = Omit, "creator"> & { @@ -33,18 +28,27 @@ export type Template = Omit, "creator"> & { export type SelectTemplatesParams = { id: string } | { accessibleTo: string }; +const creatorIdOf = (r: RawTemplate): string | null => { + const c = r.creator; + if (!c) return null; + return Array.isArray(c) ? (c[0]?.id ?? null) : c.id; +}; + +const throwOn = (label: string, error: { message: string } | null) => { + if (!error) return; + console.error(`Error ${label}:`, error); + throw new Error(`selectTemplates ${label} failed: ${error.message}`); +}; + /** - * Returns agent templates fully shaped for the API: - * - creator block flattened to `{ id, name, image, is_admin }` - * - `is_admin` derived from Recoup org membership (see `getAdminAccountIds`) - * - `is_favourite` populated against `forAccountId` (defaults to `false`) - * - `shared_emails` populated only for private templates `forAccountId` owns + * Reads agent templates shaped for the API. * - * `{ id }` → row with that id, or empty array. - * `{ accessibleTo }` → own + public + shared (deduped, sorted by title). + * - `{ id }` → row with that id, or empty array + * - `{ accessibleTo }` → own + public + shared (deduped, sorted by title) * - * Omit `forAccountId` for internal callers (e.g. ownership validators) that - * only need the creator block and want to skip the per-caller enrichment. + * Pass `forAccountId` to enrich `is_favourite` and `shared_emails` (the + * latter only on private templates the account owns). Omit it for + * lightweight callers (e.g. ownership validators). * * Throws on database error. */ @@ -52,21 +56,18 @@ export async function selectTemplates( params: SelectTemplatesParams, forAccountId?: string, ): Promise { + // 1. Fetch let rows: RawTemplate[]; - if ("id" in params) { const { data, error } = await supabase .from("agent_templates") .select(SELECT) .eq("id", params.id); - if (error) { - console.error("Error selecting template by id:", error); - throw new Error(`selectTemplates(id) failed: ${error.message}`); - } + throwOn("by id", error); rows = data ?? []; } else { const accountId = params.accessibleTo; - const [ownedAndPublic, shared] = await Promise.all([ + const [owned, shared] = await Promise.all([ supabase .from("agent_templates") .select(SELECT) @@ -77,66 +78,45 @@ export async function selectTemplates( .select(`template:agent_templates!agent_template_shares_template_id_fkey (${SELECT})`) .eq("user_id", accountId), ]); + throwOn("owned/public", owned.error); + throwOn("shared", shared.error); - if (ownedAndPublic.error) { - console.error("Error selecting owned/public templates:", ownedAndPublic.error); - throw new Error( - `selectTemplates(accessibleTo) owned/public failed: ${ownedAndPublic.error.message}`, - ); - } - if (shared.error) { - console.error("Error selecting shared templates:", shared.error); - throw new Error(`selectTemplates(accessibleTo) shared failed: ${shared.error.message}`); - } - - const byId = new Map(); - (ownedAndPublic.data ?? []).forEach(row => byId.set(row.id, row)); - (shared.data ?? []).forEach(s => { - const { template } = s as { template: RawTemplate | RawTemplate[] | null }; - if (!template) return; - const list = Array.isArray(template) ? template : [template]; - list.forEach(t => { - if (t && !byId.has(t.id)) byId.set(t.id, t); - }); + const sharedRows = (shared.data ?? []).flatMap(s => { + const t = (s as { template: RawTemplate | RawTemplate[] | null }).template; + return t ? (Array.isArray(t) ? t : [t]) : []; }); - rows = Array.from(byId.values()); + const seen = new Set(); + rows = [...(owned.data ?? []), ...sharedRows].filter(r => + seen.has(r.id) ? false : (seen.add(r.id), true), + ); } - if (rows.length === 0) return []; - const creatorIds = new Set(); - rows.forEach(row => { - const c = row.creator; - if (!c) return; - const single = Array.isArray(c) ? c[0] : c; - if (single?.id) creatorIds.add(single.id); - }); - const adminAccountIds = await getAdminAccountIds(Array.from(creatorIds)); - - const flattened = rows.map(row => ({ - ...row, - creator: flattenCreator(row.creator, adminAccountIds), - })); - - if (!forAccountId) { - return flattened.map(row => ({ ...row, is_favourite: false, shared_emails: [] })); - } - - const ownedPrivateIds = flattened - .filter(r => r.is_private && r.creator?.id === forAccountId) - .map(r => r.id); - const [favorites, sharedEmailsMap] = await Promise.all([ - selectTemplateFavorites(forAccountId), - resolveSharedEmails(ownedPrivateIds), + // 2. Caller-/admin-dependent fetches in parallel + const creatorIds = Array.from( + new Set(rows.map(creatorIdOf).filter((id): id is string => id !== null)), + ); + const ownedPrivateIds = forAccountId + ? rows.filter(r => r.is_private && creatorIdOf(r) === forAccountId).map(r => r.id) + : []; + const [adminIds, favorites, sharedEmails] = await Promise.all([ + getAdminAccountIds(creatorIds), + forAccountId ? selectTemplateFavorites(forAccountId) : Promise.resolve([]), + forAccountId ? resolveSharedEmails(ownedPrivateIds) : Promise.resolve({}), ]); const favoriteIds = new Set(favorites.map(f => f.template_id)); - return flattened - .map(row => ({ - ...row, - is_favourite: favoriteIds.has(row.id), - shared_emails: - row.is_private && row.creator?.id === forAccountId ? (sharedEmailsMap[row.id] ?? []) : [], - })) + // 3. Shape + return rows + .map(row => { + const creator = flattenCreator(row.creator, adminIds); + const isOwnedPrivate = !!forAccountId && row.is_private && creator?.id === forAccountId; + return { + ...row, + creator, + is_favourite: favoriteIds.has(row.id), + shared_emails: isOwnedPrivate ? (sharedEmails[row.id] ?? []) : [], + }; + }) .sort((a, b) => a.title.localeCompare(b.title)); } From 316f131ee5d6178232b3e2bf64593ae7ff800cb8 Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Tue, 12 May 2026 23:05:28 +0530 Subject: [PATCH 18/32] refactor(templates): move all side queries into the main SELECT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before: selectTemplates did one fetch then ran 3 side queries (admin lookup, favorites, shared emails) in JS, and stitched results together. Now: one SELECT embeds everything via PostgREST relationships — creator.account_organization_ids → is_admin agent_template_favorites filtered to caller → is_favourite agent_template_shares → accounts → account_emails → shared_emails JS just unwraps embedded arrays into the response shape. Five helpers deleted: - lib/admins/getAdminAccountIds - lib/supabase/account_organization_ids/selectOrgMemberAccountIds - lib/supabase/template_favorites/selectTemplateFavorites - lib/supabase/templates/flattenCreator - lib/supabase/templates/resolveSharedEmails Roundtrips: 5 → 2 (id case: 5 → 1). One sentinel UUID is used for the favorite filter when no caller is passed (validators), so the embedded favorites array stays empty. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/admins/getAdminAccountIds.ts | 12 -- .../selectOrgMemberAccountIds.ts | 30 ---- .../selectTemplateFavorites.ts | 23 --- lib/supabase/templates/flattenCreator.ts | 29 ---- lib/supabase/templates/resolveSharedEmails.ts | 51 ------ lib/supabase/templates/selectTemplates.ts | 145 +++++++++++------- 6 files changed, 89 insertions(+), 201 deletions(-) delete mode 100644 lib/admins/getAdminAccountIds.ts delete mode 100644 lib/supabase/account_organization_ids/selectOrgMemberAccountIds.ts delete mode 100644 lib/supabase/template_favorites/selectTemplateFavorites.ts delete mode 100644 lib/supabase/templates/flattenCreator.ts delete mode 100644 lib/supabase/templates/resolveSharedEmails.ts diff --git a/lib/admins/getAdminAccountIds.ts b/lib/admins/getAdminAccountIds.ts deleted file mode 100644 index 3f7c7abb8..000000000 --- a/lib/admins/getAdminAccountIds.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { RECOUP_ORG_ID } from "@/lib/const"; -import { selectOrgMemberAccountIds } from "@/lib/supabase/account_organization_ids/selectOrgMemberAccountIds"; - -/** - * Returns the subset of `accountIds` that are Recoup admins — i.e. members - * of the Recoup organization. Mirrors `checkIsAdmin`'s single-account check, - * batched for use when many accounts need to be classified at once. - */ -export async function getAdminAccountIds(accountIds: string[]): Promise> { - const ids = await selectOrgMemberAccountIds(RECOUP_ORG_ID, accountIds); - return new Set(ids); -} diff --git a/lib/supabase/account_organization_ids/selectOrgMemberAccountIds.ts b/lib/supabase/account_organization_ids/selectOrgMemberAccountIds.ts deleted file mode 100644 index 6c3d95993..000000000 --- a/lib/supabase/account_organization_ids/selectOrgMemberAccountIds.ts +++ /dev/null @@ -1,30 +0,0 @@ -import supabase from "@/lib/supabase/serverClient"; - -/** - * For a given organization, returns the subset of `accountIds` that are - * members of it. Used for batch admin checks (e.g. "which of these template - * creators are members of the Recoup org"). - * - * Returns an empty array on database error or empty input. - */ -export async function selectOrgMemberAccountIds( - organizationId: string, - accountIds: string[], -): Promise { - if (!organizationId || accountIds.length === 0) return []; - - const { data, error } = await supabase - .from("account_organization_ids") - .select("account_id") - .eq("organization_id", organizationId) - .in("account_id", accountIds); - - if (error) { - console.error("Error selecting org members:", error); - throw new Error(`selectOrgMemberAccountIds failed: ${error.message}`); - } - - return (data ?? []) - .map(row => row.account_id) - .filter((id): id is string => typeof id === "string"); -} diff --git a/lib/supabase/template_favorites/selectTemplateFavorites.ts b/lib/supabase/template_favorites/selectTemplateFavorites.ts deleted file mode 100644 index fa48a52b2..000000000 --- a/lib/supabase/template_favorites/selectTemplateFavorites.ts +++ /dev/null @@ -1,23 +0,0 @@ -import supabase from "@/lib/supabase/serverClient"; -import type { Tables } from "@/types/database.types"; - -/** - * Selects raw `template_favorites` rows for the given account. Throws on - * database error so callers can distinguish a real failure from "account - * has no favorites". - */ -export async function selectTemplateFavorites( - accountId: string, -): Promise[]> { - const { data, error } = await supabase - .from("agent_template_favorites") - .select("*") - .eq("user_id", accountId); - - if (error) { - console.error("Error selecting template_favorites:", error); - throw new Error(`selectTemplateFavorites failed: ${error.message}`); - } - - return data ?? []; -} diff --git a/lib/supabase/templates/flattenCreator.ts b/lib/supabase/templates/flattenCreator.ts deleted file mode 100644 index fedf6e5d9..000000000 --- a/lib/supabase/templates/flattenCreator.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { RawTemplate } from "@/lib/supabase/templates/selectTemplates"; - -export interface TemplateCreator { - id: string; - name: string | null; - image: string | null; - is_admin: boolean; -} - -/** - * Flattens the joined creator block on a raw template row into the API - * response shape. `is_admin` is taken from the supplied set so the caller - * can batch-compute admin membership across many rows in one query - * (see `getAdminAccountIds`). - */ -export function flattenCreator( - creator: RawTemplate["creator"], - adminAccountIds: Set, -): TemplateCreator | null { - if (!creator) return null; - const row = Array.isArray(creator) ? creator[0] : creator; - if (!row) return null; - return { - id: row.id, - name: row.name ?? null, - image: row.account_info?.[0]?.image ?? null, - is_admin: adminAccountIds.has(row.id), - }; -} diff --git a/lib/supabase/templates/resolveSharedEmails.ts b/lib/supabase/templates/resolveSharedEmails.ts deleted file mode 100644 index d32e1dd6b..000000000 --- a/lib/supabase/templates/resolveSharedEmails.ts +++ /dev/null @@ -1,51 +0,0 @@ -import supabase from "@/lib/supabase/serverClient"; - -/** - * For each supplied template id, returns the emails it has been shared with. - * One round trip: embeds the share's `user_id → accounts → account_emails` - * chain in a single PostgREST query, then groups + dedupes the emails by - * template id in memory. Empty input returns an empty map. - * - * PostgREST doesn't support array aggregation in select strings, so the - * grouping step has to live here (or in a Postgres view/RPC, which would - * be a schema change). - */ -export async function resolveSharedEmails( - templateIds: string[], -): Promise> { - if (templateIds.length === 0) return {}; - - const { data, error } = await supabase - .from("agent_template_shares") - .select( - ` - template_id, - sharee:accounts!agent_template_shares_user_id_fkey ( - account_emails ( email ) - ) - `, - ) - .in("template_id", templateIds); - - if (error) { - console.error("Error selecting template shares with emails:", error); - throw new Error(`resolveSharedEmails failed: ${error.message}`); - } - - const result: Record = {}; - (data ?? []).forEach(row => { - const shareeList = Array.isArray(row.sharee) ? row.sharee : row.sharee ? [row.sharee] : []; - shareeList.forEach(sharee => { - sharee.account_emails?.forEach(ae => { - if (!ae?.email) return; - const list = result[row.template_id] ?? []; - list.push(ae.email); - result[row.template_id] = list; - }); - }); - }); - Object.keys(result).forEach(id => { - result[id] = Array.from(new Set(result[id])); - }); - return result; -} diff --git a/lib/supabase/templates/selectTemplates.ts b/lib/supabase/templates/selectTemplates.ts index 8d0cdc5d8..e077313cd 100644 --- a/lib/supabase/templates/selectTemplates.ts +++ b/lib/supabase/templates/selectTemplates.ts @@ -1,24 +1,14 @@ import type { QueryData } from "@supabase/supabase-js"; import supabase from "@/lib/supabase/serverClient"; +import { RECOUP_ORG_ID } from "@/lib/const"; import type { Tables } from "@/types/database.types"; -import { getAdminAccountIds } from "@/lib/admins/getAdminAccountIds"; -import { selectTemplateFavorites } from "@/lib/supabase/template_favorites/selectTemplateFavorites"; -import { flattenCreator, type TemplateCreator } from "@/lib/supabase/templates/flattenCreator"; -import { resolveSharedEmails } from "@/lib/supabase/templates/resolveSharedEmails"; -export type { TemplateCreator }; - -const SELECT = ` - *, - creator:accounts!agent_templates_creator_fkey ( - id, - name, - account_info ( image ) - ) -` as const; - -const _typedQuery = supabase.from("agent_templates").select(SELECT); -export type RawTemplate = QueryData[number]; +export interface TemplateCreator { + id: string; + name: string | null; + image: string | null; + is_admin: boolean; +} export type Template = Omit, "creator"> & { creator: TemplateCreator | null; @@ -28,17 +18,33 @@ export type Template = Omit, "creator"> & { export type SelectTemplatesParams = { id: string } | { accessibleTo: string }; -const creatorIdOf = (r: RawTemplate): string | null => { - const c = r.creator; - if (!c) return null; - return Array.isArray(c) ? (c[0]?.id ?? null) : c.id; -}; +// Sentinel used for the favorite filter when no caller is passed (validators). +// PostgREST filters the embed by user_id; this UUID matches nothing → +// caller_favorite stays empty and `is_favourite` resolves to `false`. +const NO_CALLER = "00000000-0000-0000-0000-000000000000"; -const throwOn = (label: string, error: { message: string } | null) => { - if (!error) return; - console.error(`Error ${label}:`, error); - throw new Error(`selectTemplates ${label} failed: ${error.message}`); -}; +// Everything the API response needs, in one shot: +// creator.org_membership → presence ⇒ is_admin +// caller_favorite → presence ⇒ is_favourite (filtered to caller) +// template_shares.sharee.account_emails → flatten ⇒ shared_emails +const SELECT = ` + *, + creator:accounts!agent_templates_creator_fkey ( + id, + name, + account_info ( image ), + org_membership:account_organization_ids ( organization_id ) + ), + caller_favorite:agent_template_favorites ( user_id ), + template_shares:agent_template_shares ( + sharee:accounts!agent_template_shares_user_id_fkey ( + account_emails ( email ) + ) + ) +` as const; + +const _typedQuery = supabase.from("agent_templates").select(SELECT); +type RawTemplate = QueryData[number]; /** * Reads agent templates shaped for the API. @@ -46,9 +52,10 @@ const throwOn = (label: string, error: { message: string } | null) => { * - `{ id }` → row with that id, or empty array * - `{ accessibleTo }` → own + public + shared (deduped, sorted by title) * - * Pass `forAccountId` to enrich `is_favourite` and `shared_emails` (the - * latter only on private templates the account owns). Omit it for - * lightweight callers (e.g. ownership validators). + * Everything the response needs — `is_admin`, `is_favourite`, + * `shared_emails` — is embedded in the same query via PostgREST joins. + * The JS step just unwraps the embedded arrays into booleans and dedupes + * emails. * * Throws on database error. */ @@ -56,14 +63,21 @@ export async function selectTemplates( params: SelectTemplatesParams, forAccountId?: string, ): Promise { + const callerId = forAccountId ?? NO_CALLER; + // 1. Fetch let rows: RawTemplate[]; if ("id" in params) { const { data, error } = await supabase .from("agent_templates") .select(SELECT) - .eq("id", params.id); - throwOn("by id", error); + .eq("id", params.id) + .eq("creator.org_membership.organization_id", RECOUP_ORG_ID) + .eq("caller_favorite.user_id", callerId); + if (error) { + console.error("Error selecting template by id:", error); + throw new Error(`selectTemplates(id) failed: ${error.message}`); + } rows = data ?? []; } else { const accountId = params.accessibleTo; @@ -72,15 +86,24 @@ export async function selectTemplates( .from("agent_templates") .select(SELECT) .or(`creator.eq.${accountId},is_private.eq.false`) + .eq("creator.org_membership.organization_id", RECOUP_ORG_ID) + .eq("caller_favorite.user_id", callerId) .order("title"), supabase .from("agent_template_shares") .select(`template:agent_templates!agent_template_shares_template_id_fkey (${SELECT})`) - .eq("user_id", accountId), + .eq("user_id", accountId) + .eq("template.creator.org_membership.organization_id", RECOUP_ORG_ID) + .eq("template.caller_favorite.user_id", callerId), ]); - throwOn("owned/public", owned.error); - throwOn("shared", shared.error); - + if (owned.error) { + console.error("Error selecting owned/public templates:", owned.error); + throw new Error(`selectTemplates(accessibleTo) owned/public failed: ${owned.error.message}`); + } + if (shared.error) { + console.error("Error selecting shared templates:", shared.error); + throw new Error(`selectTemplates(accessibleTo) shared failed: ${shared.error.message}`); + } const sharedRows = (shared.data ?? []).flatMap(s => { const t = (s as { template: RawTemplate | RawTemplate[] | null }).template; return t ? (Array.isArray(t) ? t : [t]) : []; @@ -92,30 +115,40 @@ export async function selectTemplates( } if (rows.length === 0) return []; - // 2. Caller-/admin-dependent fetches in parallel - const creatorIds = Array.from( - new Set(rows.map(creatorIdOf).filter((id): id is string => id !== null)), - ); - const ownedPrivateIds = forAccountId - ? rows.filter(r => r.is_private && creatorIdOf(r) === forAccountId).map(r => r.id) - : []; - const [adminIds, favorites, sharedEmails] = await Promise.all([ - getAdminAccountIds(creatorIds), - forAccountId ? selectTemplateFavorites(forAccountId) : Promise.resolve([]), - forAccountId ? resolveSharedEmails(ownedPrivateIds) : Promise.resolve({}), - ]); - const favoriteIds = new Set(favorites.map(f => f.template_id)); - - // 3. Shape + // 2. Shape — unwrap embedded arrays into booleans + emails return rows .map(row => { - const creator = flattenCreator(row.creator, adminIds); - const isOwnedPrivate = !!forAccountId && row.is_private && creator?.id === forAccountId; + const creatorRow = Array.isArray(row.creator) ? row.creator[0] : row.creator; + const creator: TemplateCreator | null = creatorRow + ? { + id: creatorRow.id, + name: creatorRow.name ?? null, + image: creatorRow.account_info?.[0]?.image ?? null, + is_admin: (creatorRow.org_membership ?? []).length > 0, + } + : null; + const isOwnedPrivate = + !!forAccountId && row.is_private && creator?.id === forAccountId; + const sharedEmails = isOwnedPrivate + ? Array.from( + new Set( + (row.template_shares ?? []).flatMap(s => { + const sharees = Array.isArray(s.sharee) ? s.sharee : s.sharee ? [s.sharee] : []; + return sharees.flatMap(sh => + (sh.account_emails ?? []) + .map(ae => ae.email) + .filter((e): e is string => typeof e === "string"), + ); + }), + ), + ) + : []; + const { creator: _c, caller_favorite, template_shares: _ts, ...rest } = row; return { - ...row, + ...rest, creator, - is_favourite: favoriteIds.has(row.id), - shared_emails: isOwnedPrivate ? (sharedEmails[row.id] ?? []) : [], + is_favourite: (caller_favorite ?? []).length > 0, + shared_emails: sharedEmails, }; }) .sort((a, b) => a.title.localeCompare(b.title)); From 9d02be1f5a3694ddcb469071d0486d65091e9bca Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Tue, 12 May 2026 23:07:59 +0530 Subject: [PATCH 19/32] Revert "refactor(templates): move all side queries into the main SELECT" This reverts commit 316f131ee5d6178232b3e2bf64593ae7ff800cb8. --- lib/admins/getAdminAccountIds.ts | 12 ++ .../selectOrgMemberAccountIds.ts | 30 ++++ .../selectTemplateFavorites.ts | 23 +++ lib/supabase/templates/flattenCreator.ts | 29 ++++ lib/supabase/templates/resolveSharedEmails.ts | 51 ++++++ lib/supabase/templates/selectTemplates.ts | 145 +++++++----------- 6 files changed, 201 insertions(+), 89 deletions(-) create mode 100644 lib/admins/getAdminAccountIds.ts create mode 100644 lib/supabase/account_organization_ids/selectOrgMemberAccountIds.ts create mode 100644 lib/supabase/template_favorites/selectTemplateFavorites.ts create mode 100644 lib/supabase/templates/flattenCreator.ts create mode 100644 lib/supabase/templates/resolveSharedEmails.ts diff --git a/lib/admins/getAdminAccountIds.ts b/lib/admins/getAdminAccountIds.ts new file mode 100644 index 000000000..3f7c7abb8 --- /dev/null +++ b/lib/admins/getAdminAccountIds.ts @@ -0,0 +1,12 @@ +import { RECOUP_ORG_ID } from "@/lib/const"; +import { selectOrgMemberAccountIds } from "@/lib/supabase/account_organization_ids/selectOrgMemberAccountIds"; + +/** + * Returns the subset of `accountIds` that are Recoup admins — i.e. members + * of the Recoup organization. Mirrors `checkIsAdmin`'s single-account check, + * batched for use when many accounts need to be classified at once. + */ +export async function getAdminAccountIds(accountIds: string[]): Promise> { + const ids = await selectOrgMemberAccountIds(RECOUP_ORG_ID, accountIds); + return new Set(ids); +} diff --git a/lib/supabase/account_organization_ids/selectOrgMemberAccountIds.ts b/lib/supabase/account_organization_ids/selectOrgMemberAccountIds.ts new file mode 100644 index 000000000..6c3d95993 --- /dev/null +++ b/lib/supabase/account_organization_ids/selectOrgMemberAccountIds.ts @@ -0,0 +1,30 @@ +import supabase from "@/lib/supabase/serverClient"; + +/** + * For a given organization, returns the subset of `accountIds` that are + * members of it. Used for batch admin checks (e.g. "which of these template + * creators are members of the Recoup org"). + * + * Returns an empty array on database error or empty input. + */ +export async function selectOrgMemberAccountIds( + organizationId: string, + accountIds: string[], +): Promise { + if (!organizationId || accountIds.length === 0) return []; + + const { data, error } = await supabase + .from("account_organization_ids") + .select("account_id") + .eq("organization_id", organizationId) + .in("account_id", accountIds); + + if (error) { + console.error("Error selecting org members:", error); + throw new Error(`selectOrgMemberAccountIds failed: ${error.message}`); + } + + return (data ?? []) + .map(row => row.account_id) + .filter((id): id is string => typeof id === "string"); +} diff --git a/lib/supabase/template_favorites/selectTemplateFavorites.ts b/lib/supabase/template_favorites/selectTemplateFavorites.ts new file mode 100644 index 000000000..fa48a52b2 --- /dev/null +++ b/lib/supabase/template_favorites/selectTemplateFavorites.ts @@ -0,0 +1,23 @@ +import supabase from "@/lib/supabase/serverClient"; +import type { Tables } from "@/types/database.types"; + +/** + * Selects raw `template_favorites` rows for the given account. Throws on + * database error so callers can distinguish a real failure from "account + * has no favorites". + */ +export async function selectTemplateFavorites( + accountId: string, +): Promise[]> { + const { data, error } = await supabase + .from("agent_template_favorites") + .select("*") + .eq("user_id", accountId); + + if (error) { + console.error("Error selecting template_favorites:", error); + throw new Error(`selectTemplateFavorites failed: ${error.message}`); + } + + return data ?? []; +} diff --git a/lib/supabase/templates/flattenCreator.ts b/lib/supabase/templates/flattenCreator.ts new file mode 100644 index 000000000..fedf6e5d9 --- /dev/null +++ b/lib/supabase/templates/flattenCreator.ts @@ -0,0 +1,29 @@ +import type { RawTemplate } from "@/lib/supabase/templates/selectTemplates"; + +export interface TemplateCreator { + id: string; + name: string | null; + image: string | null; + is_admin: boolean; +} + +/** + * Flattens the joined creator block on a raw template row into the API + * response shape. `is_admin` is taken from the supplied set so the caller + * can batch-compute admin membership across many rows in one query + * (see `getAdminAccountIds`). + */ +export function flattenCreator( + creator: RawTemplate["creator"], + adminAccountIds: Set, +): TemplateCreator | null { + if (!creator) return null; + const row = Array.isArray(creator) ? creator[0] : creator; + if (!row) return null; + return { + id: row.id, + name: row.name ?? null, + image: row.account_info?.[0]?.image ?? null, + is_admin: adminAccountIds.has(row.id), + }; +} diff --git a/lib/supabase/templates/resolveSharedEmails.ts b/lib/supabase/templates/resolveSharedEmails.ts new file mode 100644 index 000000000..d32e1dd6b --- /dev/null +++ b/lib/supabase/templates/resolveSharedEmails.ts @@ -0,0 +1,51 @@ +import supabase from "@/lib/supabase/serverClient"; + +/** + * For each supplied template id, returns the emails it has been shared with. + * One round trip: embeds the share's `user_id → accounts → account_emails` + * chain in a single PostgREST query, then groups + dedupes the emails by + * template id in memory. Empty input returns an empty map. + * + * PostgREST doesn't support array aggregation in select strings, so the + * grouping step has to live here (or in a Postgres view/RPC, which would + * be a schema change). + */ +export async function resolveSharedEmails( + templateIds: string[], +): Promise> { + if (templateIds.length === 0) return {}; + + const { data, error } = await supabase + .from("agent_template_shares") + .select( + ` + template_id, + sharee:accounts!agent_template_shares_user_id_fkey ( + account_emails ( email ) + ) + `, + ) + .in("template_id", templateIds); + + if (error) { + console.error("Error selecting template shares with emails:", error); + throw new Error(`resolveSharedEmails failed: ${error.message}`); + } + + const result: Record = {}; + (data ?? []).forEach(row => { + const shareeList = Array.isArray(row.sharee) ? row.sharee : row.sharee ? [row.sharee] : []; + shareeList.forEach(sharee => { + sharee.account_emails?.forEach(ae => { + if (!ae?.email) return; + const list = result[row.template_id] ?? []; + list.push(ae.email); + result[row.template_id] = list; + }); + }); + }); + Object.keys(result).forEach(id => { + result[id] = Array.from(new Set(result[id])); + }); + return result; +} diff --git a/lib/supabase/templates/selectTemplates.ts b/lib/supabase/templates/selectTemplates.ts index e077313cd..8d0cdc5d8 100644 --- a/lib/supabase/templates/selectTemplates.ts +++ b/lib/supabase/templates/selectTemplates.ts @@ -1,50 +1,44 @@ import type { QueryData } from "@supabase/supabase-js"; import supabase from "@/lib/supabase/serverClient"; -import { RECOUP_ORG_ID } from "@/lib/const"; import type { Tables } from "@/types/database.types"; +import { getAdminAccountIds } from "@/lib/admins/getAdminAccountIds"; +import { selectTemplateFavorites } from "@/lib/supabase/template_favorites/selectTemplateFavorites"; +import { flattenCreator, type TemplateCreator } from "@/lib/supabase/templates/flattenCreator"; +import { resolveSharedEmails } from "@/lib/supabase/templates/resolveSharedEmails"; -export interface TemplateCreator { - id: string; - name: string | null; - image: string | null; - is_admin: boolean; -} - -export type Template = Omit, "creator"> & { - creator: TemplateCreator | null; - is_favourite: boolean; - shared_emails: string[]; -}; +export type { TemplateCreator }; -export type SelectTemplatesParams = { id: string } | { accessibleTo: string }; - -// Sentinel used for the favorite filter when no caller is passed (validators). -// PostgREST filters the embed by user_id; this UUID matches nothing → -// caller_favorite stays empty and `is_favourite` resolves to `false`. -const NO_CALLER = "00000000-0000-0000-0000-000000000000"; - -// Everything the API response needs, in one shot: -// creator.org_membership → presence ⇒ is_admin -// caller_favorite → presence ⇒ is_favourite (filtered to caller) -// template_shares.sharee.account_emails → flatten ⇒ shared_emails const SELECT = ` *, creator:accounts!agent_templates_creator_fkey ( id, name, - account_info ( image ), - org_membership:account_organization_ids ( organization_id ) - ), - caller_favorite:agent_template_favorites ( user_id ), - template_shares:agent_template_shares ( - sharee:accounts!agent_template_shares_user_id_fkey ( - account_emails ( email ) - ) + account_info ( image ) ) ` as const; const _typedQuery = supabase.from("agent_templates").select(SELECT); -type RawTemplate = QueryData[number]; +export type RawTemplate = QueryData[number]; + +export type Template = Omit, "creator"> & { + creator: TemplateCreator | null; + is_favourite: boolean; + shared_emails: string[]; +}; + +export type SelectTemplatesParams = { id: string } | { accessibleTo: string }; + +const creatorIdOf = (r: RawTemplate): string | null => { + const c = r.creator; + if (!c) return null; + return Array.isArray(c) ? (c[0]?.id ?? null) : c.id; +}; + +const throwOn = (label: string, error: { message: string } | null) => { + if (!error) return; + console.error(`Error ${label}:`, error); + throw new Error(`selectTemplates ${label} failed: ${error.message}`); +}; /** * Reads agent templates shaped for the API. @@ -52,10 +46,9 @@ type RawTemplate = QueryData[number]; * - `{ id }` → row with that id, or empty array * - `{ accessibleTo }` → own + public + shared (deduped, sorted by title) * - * Everything the response needs — `is_admin`, `is_favourite`, - * `shared_emails` — is embedded in the same query via PostgREST joins. - * The JS step just unwraps the embedded arrays into booleans and dedupes - * emails. + * Pass `forAccountId` to enrich `is_favourite` and `shared_emails` (the + * latter only on private templates the account owns). Omit it for + * lightweight callers (e.g. ownership validators). * * Throws on database error. */ @@ -63,21 +56,14 @@ export async function selectTemplates( params: SelectTemplatesParams, forAccountId?: string, ): Promise { - const callerId = forAccountId ?? NO_CALLER; - // 1. Fetch let rows: RawTemplate[]; if ("id" in params) { const { data, error } = await supabase .from("agent_templates") .select(SELECT) - .eq("id", params.id) - .eq("creator.org_membership.organization_id", RECOUP_ORG_ID) - .eq("caller_favorite.user_id", callerId); - if (error) { - console.error("Error selecting template by id:", error); - throw new Error(`selectTemplates(id) failed: ${error.message}`); - } + .eq("id", params.id); + throwOn("by id", error); rows = data ?? []; } else { const accountId = params.accessibleTo; @@ -86,24 +72,15 @@ export async function selectTemplates( .from("agent_templates") .select(SELECT) .or(`creator.eq.${accountId},is_private.eq.false`) - .eq("creator.org_membership.organization_id", RECOUP_ORG_ID) - .eq("caller_favorite.user_id", callerId) .order("title"), supabase .from("agent_template_shares") .select(`template:agent_templates!agent_template_shares_template_id_fkey (${SELECT})`) - .eq("user_id", accountId) - .eq("template.creator.org_membership.organization_id", RECOUP_ORG_ID) - .eq("template.caller_favorite.user_id", callerId), + .eq("user_id", accountId), ]); - if (owned.error) { - console.error("Error selecting owned/public templates:", owned.error); - throw new Error(`selectTemplates(accessibleTo) owned/public failed: ${owned.error.message}`); - } - if (shared.error) { - console.error("Error selecting shared templates:", shared.error); - throw new Error(`selectTemplates(accessibleTo) shared failed: ${shared.error.message}`); - } + throwOn("owned/public", owned.error); + throwOn("shared", shared.error); + const sharedRows = (shared.data ?? []).flatMap(s => { const t = (s as { template: RawTemplate | RawTemplate[] | null }).template; return t ? (Array.isArray(t) ? t : [t]) : []; @@ -115,40 +92,30 @@ export async function selectTemplates( } if (rows.length === 0) return []; - // 2. Shape — unwrap embedded arrays into booleans + emails + // 2. Caller-/admin-dependent fetches in parallel + const creatorIds = Array.from( + new Set(rows.map(creatorIdOf).filter((id): id is string => id !== null)), + ); + const ownedPrivateIds = forAccountId + ? rows.filter(r => r.is_private && creatorIdOf(r) === forAccountId).map(r => r.id) + : []; + const [adminIds, favorites, sharedEmails] = await Promise.all([ + getAdminAccountIds(creatorIds), + forAccountId ? selectTemplateFavorites(forAccountId) : Promise.resolve([]), + forAccountId ? resolveSharedEmails(ownedPrivateIds) : Promise.resolve({}), + ]); + const favoriteIds = new Set(favorites.map(f => f.template_id)); + + // 3. Shape return rows .map(row => { - const creatorRow = Array.isArray(row.creator) ? row.creator[0] : row.creator; - const creator: TemplateCreator | null = creatorRow - ? { - id: creatorRow.id, - name: creatorRow.name ?? null, - image: creatorRow.account_info?.[0]?.image ?? null, - is_admin: (creatorRow.org_membership ?? []).length > 0, - } - : null; - const isOwnedPrivate = - !!forAccountId && row.is_private && creator?.id === forAccountId; - const sharedEmails = isOwnedPrivate - ? Array.from( - new Set( - (row.template_shares ?? []).flatMap(s => { - const sharees = Array.isArray(s.sharee) ? s.sharee : s.sharee ? [s.sharee] : []; - return sharees.flatMap(sh => - (sh.account_emails ?? []) - .map(ae => ae.email) - .filter((e): e is string => typeof e === "string"), - ); - }), - ), - ) - : []; - const { creator: _c, caller_favorite, template_shares: _ts, ...rest } = row; + const creator = flattenCreator(row.creator, adminIds); + const isOwnedPrivate = !!forAccountId && row.is_private && creator?.id === forAccountId; return { - ...rest, + ...row, creator, - is_favourite: (caller_favorite ?? []).length > 0, - shared_emails: sharedEmails, + is_favourite: favoriteIds.has(row.id), + shared_emails: isOwnedPrivate ? (sharedEmails[row.id] ?? []) : [], }; }) .sort((a, b) => a.title.localeCompare(b.title)); From aecf064d108d9cc4ca8787fb021f1f871956ab49 Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Tue, 12 May 2026 23:13:50 +0530 Subject: [PATCH 20/32] Reapply "refactor(templates): move all side queries into the main SELECT" This reverts commit 9d02be1f5a3694ddcb469071d0486d65091e9bca. --- lib/admins/getAdminAccountIds.ts | 12 -- .../selectOrgMemberAccountIds.ts | 30 ---- .../selectTemplateFavorites.ts | 23 --- lib/supabase/templates/flattenCreator.ts | 29 ---- lib/supabase/templates/resolveSharedEmails.ts | 51 ------ lib/supabase/templates/selectTemplates.ts | 145 +++++++++++------- 6 files changed, 89 insertions(+), 201 deletions(-) delete mode 100644 lib/admins/getAdminAccountIds.ts delete mode 100644 lib/supabase/account_organization_ids/selectOrgMemberAccountIds.ts delete mode 100644 lib/supabase/template_favorites/selectTemplateFavorites.ts delete mode 100644 lib/supabase/templates/flattenCreator.ts delete mode 100644 lib/supabase/templates/resolveSharedEmails.ts diff --git a/lib/admins/getAdminAccountIds.ts b/lib/admins/getAdminAccountIds.ts deleted file mode 100644 index 3f7c7abb8..000000000 --- a/lib/admins/getAdminAccountIds.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { RECOUP_ORG_ID } from "@/lib/const"; -import { selectOrgMemberAccountIds } from "@/lib/supabase/account_organization_ids/selectOrgMemberAccountIds"; - -/** - * Returns the subset of `accountIds` that are Recoup admins — i.e. members - * of the Recoup organization. Mirrors `checkIsAdmin`'s single-account check, - * batched for use when many accounts need to be classified at once. - */ -export async function getAdminAccountIds(accountIds: string[]): Promise> { - const ids = await selectOrgMemberAccountIds(RECOUP_ORG_ID, accountIds); - return new Set(ids); -} diff --git a/lib/supabase/account_organization_ids/selectOrgMemberAccountIds.ts b/lib/supabase/account_organization_ids/selectOrgMemberAccountIds.ts deleted file mode 100644 index 6c3d95993..000000000 --- a/lib/supabase/account_organization_ids/selectOrgMemberAccountIds.ts +++ /dev/null @@ -1,30 +0,0 @@ -import supabase from "@/lib/supabase/serverClient"; - -/** - * For a given organization, returns the subset of `accountIds` that are - * members of it. Used for batch admin checks (e.g. "which of these template - * creators are members of the Recoup org"). - * - * Returns an empty array on database error or empty input. - */ -export async function selectOrgMemberAccountIds( - organizationId: string, - accountIds: string[], -): Promise { - if (!organizationId || accountIds.length === 0) return []; - - const { data, error } = await supabase - .from("account_organization_ids") - .select("account_id") - .eq("organization_id", organizationId) - .in("account_id", accountIds); - - if (error) { - console.error("Error selecting org members:", error); - throw new Error(`selectOrgMemberAccountIds failed: ${error.message}`); - } - - return (data ?? []) - .map(row => row.account_id) - .filter((id): id is string => typeof id === "string"); -} diff --git a/lib/supabase/template_favorites/selectTemplateFavorites.ts b/lib/supabase/template_favorites/selectTemplateFavorites.ts deleted file mode 100644 index fa48a52b2..000000000 --- a/lib/supabase/template_favorites/selectTemplateFavorites.ts +++ /dev/null @@ -1,23 +0,0 @@ -import supabase from "@/lib/supabase/serverClient"; -import type { Tables } from "@/types/database.types"; - -/** - * Selects raw `template_favorites` rows for the given account. Throws on - * database error so callers can distinguish a real failure from "account - * has no favorites". - */ -export async function selectTemplateFavorites( - accountId: string, -): Promise[]> { - const { data, error } = await supabase - .from("agent_template_favorites") - .select("*") - .eq("user_id", accountId); - - if (error) { - console.error("Error selecting template_favorites:", error); - throw new Error(`selectTemplateFavorites failed: ${error.message}`); - } - - return data ?? []; -} diff --git a/lib/supabase/templates/flattenCreator.ts b/lib/supabase/templates/flattenCreator.ts deleted file mode 100644 index fedf6e5d9..000000000 --- a/lib/supabase/templates/flattenCreator.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { RawTemplate } from "@/lib/supabase/templates/selectTemplates"; - -export interface TemplateCreator { - id: string; - name: string | null; - image: string | null; - is_admin: boolean; -} - -/** - * Flattens the joined creator block on a raw template row into the API - * response shape. `is_admin` is taken from the supplied set so the caller - * can batch-compute admin membership across many rows in one query - * (see `getAdminAccountIds`). - */ -export function flattenCreator( - creator: RawTemplate["creator"], - adminAccountIds: Set, -): TemplateCreator | null { - if (!creator) return null; - const row = Array.isArray(creator) ? creator[0] : creator; - if (!row) return null; - return { - id: row.id, - name: row.name ?? null, - image: row.account_info?.[0]?.image ?? null, - is_admin: adminAccountIds.has(row.id), - }; -} diff --git a/lib/supabase/templates/resolveSharedEmails.ts b/lib/supabase/templates/resolveSharedEmails.ts deleted file mode 100644 index d32e1dd6b..000000000 --- a/lib/supabase/templates/resolveSharedEmails.ts +++ /dev/null @@ -1,51 +0,0 @@ -import supabase from "@/lib/supabase/serverClient"; - -/** - * For each supplied template id, returns the emails it has been shared with. - * One round trip: embeds the share's `user_id → accounts → account_emails` - * chain in a single PostgREST query, then groups + dedupes the emails by - * template id in memory. Empty input returns an empty map. - * - * PostgREST doesn't support array aggregation in select strings, so the - * grouping step has to live here (or in a Postgres view/RPC, which would - * be a schema change). - */ -export async function resolveSharedEmails( - templateIds: string[], -): Promise> { - if (templateIds.length === 0) return {}; - - const { data, error } = await supabase - .from("agent_template_shares") - .select( - ` - template_id, - sharee:accounts!agent_template_shares_user_id_fkey ( - account_emails ( email ) - ) - `, - ) - .in("template_id", templateIds); - - if (error) { - console.error("Error selecting template shares with emails:", error); - throw new Error(`resolveSharedEmails failed: ${error.message}`); - } - - const result: Record = {}; - (data ?? []).forEach(row => { - const shareeList = Array.isArray(row.sharee) ? row.sharee : row.sharee ? [row.sharee] : []; - shareeList.forEach(sharee => { - sharee.account_emails?.forEach(ae => { - if (!ae?.email) return; - const list = result[row.template_id] ?? []; - list.push(ae.email); - result[row.template_id] = list; - }); - }); - }); - Object.keys(result).forEach(id => { - result[id] = Array.from(new Set(result[id])); - }); - return result; -} diff --git a/lib/supabase/templates/selectTemplates.ts b/lib/supabase/templates/selectTemplates.ts index 8d0cdc5d8..e077313cd 100644 --- a/lib/supabase/templates/selectTemplates.ts +++ b/lib/supabase/templates/selectTemplates.ts @@ -1,24 +1,14 @@ import type { QueryData } from "@supabase/supabase-js"; import supabase from "@/lib/supabase/serverClient"; +import { RECOUP_ORG_ID } from "@/lib/const"; import type { Tables } from "@/types/database.types"; -import { getAdminAccountIds } from "@/lib/admins/getAdminAccountIds"; -import { selectTemplateFavorites } from "@/lib/supabase/template_favorites/selectTemplateFavorites"; -import { flattenCreator, type TemplateCreator } from "@/lib/supabase/templates/flattenCreator"; -import { resolveSharedEmails } from "@/lib/supabase/templates/resolveSharedEmails"; -export type { TemplateCreator }; - -const SELECT = ` - *, - creator:accounts!agent_templates_creator_fkey ( - id, - name, - account_info ( image ) - ) -` as const; - -const _typedQuery = supabase.from("agent_templates").select(SELECT); -export type RawTemplate = QueryData[number]; +export interface TemplateCreator { + id: string; + name: string | null; + image: string | null; + is_admin: boolean; +} export type Template = Omit, "creator"> & { creator: TemplateCreator | null; @@ -28,17 +18,33 @@ export type Template = Omit, "creator"> & { export type SelectTemplatesParams = { id: string } | { accessibleTo: string }; -const creatorIdOf = (r: RawTemplate): string | null => { - const c = r.creator; - if (!c) return null; - return Array.isArray(c) ? (c[0]?.id ?? null) : c.id; -}; +// Sentinel used for the favorite filter when no caller is passed (validators). +// PostgREST filters the embed by user_id; this UUID matches nothing → +// caller_favorite stays empty and `is_favourite` resolves to `false`. +const NO_CALLER = "00000000-0000-0000-0000-000000000000"; -const throwOn = (label: string, error: { message: string } | null) => { - if (!error) return; - console.error(`Error ${label}:`, error); - throw new Error(`selectTemplates ${label} failed: ${error.message}`); -}; +// Everything the API response needs, in one shot: +// creator.org_membership → presence ⇒ is_admin +// caller_favorite → presence ⇒ is_favourite (filtered to caller) +// template_shares.sharee.account_emails → flatten ⇒ shared_emails +const SELECT = ` + *, + creator:accounts!agent_templates_creator_fkey ( + id, + name, + account_info ( image ), + org_membership:account_organization_ids ( organization_id ) + ), + caller_favorite:agent_template_favorites ( user_id ), + template_shares:agent_template_shares ( + sharee:accounts!agent_template_shares_user_id_fkey ( + account_emails ( email ) + ) + ) +` as const; + +const _typedQuery = supabase.from("agent_templates").select(SELECT); +type RawTemplate = QueryData[number]; /** * Reads agent templates shaped for the API. @@ -46,9 +52,10 @@ const throwOn = (label: string, error: { message: string } | null) => { * - `{ id }` → row with that id, or empty array * - `{ accessibleTo }` → own + public + shared (deduped, sorted by title) * - * Pass `forAccountId` to enrich `is_favourite` and `shared_emails` (the - * latter only on private templates the account owns). Omit it for - * lightweight callers (e.g. ownership validators). + * Everything the response needs — `is_admin`, `is_favourite`, + * `shared_emails` — is embedded in the same query via PostgREST joins. + * The JS step just unwraps the embedded arrays into booleans and dedupes + * emails. * * Throws on database error. */ @@ -56,14 +63,21 @@ export async function selectTemplates( params: SelectTemplatesParams, forAccountId?: string, ): Promise { + const callerId = forAccountId ?? NO_CALLER; + // 1. Fetch let rows: RawTemplate[]; if ("id" in params) { const { data, error } = await supabase .from("agent_templates") .select(SELECT) - .eq("id", params.id); - throwOn("by id", error); + .eq("id", params.id) + .eq("creator.org_membership.organization_id", RECOUP_ORG_ID) + .eq("caller_favorite.user_id", callerId); + if (error) { + console.error("Error selecting template by id:", error); + throw new Error(`selectTemplates(id) failed: ${error.message}`); + } rows = data ?? []; } else { const accountId = params.accessibleTo; @@ -72,15 +86,24 @@ export async function selectTemplates( .from("agent_templates") .select(SELECT) .or(`creator.eq.${accountId},is_private.eq.false`) + .eq("creator.org_membership.organization_id", RECOUP_ORG_ID) + .eq("caller_favorite.user_id", callerId) .order("title"), supabase .from("agent_template_shares") .select(`template:agent_templates!agent_template_shares_template_id_fkey (${SELECT})`) - .eq("user_id", accountId), + .eq("user_id", accountId) + .eq("template.creator.org_membership.organization_id", RECOUP_ORG_ID) + .eq("template.caller_favorite.user_id", callerId), ]); - throwOn("owned/public", owned.error); - throwOn("shared", shared.error); - + if (owned.error) { + console.error("Error selecting owned/public templates:", owned.error); + throw new Error(`selectTemplates(accessibleTo) owned/public failed: ${owned.error.message}`); + } + if (shared.error) { + console.error("Error selecting shared templates:", shared.error); + throw new Error(`selectTemplates(accessibleTo) shared failed: ${shared.error.message}`); + } const sharedRows = (shared.data ?? []).flatMap(s => { const t = (s as { template: RawTemplate | RawTemplate[] | null }).template; return t ? (Array.isArray(t) ? t : [t]) : []; @@ -92,30 +115,40 @@ export async function selectTemplates( } if (rows.length === 0) return []; - // 2. Caller-/admin-dependent fetches in parallel - const creatorIds = Array.from( - new Set(rows.map(creatorIdOf).filter((id): id is string => id !== null)), - ); - const ownedPrivateIds = forAccountId - ? rows.filter(r => r.is_private && creatorIdOf(r) === forAccountId).map(r => r.id) - : []; - const [adminIds, favorites, sharedEmails] = await Promise.all([ - getAdminAccountIds(creatorIds), - forAccountId ? selectTemplateFavorites(forAccountId) : Promise.resolve([]), - forAccountId ? resolveSharedEmails(ownedPrivateIds) : Promise.resolve({}), - ]); - const favoriteIds = new Set(favorites.map(f => f.template_id)); - - // 3. Shape + // 2. Shape — unwrap embedded arrays into booleans + emails return rows .map(row => { - const creator = flattenCreator(row.creator, adminIds); - const isOwnedPrivate = !!forAccountId && row.is_private && creator?.id === forAccountId; + const creatorRow = Array.isArray(row.creator) ? row.creator[0] : row.creator; + const creator: TemplateCreator | null = creatorRow + ? { + id: creatorRow.id, + name: creatorRow.name ?? null, + image: creatorRow.account_info?.[0]?.image ?? null, + is_admin: (creatorRow.org_membership ?? []).length > 0, + } + : null; + const isOwnedPrivate = + !!forAccountId && row.is_private && creator?.id === forAccountId; + const sharedEmails = isOwnedPrivate + ? Array.from( + new Set( + (row.template_shares ?? []).flatMap(s => { + const sharees = Array.isArray(s.sharee) ? s.sharee : s.sharee ? [s.sharee] : []; + return sharees.flatMap(sh => + (sh.account_emails ?? []) + .map(ae => ae.email) + .filter((e): e is string => typeof e === "string"), + ); + }), + ), + ) + : []; + const { creator: _c, caller_favorite, template_shares: _ts, ...rest } = row; return { - ...row, + ...rest, creator, - is_favourite: favoriteIds.has(row.id), - shared_emails: isOwnedPrivate ? (sharedEmails[row.id] ?? []) : [], + is_favourite: (caller_favorite ?? []).length > 0, + shared_emails: sharedEmails, }; }) .sort((a, b) => a.title.localeCompare(b.title)); From f885ac790be9ceccdcc393981cc7da2e1533f373 Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Tue, 12 May 2026 23:14:44 +0530 Subject: [PATCH 21/32] debug(templates): expose postgrest error via ?debug=1 to find embed issue --- lib/supabase/templates/selectTemplates.ts | 12 +++++++++--- lib/templates/listTemplatesHandler.ts | 16 +++++++++++++++- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/lib/supabase/templates/selectTemplates.ts b/lib/supabase/templates/selectTemplates.ts index e077313cd..65cd83c55 100644 --- a/lib/supabase/templates/selectTemplates.ts +++ b/lib/supabase/templates/selectTemplates.ts @@ -76,7 +76,9 @@ export async function selectTemplates( .eq("caller_favorite.user_id", callerId); if (error) { console.error("Error selecting template by id:", error); - throw new Error(`selectTemplates(id) failed: ${error.message}`); + throw new Error( + `selectTemplates(id) failed: ${error.message} | code=${error.code} | details=${error.details} | hint=${error.hint}`, + ); } rows = data ?? []; } else { @@ -98,11 +100,15 @@ export async function selectTemplates( ]); if (owned.error) { console.error("Error selecting owned/public templates:", owned.error); - throw new Error(`selectTemplates(accessibleTo) owned/public failed: ${owned.error.message}`); + throw new Error( + `selectTemplates(accessibleTo) owned/public failed: ${owned.error.message} | code=${owned.error.code} | details=${owned.error.details} | hint=${owned.error.hint}`, + ); } if (shared.error) { console.error("Error selecting shared templates:", shared.error); - throw new Error(`selectTemplates(accessibleTo) shared failed: ${shared.error.message}`); + throw new Error( + `selectTemplates(accessibleTo) shared failed: ${shared.error.message} | code=${shared.error.code} | details=${shared.error.details} | hint=${shared.error.hint}`, + ); } const sharedRows = (shared.data ?? []).flatMap(s => { const t = (s as { template: RawTemplate | RawTemplate[] | null }).template; diff --git a/lib/templates/listTemplatesHandler.ts b/lib/templates/listTemplatesHandler.ts index 87e7a5519..8aaf566dd 100644 --- a/lib/templates/listTemplatesHandler.ts +++ b/lib/templates/listTemplatesHandler.ts @@ -25,8 +25,22 @@ export async function listTemplatesHandler(request: NextRequest): Promise Date: Tue, 12 May 2026 23:16:39 +0530 Subject: [PATCH 22/32] debug: hint FK on org_membership embed --- lib/supabase/templates/selectTemplates.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/supabase/templates/selectTemplates.ts b/lib/supabase/templates/selectTemplates.ts index 65cd83c55..d868ead5b 100644 --- a/lib/supabase/templates/selectTemplates.ts +++ b/lib/supabase/templates/selectTemplates.ts @@ -33,7 +33,7 @@ const SELECT = ` id, name, account_info ( image ), - org_membership:account_organization_ids ( organization_id ) + org_membership:account_organization_ids!account_organization_ids_account_id_fkey ( organization_id ) ), caller_favorite:agent_template_favorites ( user_id ), template_shares:agent_template_shares ( From 453a887eccdd57b4e240e3ef2e8f06c469ee3df6 Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Tue, 12 May 2026 23:19:39 +0530 Subject: [PATCH 23/32] debug: drop org_membership filter temporarily --- lib/supabase/templates/selectTemplates.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/supabase/templates/selectTemplates.ts b/lib/supabase/templates/selectTemplates.ts index d868ead5b..cad5c786b 100644 --- a/lib/supabase/templates/selectTemplates.ts +++ b/lib/supabase/templates/selectTemplates.ts @@ -88,7 +88,7 @@ export async function selectTemplates( .from("agent_templates") .select(SELECT) .or(`creator.eq.${accountId},is_private.eq.false`) - .eq("creator.org_membership.organization_id", RECOUP_ORG_ID) + // DEBUG: dropped .eq("creator.org_membership.organization_id", ...) temporarily .eq("caller_favorite.user_id", callerId) .order("title"), supabase From a398caaad85e4a8ea288150211c938fe7c3fedca Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Tue, 12 May 2026 23:21:53 +0530 Subject: [PATCH 24/32] debug: try table-name filter path --- lib/supabase/templates/selectTemplates.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/supabase/templates/selectTemplates.ts b/lib/supabase/templates/selectTemplates.ts index cad5c786b..bdc6d3152 100644 --- a/lib/supabase/templates/selectTemplates.ts +++ b/lib/supabase/templates/selectTemplates.ts @@ -88,7 +88,7 @@ export async function selectTemplates( .from("agent_templates") .select(SELECT) .or(`creator.eq.${accountId},is_private.eq.false`) - // DEBUG: dropped .eq("creator.org_membership.organization_id", ...) temporarily + .eq("accounts.account_organization_ids.organization_id", RECOUP_ORG_ID) .eq("caller_favorite.user_id", callerId) .order("title"), supabase From 336209dd3ab976e0eb8f86efe04cea33bda1ce0d Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Tue, 12 May 2026 23:24:39 +0530 Subject: [PATCH 25/32] debug: filter org membership in JS not query --- lib/supabase/templates/selectTemplates.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/lib/supabase/templates/selectTemplates.ts b/lib/supabase/templates/selectTemplates.ts index bdc6d3152..cf4cffbd3 100644 --- a/lib/supabase/templates/selectTemplates.ts +++ b/lib/supabase/templates/selectTemplates.ts @@ -72,7 +72,6 @@ export async function selectTemplates( .from("agent_templates") .select(SELECT) .eq("id", params.id) - .eq("creator.org_membership.organization_id", RECOUP_ORG_ID) .eq("caller_favorite.user_id", callerId); if (error) { console.error("Error selecting template by id:", error); @@ -88,14 +87,12 @@ export async function selectTemplates( .from("agent_templates") .select(SELECT) .or(`creator.eq.${accountId},is_private.eq.false`) - .eq("accounts.account_organization_ids.organization_id", RECOUP_ORG_ID) .eq("caller_favorite.user_id", callerId) .order("title"), supabase .from("agent_template_shares") .select(`template:agent_templates!agent_template_shares_template_id_fkey (${SELECT})`) .eq("user_id", accountId) - .eq("template.creator.org_membership.organization_id", RECOUP_ORG_ID) .eq("template.caller_favorite.user_id", callerId), ]); if (owned.error) { @@ -130,7 +127,9 @@ export async function selectTemplates( id: creatorRow.id, name: creatorRow.name ?? null, image: creatorRow.account_info?.[0]?.image ?? null, - is_admin: (creatorRow.org_membership ?? []).length > 0, + is_admin: (creatorRow.org_membership ?? []).some( + m => m.organization_id === RECOUP_ORG_ID, + ), } : null; const isOwnedPrivate = From 68cfe3e30fe91607427b2b17fc8a3b6ae4114eac Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Tue, 12 May 2026 23:26:39 +0530 Subject: [PATCH 26/32] debug: probe sidney's org memberships --- lib/templates/listTemplatesHandler.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/lib/templates/listTemplatesHandler.ts b/lib/templates/listTemplatesHandler.ts index 8aaf566dd..d15ddcad3 100644 --- a/lib/templates/listTemplatesHandler.ts +++ b/lib/templates/listTemplatesHandler.ts @@ -19,6 +19,21 @@ export async function listTemplatesHandler(request: NextRequest): Promise Date: Tue, 12 May 2026 23:29:20 +0530 Subject: [PATCH 27/32] debug: triangulate sidney's recoup membership --- lib/templates/listTemplatesHandler.ts | 28 ++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/lib/templates/listTemplatesHandler.ts b/lib/templates/listTemplatesHandler.ts index d15ddcad3..a56cce612 100644 --- a/lib/templates/listTemplatesHandler.ts +++ b/lib/templates/listTemplatesHandler.ts @@ -22,14 +22,28 @@ export async function listTemplatesHandler(request: NextRequest): Promise Date: Tue, 12 May 2026 23:32:48 +0530 Subject: [PATCH 28/32] refactor(templates): embed all side data in one SELECT, drop helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces 4 round trips (fetch + admin lookup + favorites + shared emails) with 1 (or 2 for accessibleTo) via PostgREST embeds: creator.org_membership:account_organization_ids (filtered to RECOUP_ORG_ID in JS) → is_admin caller_favorite:agent_template_favorites (filtered to caller at query level) → is_favourite template_shares.sharee.account_emails → shared_emails Five JS helpers deleted; selectTemplates is now one function. Debug notes: - account_organization_ids has two FKs to accounts (account_id and organization_id), so the embed needs the account_organization_ids_account_id_fkey hint - supabase-js's filter path doesn't reliably traverse double-nested embeds (creator.org_membership.organization_id silently filters to nothing), so the admin check is one JS .some() instead Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/supabase/templates/selectTemplates.ts | 38 +++++++++---------- lib/templates/listTemplatesHandler.ts | 45 +---------------------- 2 files changed, 18 insertions(+), 65 deletions(-) diff --git a/lib/supabase/templates/selectTemplates.ts b/lib/supabase/templates/selectTemplates.ts index cf4cffbd3..ca29af781 100644 --- a/lib/supabase/templates/selectTemplates.ts +++ b/lib/supabase/templates/selectTemplates.ts @@ -18,15 +18,19 @@ export type Template = Omit, "creator"> & { export type SelectTemplatesParams = { id: string } | { accessibleTo: string }; -// Sentinel used for the favorite filter when no caller is passed (validators). -// PostgREST filters the embed by user_id; this UUID matches nothing → -// caller_favorite stays empty and `is_favourite` resolves to `false`. +// Sentinel for the favorite-embed filter when no caller is passed (validators). +// This UUID never matches a real user_id, so `caller_favorite` stays empty and +// `is_favourite` resolves to `false`. const NO_CALLER = "00000000-0000-0000-0000-000000000000"; -// Everything the API response needs, in one shot: -// creator.org_membership → presence ⇒ is_admin -// caller_favorite → presence ⇒ is_favourite (filtered to caller) +// Single SELECT for everything the API response needs: +// creator.org_membership → presence of RECOUP_ORG_ID ⇒ is_admin +// caller_favorite → presence ⇒ is_favourite (filtered to caller at query level) // template_shares.sharee.account_emails → flatten ⇒ shared_emails +// +// The org_membership embed isn't filtered at the query level because +// supabase-js's filter path doesn't reliably support double-nested +// embeds; we keep the filter in JS via `.some()` instead. const SELECT = ` *, creator:accounts!agent_templates_creator_fkey ( @@ -52,10 +56,9 @@ type RawTemplate = QueryData[number]; * - `{ id }` → row with that id, or empty array * - `{ accessibleTo }` → own + public + shared (deduped, sorted by title) * - * Everything the response needs — `is_admin`, `is_favourite`, - * `shared_emails` — is embedded in the same query via PostgREST joins. - * The JS step just unwraps the embedded arrays into booleans and dedupes - * emails. + * Everything the response needs — is_admin, is_favourite, shared_emails — + * comes from the embedded SELECT. JS only unwraps embedded arrays into + * booleans and dedupes emails. * * Throws on database error. */ @@ -75,9 +78,7 @@ export async function selectTemplates( .eq("caller_favorite.user_id", callerId); if (error) { console.error("Error selecting template by id:", error); - throw new Error( - `selectTemplates(id) failed: ${error.message} | code=${error.code} | details=${error.details} | hint=${error.hint}`, - ); + throw new Error(`selectTemplates(id) failed: ${error.message}`); } rows = data ?? []; } else { @@ -97,15 +98,11 @@ export async function selectTemplates( ]); if (owned.error) { console.error("Error selecting owned/public templates:", owned.error); - throw new Error( - `selectTemplates(accessibleTo) owned/public failed: ${owned.error.message} | code=${owned.error.code} | details=${owned.error.details} | hint=${owned.error.hint}`, - ); + throw new Error(`selectTemplates(accessibleTo) owned/public failed: ${owned.error.message}`); } if (shared.error) { console.error("Error selecting shared templates:", shared.error); - throw new Error( - `selectTemplates(accessibleTo) shared failed: ${shared.error.message} | code=${shared.error.code} | details=${shared.error.details} | hint=${shared.error.hint}`, - ); + throw new Error(`selectTemplates(accessibleTo) shared failed: ${shared.error.message}`); } const sharedRows = (shared.data ?? []).flatMap(s => { const t = (s as { template: RawTemplate | RawTemplate[] | null }).template; @@ -132,8 +129,7 @@ export async function selectTemplates( ), } : null; - const isOwnedPrivate = - !!forAccountId && row.is_private && creator?.id === forAccountId; + const isOwnedPrivate = !!forAccountId && row.is_private && creator?.id === forAccountId; const sharedEmails = isOwnedPrivate ? Array.from( new Set( diff --git a/lib/templates/listTemplatesHandler.ts b/lib/templates/listTemplatesHandler.ts index a56cce612..87e7a5519 100644 --- a/lib/templates/listTemplatesHandler.ts +++ b/lib/templates/listTemplatesHandler.ts @@ -19,57 +19,14 @@ export async function listTemplatesHandler(request: NextRequest): Promise Date: Tue, 12 May 2026 23:36:59 +0530 Subject: [PATCH 29/32] test: filter org_membership by alias-only prefix --- lib/supabase/templates/selectTemplates.ts | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/lib/supabase/templates/selectTemplates.ts b/lib/supabase/templates/selectTemplates.ts index ca29af781..f7de68acb 100644 --- a/lib/supabase/templates/selectTemplates.ts +++ b/lib/supabase/templates/selectTemplates.ts @@ -24,13 +24,9 @@ export type SelectTemplatesParams = { id: string } | { accessibleTo: string }; const NO_CALLER = "00000000-0000-0000-0000-000000000000"; // Single SELECT for everything the API response needs: -// creator.org_membership → presence of RECOUP_ORG_ID ⇒ is_admin -// caller_favorite → presence ⇒ is_favourite (filtered to caller at query level) +// creator.org_membership → presence ⇒ is_admin (embed filtered to RECOUP_ORG_ID) +// caller_favorite → presence ⇒ is_favourite (embed filtered to caller) // template_shares.sharee.account_emails → flatten ⇒ shared_emails -// -// The org_membership embed isn't filtered at the query level because -// supabase-js's filter path doesn't reliably support double-nested -// embeds; we keep the filter in JS via `.some()` instead. const SELECT = ` *, creator:accounts!agent_templates_creator_fkey ( @@ -75,7 +71,8 @@ export async function selectTemplates( .from("agent_templates") .select(SELECT) .eq("id", params.id) - .eq("caller_favorite.user_id", callerId); + .eq("caller_favorite.user_id", callerId) + .eq("org_membership.organization_id", RECOUP_ORG_ID); if (error) { console.error("Error selecting template by id:", error); throw new Error(`selectTemplates(id) failed: ${error.message}`); @@ -89,12 +86,14 @@ export async function selectTemplates( .select(SELECT) .or(`creator.eq.${accountId},is_private.eq.false`) .eq("caller_favorite.user_id", callerId) + .eq("org_membership.organization_id", RECOUP_ORG_ID) .order("title"), supabase .from("agent_template_shares") .select(`template:agent_templates!agent_template_shares_template_id_fkey (${SELECT})`) .eq("user_id", accountId) - .eq("template.caller_favorite.user_id", callerId), + .eq("caller_favorite.user_id", callerId) + .eq("org_membership.organization_id", RECOUP_ORG_ID), ]); if (owned.error) { console.error("Error selecting owned/public templates:", owned.error); @@ -124,9 +123,7 @@ export async function selectTemplates( id: creatorRow.id, name: creatorRow.name ?? null, image: creatorRow.account_info?.[0]?.image ?? null, - is_admin: (creatorRow.org_membership ?? []).some( - m => m.organization_id === RECOUP_ORG_ID, - ), + is_admin: (creatorRow.org_membership ?? []).length > 0, } : null; const isOwnedPrivate = !!forAccountId && row.is_private && creator?.id === forAccountId; From f7cef4672e47a230bbdda37de8d0aea3ce798699 Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Tue, 12 May 2026 23:39:14 +0530 Subject: [PATCH 30/32] debug: re-add error echo --- lib/templates/listTemplatesHandler.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/templates/listTemplatesHandler.ts b/lib/templates/listTemplatesHandler.ts index 87e7a5519..8af75f2aa 100644 --- a/lib/templates/listTemplatesHandler.ts +++ b/lib/templates/listTemplatesHandler.ts @@ -25,8 +25,13 @@ export async function listTemplatesHandler(request: NextRequest): Promise Date: Tue, 12 May 2026 23:42:04 +0530 Subject: [PATCH 31/32] test: full alias-chain filter paths --- lib/supabase/templates/selectTemplates.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/supabase/templates/selectTemplates.ts b/lib/supabase/templates/selectTemplates.ts index f7de68acb..e53a7dbde 100644 --- a/lib/supabase/templates/selectTemplates.ts +++ b/lib/supabase/templates/selectTemplates.ts @@ -72,7 +72,7 @@ export async function selectTemplates( .select(SELECT) .eq("id", params.id) .eq("caller_favorite.user_id", callerId) - .eq("org_membership.organization_id", RECOUP_ORG_ID); + .eq("creator.org_membership.organization_id", RECOUP_ORG_ID); if (error) { console.error("Error selecting template by id:", error); throw new Error(`selectTemplates(id) failed: ${error.message}`); @@ -86,14 +86,14 @@ export async function selectTemplates( .select(SELECT) .or(`creator.eq.${accountId},is_private.eq.false`) .eq("caller_favorite.user_id", callerId) - .eq("org_membership.organization_id", RECOUP_ORG_ID) + .eq("creator.org_membership.organization_id", RECOUP_ORG_ID) .order("title"), supabase .from("agent_template_shares") .select(`template:agent_templates!agent_template_shares_template_id_fkey (${SELECT})`) .eq("user_id", accountId) - .eq("caller_favorite.user_id", callerId) - .eq("org_membership.organization_id", RECOUP_ORG_ID), + .eq("template.caller_favorite.user_id", callerId) + .eq("template.creator.org_membership.organization_id", RECOUP_ORG_ID), ]); if (owned.error) { console.error("Error selecting owned/public templates:", owned.error); From 84fa1b9f1acdf6350726605983f33509bef685dc Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Tue, 12 May 2026 23:44:35 +0530 Subject: [PATCH 32/32] refactor(templates): all filters at the DB level, no JS field filtering PostgREST handles every filter at the query level via the chained-alias filter path: creator.org_membership.organization_id = RECOUP_ORG_ID caller_favorite.user_id = forAccountId template.creator.org_membership.org_id = RECOUP_ORG_ID (shared branch) template.caller_favorite.user_id = forAccountId (shared branch) JS only unwraps embedded arrays into booleans + extracts the already- filtered email strings. No JS `.some(...)` predicates on field values anymore. Earlier diagnostic confusion: I expected Sidney Swift to come back is_admin=true (carried over from the old ADMIN_EMAILS world). The new org-membership definition correctly reports is_admin=false because sidney's account isn't a member of RECOUP_ORG_ID in account_organization_ids. The embedded filter has been working since the FK hint went in (account_organization_ids_account_id_fkey). Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/templates/listTemplatesHandler.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/lib/templates/listTemplatesHandler.ts b/lib/templates/listTemplatesHandler.ts index 8af75f2aa..87e7a5519 100644 --- a/lib/templates/listTemplatesHandler.ts +++ b/lib/templates/listTemplatesHandler.ts @@ -25,13 +25,8 @@ export async function listTemplatesHandler(request: NextRequest): Promise