diff --git a/app/api/agents/templates/[id]/favorite/route.ts b/app/api/agents/templates/[id]/favorite/route.ts new file mode 100644 index 000000000..e663a929a --- /dev/null +++ b/app/api/agents/templates/[id]/favorite/route.ts @@ -0,0 +1,37 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { toggleTemplateFavoriteHandler } from "@/lib/templates/toggleTemplateFavoriteHandler"; + +/** + * 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/agents/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 toggleTemplateFavoriteHandler(request, context.params); +} + +export const dynamic = "force-dynamic"; +export const fetchCache = "force-no-store"; +export const revalidate = 0; diff --git a/app/api/agents/templates/[id]/route.ts b/app/api/agents/templates/[id]/route.ts new file mode 100644 index 000000000..a170de32b --- /dev/null +++ b/app/api/agents/templates/[id]/route.ts @@ -0,0 +1,55 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { updateTemplateHandler } from "@/lib/templates/updateTemplateHandler"; +import { deleteTemplateHandler } from "@/lib/templates/deleteTemplateHandler"; + +/** + * 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/agents/templates/{id} + * + * 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. + * @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 updateTemplateHandler(request, context.params); +} + +/** + * DELETE /api/agents/templates/{id} + * + * Permanently removes an 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 deleteTemplateHandler(request, context.params); +} + +export const dynamic = "force-dynamic"; +export const fetchCache = "force-no-store"; +export const revalidate = 0; diff --git a/app/api/agents/templates/route.ts b/app/api/agents/templates/route.ts new file mode 100644 index 000000000..b8d6e4fec --- /dev/null +++ b/app/api/agents/templates/route.ts @@ -0,0 +1,48 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { listTemplatesHandler } from "@/lib/templates/listTemplatesHandler"; +import { createTemplateHandler } from "@/lib/templates/createTemplateHandler"; + +/** + * 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/agents/templates + * + * 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. + * + * @param request - Incoming request; auth is read from headers. + * @returns A 200 NextResponse with `{ status, templates }`. + */ +export async function GET(request: NextRequest): Promise { + return listTemplatesHandler(request); +} + +/** + * 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 + * 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 createTemplateHandler(request); +} + +export const dynamic = "force-dynamic"; +export const fetchCache = "force-no-store"; +export const revalidate = 0; diff --git a/lib/supabase/template_favorites/deleteTemplateFavorite.ts b/lib/supabase/template_favorites/deleteTemplateFavorite.ts new file mode 100644 index 000000000..ef592df01 --- /dev/null +++ b/lib/supabase/template_favorites/deleteTemplateFavorite.ts @@ -0,0 +1,26 @@ +import supabase from "@/lib/supabase/serverClient"; + +/** + * Removes the favorite row for `(template_id, account)`. Idempotent. + * + * @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 deleteTemplateFavorite( + templateId: string, + accountId: string, +): Promise { + const { error } = await supabase + .from("agent_template_favorites") + .delete() + .eq("template_id", templateId) + .eq("user_id", accountId); + + if (error) { + console.error("Error deleting template_favorite:", error); + return false; + } + + return true; +} diff --git a/lib/supabase/template_favorites/insertTemplateFavorite.ts b/lib/supabase/template_favorites/insertTemplateFavorite.ts new file mode 100644 index 000000000..75d23942c --- /dev/null +++ b/lib/supabase/template_favorites/insertTemplateFavorite.ts @@ -0,0 +1,27 @@ +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 template UUID + * @param accountId - The favoriting account UUID + * @returns True if the favorite exists after the call, false on unexpected error. + */ +export async function insertTemplateFavorite( + templateId: string, + accountId: string, +): Promise { + const { error } = await supabase + .from("agent_template_favorites") + .insert({ template_id: templateId, user_id: accountId }) + .select("template_id") + .maybeSingle(); + + if (error && error.code !== "23505") { + console.error("Error inserting template_favorite:", error); + return false; + } + + return true; +} diff --git a/lib/supabase/template_shares/deleteTemplateShares.ts b/lib/supabase/template_shares/deleteTemplateShares.ts new file mode 100644 index 000000000..ee373eff9 --- /dev/null +++ b/lib/supabase/template_shares/deleteTemplateShares.ts @@ -0,0 +1,22 @@ +import supabase from "@/lib/supabase/serverClient"; + +/** + * 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 template UUID + * @throws If the Supabase delete fails. + */ +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 template_shares:", error); + throw new Error(`deleteTemplateShares failed: ${error.message}`); + } +} diff --git a/lib/supabase/template_shares/insertTemplateShares.ts b/lib/supabase/template_shares/insertTemplateShares.ts new file mode 100644 index 000000000..df01f1916 --- /dev/null +++ b/lib/supabase/template_shares/insertTemplateShares.ts @@ -0,0 +1,40 @@ +import supabase from "@/lib/supabase/serverClient"; +import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmails"; + +/** + * Resolves the supplied emails to account ids and upserts an + * `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 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 insertTemplateShares(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 template_shares:", error); + throw new Error(`insertTemplateShares failed: ${error.message}`); + } + + return data?.length ?? 0; +} 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/templates/deleteTemplate.ts b/lib/supabase/templates/deleteTemplate.ts new file mode 100644 index 000000000..9dbc5c834 --- /dev/null +++ b/lib/supabase/templates/deleteTemplate.ts @@ -0,0 +1,19 @@ +import supabase from "@/lib/supabase/serverClient"; + +/** + * Deletes an template row by id. Cascades remove dependent shares / + * favorites at the database level. + * + * @param id - The template UUID + * @returns True if the delete succeeded, false otherwise. + */ +export async function deleteTemplate(id: string): Promise { + const { error } = await supabase.from("agent_templates").delete().eq("id", id); + + if (error) { + console.error("Error deleting template:", error); + return false; + } + + return true; +} diff --git a/lib/supabase/templates/insertTemplate.ts b/lib/supabase/templates/insertTemplate.ts new file mode 100644 index 000000000..49e51f634 --- /dev/null +++ b/lib/supabase/templates/insertTemplate.ts @@ -0,0 +1,21 @@ +import supabase from "@/lib/supabase/serverClient"; +import type { Tables, TablesInsert } from "@/types/database.types"; + +/** + * Inserts a new template row. + * + * @param row - The template insert payload (must include creator). + * @returns The newly created templates row, or null on error. + */ +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 template:", error); + return null; + } + + return data; +} diff --git a/lib/supabase/templates/selectTemplates.ts b/lib/supabase/templates/selectTemplates.ts new file mode 100644 index 000000000..e53a7dbde --- /dev/null +++ b/lib/supabase/templates/selectTemplates.ts @@ -0,0 +1,153 @@ +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"; + +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 SelectTemplatesParams = { id: string } | { accessibleTo: string }; + +// 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"; + +// Single SELECT for everything the API response needs: +// 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 +const SELECT = ` + *, + creator:accounts!agent_templates_creator_fkey ( + id, + name, + account_info ( image ), + 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 ( + 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. + * + * - `{ 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 — + * comes from the embedded SELECT. JS only unwraps embedded arrays into + * booleans and dedupes emails. + * + * Throws on database error. + */ +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("caller_favorite.user_id", callerId) + .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}`); + } + rows = data ?? []; + } else { + const accountId = params.accessibleTo; + const [owned, shared] = await Promise.all([ + supabase + .from("agent_templates") + .select(SELECT) + .or(`creator.eq.${accountId},is_private.eq.false`) + .eq("caller_favorite.user_id", callerId) + .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("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); + 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]) : []; + }); + 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 []; + + // 2. Shape — unwrap embedded arrays into booleans + emails + 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; + return { + ...rest, + creator, + is_favourite: (caller_favorite ?? []).length > 0, + shared_emails: sharedEmails, + }; + }) + .sort((a, b) => a.title.localeCompare(b.title)); +} diff --git a/lib/supabase/templates/updateTemplate.ts b/lib/supabase/templates/updateTemplate.ts new file mode 100644 index 000000000..bc16e070a --- /dev/null +++ b/lib/supabase/templates/updateTemplate.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 template row, refreshing `updated_at`. + * + * @param id - The template UUID + * @param updates - Partial column updates + * @returns The updated row, or null on error. + */ +export async function updateTemplate( + 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 template:", error); + return null; + } + + return data; +} diff --git a/lib/templates/__tests__/createTemplateHandler.test.ts b/lib/templates/__tests__/createTemplateHandler.test.ts new file mode 100644 index 000000000..07cab1434 --- /dev/null +++ b/lib/templates/__tests__/createTemplateHandler.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/validateCreateTemplateRequest", () => ({ + validateCreateTemplateRequest: vi.fn(), +})); + +vi.mock("@/lib/supabase/templates/insertTemplate", () => ({ + insertTemplate: vi.fn(), +})); + +vi.mock("@/lib/supabase/template_shares/insertTemplateShares", () => ({ + insertTemplateShares: vi.fn(), +})); + +vi.mock("@/lib/supabase/templates/selectTemplates", () => ({ + selectTemplates: vi.fn(), +})); + +const { createTemplateHandler } = await import("../createTemplateHandler"); +const { validateCreateTemplateRequest } = await import( + "@/lib/templates/validateCreateTemplateRequest" +); +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"; + +const baseBody = { + title: "Valid title", + description: "valid description", + prompt: "Valid prompt content for template", + tags: [], + is_private: false, + 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 () => { + 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()); + + expect(res.status).toBe(201); + expect((await res.json()).template.id).toBe(TEMPLATE_ID); + expect(insertTemplate).toHaveBeenCalledWith( + expect.objectContaining({ creator: ACCOUNT_ID, is_private: true }), + ); + expect(insertTemplateShares).toHaveBeenCalledWith(TEMPLATE_ID, ["a@x.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: "title must be at least 3 characters" }, + { status: 400 }, + ); + vi.mocked(validateCreateTemplateRequest).mockResolvedValue(failure); + + const res = await createTemplateHandler(makeRequest()); + expect(res).toBe(failure); + expect(insertTemplate).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/templates/__tests__/deleteTemplateHandler.test.ts b/lib/templates/__tests__/deleteTemplateHandler.test.ts new file mode 100644 index 000000000..e078fbb3e --- /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/agents/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/agents/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/templates/__tests__/listTemplatesHandler.test.ts b/lib/templates/__tests__/listTemplatesHandler.test.ts new file mode 100644 index 000000000..b096f6fd9 --- /dev/null +++ b/lib/templates/__tests__/listTemplatesHandler.test.ts @@ -0,0 +1,55 @@ +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/templates/selectTemplates", () => ({ + selectTemplates: vi.fn(), +})); + +const { listTemplatesHandler } = await import("../listTemplatesHandler"); +const { validateAuthContext } = await import("@/lib/auth/validateAuthContext"); +const { selectTemplates } = await import("@/lib/supabase/templates/selectTemplates"); + +const ACCOUNT_ID = "11111111-1111-1111-1111-111111111111"; + +describe("listTemplatesHandler", () => { + beforeEach(() => vi.clearAllMocks()); + + it("returns templates fetched via selectTemplates for the authenticated account", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: ACCOUNT_ID, + orgId: null, + authToken: "k", + }); + vi.mocked(selectTemplates).mockResolvedValue([ + { id: "t1", is_favourite: true, shared_emails: [] } as never, + ]); + + const res = await listTemplatesHandler( + new NextRequest("http://localhost/api/agents/templates", { headers: { "x-api-key": "k" } }), + ); + + expect(res.status).toBe(200); + expect((await res.json()).templates).toHaveLength(1); + 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 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 new file mode 100644 index 000000000..d26a5036b --- /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/agents/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/agents/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/agents/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..15d0039d6 --- /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/agents/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/agents/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/templates/createTemplateHandler.ts b/lib/templates/createTemplateHandler.ts new file mode 100644 index 000000000..32b2dcd63 --- /dev/null +++ b/lib/templates/createTemplateHandler.ts @@ -0,0 +1,55 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +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"; + +/** + * Handler for POST /api/agents/templates. + * + * 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 validated = await validateCreateTemplateRequest(request); + if (validated instanceof NextResponse) return validated; + + const { accountId, body } = validated; + + const inserted = await insertTemplate({ + title: body.title, + description: body.description, + prompt: body.prompt, + tags: body.tags, + is_private: body.is_private, + creator: accountId, + }); + + if (!inserted) { + return NextResponse.json( + { status: "error", error: "Failed to create template" }, + { status: 500, headers: getCorsHeaders() }, + ); + } + + if (body.is_private && body.share_emails.length > 0) { + await insertTemplateShares(inserted.id, body.share_emails); + } + + 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] createTemplateHandler:", error); + return NextResponse.json( + { status: "error", error: "Internal server error" }, + { status: 500, headers: getCorsHeaders() }, + ); + } +} diff --git a/lib/templates/deleteTemplateHandler.ts b/lib/templates/deleteTemplateHandler.ts new file mode 100644 index 000000000..b12acda0d --- /dev/null +++ b/lib/templates/deleteTemplateHandler.ts @@ -0,0 +1,41 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateDeleteTemplateRequest } from "@/lib/templates/validateDeleteTemplateRequest"; +import { deleteTemplate } from "@/lib/supabase/templates/deleteTemplate"; + +/** + * Handler for DELETE /api/agents/templates/{id}. + * + * 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 deleteTemplateHandler( + request: NextRequest, + params: Promise<{ id: string }>, +): Promise { + try { + const { id } = await params; + const validated = await validateDeleteTemplateRequest(request, id); + if (validated instanceof NextResponse) return validated; + + const ok = await deleteTemplate(validated.templateId); + if (!ok) { + return NextResponse.json( + { 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] deleteTemplateHandler:", error); + return NextResponse.json( + { status: "error", error: "Internal server error" }, + { status: 500, headers: getCorsHeaders() }, + ); + } +} diff --git a/lib/templates/listTemplatesHandler.ts b/lib/templates/listTemplatesHandler.ts new file mode 100644 index 000000000..87e7a5519 --- /dev/null +++ b/lib/templates/listTemplatesHandler.ts @@ -0,0 +1,33 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { selectTemplates } from "@/lib/supabase/templates/selectTemplates"; + +/** + * Handler for GET /api/agents/templates. + * + * 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 listTemplatesHandler(request: NextRequest): Promise { + try { + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) return authResult; + + const accountId = authResult.accountId; + const templates = await selectTemplates({ accessibleTo: accountId }, accountId); + + return NextResponse.json( + { status: "success", templates }, + { status: 200, headers: getCorsHeaders() }, + ); + } catch (error) { + console.error("[ERROR] listTemplatesHandler:", error); + return NextResponse.json( + { status: "error", error: "Internal server error" }, + { status: 500, headers: getCorsHeaders() }, + ); + } +} diff --git a/lib/templates/toggleTemplateFavoriteHandler.ts b/lib/templates/toggleTemplateFavoriteHandler.ts new file mode 100644 index 000000000..c37076e34 --- /dev/null +++ b/lib/templates/toggleTemplateFavoriteHandler.ts @@ -0,0 +1,48 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +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/agents/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 toggleTemplateFavoriteHandler( + 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 insertTemplateFavorite(templateId, accountId) + : await deleteTemplateFavorite(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] toggleTemplateFavoriteHandler:", error); + return NextResponse.json( + { status: "error", error: "Internal server error" }, + { status: 500, headers: getCorsHeaders() }, + ); + } +} diff --git a/lib/templates/updateTemplateHandler.ts b/lib/templates/updateTemplateHandler.ts new file mode 100644 index 000000000..8599b61c5 --- /dev/null +++ b/lib/templates/updateTemplateHandler.ts @@ -0,0 +1,67 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +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/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. + */ +export async function updateTemplateHandler( + request: NextRequest, + params: Promise<{ id: string }>, +): Promise { + try { + const { id } = await params; + const validated = await validateUpdateTemplateRequest(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 updateTemplate(templateId, updates); + if (!updated) { + return NextResponse.json( + { status: "error", error: "Failed to update template" }, + { status: 500, headers: getCorsHeaders() }, + ); + } + } + + // 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 deleteTemplateShares(templateId); + if (body.share_emails.length > 0) { + await insertTemplateShares(templateId, body.share_emails); + } + } + + const [template] = await selectTemplates({ id: templateId }, accountId); + + return NextResponse.json( + { status: "success", template: template ?? null }, + { status: 200, headers: getCorsHeaders() }, + ); + } catch (error) { + console.error("[ERROR] updateTemplateHandler:", error); + return NextResponse.json( + { status: "error", error: "Internal server error" }, + { status: 500, headers: getCorsHeaders() }, + ); + } +} diff --git a/lib/templates/validateCreateTemplateBody.ts b/lib/templates/validateCreateTemplateBody.ts new file mode 100644 index 000000000..4147dd522 --- /dev/null +++ b/lib/templates/validateCreateTemplateBody.ts @@ -0,0 +1,49 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; + +export const createTemplateBodySchema = 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 CreateTemplateBody = z.infer; + +/** + * 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. + */ +export function validateCreateTemplateBody(body: unknown): NextResponse | CreateTemplateBody { + const result = createTemplateBodySchema.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/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 }; +} diff --git a/lib/templates/validateDeleteTemplateRequest.ts b/lib/templates/validateDeleteTemplateRequest.ts new file mode 100644 index 000000000..1594b8677 --- /dev/null +++ b/lib/templates/validateDeleteTemplateRequest.ts @@ -0,0 +1,45 @@ +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 { selectTemplates } from "@/lib/supabase/templates/selectTemplates"; + +export interface ValidatedDeleteTemplateRequest { + templateId: string; + accountId: string; +} + +/** + * Validates DELETE /api/agents/templates/{id}: auth, id format, and that the + * caller is the template's creator. + */ +export async function validateDeleteTemplateRequest( + 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; + + const templateId = validatedParams.id; + const accountId = authResult.accountId; + + const [existing] = await selectTemplates({ id: templateId }); + if (!existing) { + return NextResponse.json( + { status: "error", error: "Template not found" }, + { status: 404, headers: getCorsHeaders() }, + ); + } + + if (existing.creator?.id !== accountId) { + return NextResponse.json( + { status: "error", error: "Forbidden" }, + { status: 403, headers: getCorsHeaders() }, + ); + } + + return { templateId, accountId }; +} diff --git a/lib/templates/validateToggleFavoriteRequest.ts b/lib/templates/validateToggleFavoriteRequest.ts new file mode 100644 index 000000000..4769bd866 --- /dev/null +++ b/lib/templates/validateToggleFavoriteRequest.ts @@ -0,0 +1,78 @@ +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 { selectTemplates } from "@/lib/supabase/templates/selectTemplates"; +import { isTemplateSharedWithAccount } from "@/lib/supabase/template_shares/isTemplateSharedWithAccount"; + +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/agents/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; + + 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 templateId = validatedParams.id; + const accountId = authResult.accountId; + + const [existing] = await selectTemplates({ id: templateId }); + if (!existing) { + return NextResponse.json( + { status: "error", error: "Template not found" }, + { status: 404, headers: getCorsHeaders() }, + ); + } + + const isOwner = existing.creator?.id === accountId; + let canAccess = isOwner || !existing.is_private; + if (!canAccess) { + canAccess = await isTemplateSharedWithAccount(templateId, accountId); + } + if (!canAccess) { + return NextResponse.json( + { status: "error", error: "Forbidden" }, + { status: 403, headers: getCorsHeaders() }, + ); + } + + return { + templateId, + accountId, + isFavourite: parsedBody.data.is_favourite, + }; +} diff --git a/lib/templates/validateUpdateTemplateRequest.ts b/lib/templates/validateUpdateTemplateRequest.ts new file mode 100644 index 000000000..c02bab548 --- /dev/null +++ b/lib/templates/validateUpdateTemplateRequest.ts @@ -0,0 +1,77 @@ +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 { selectTemplates } from "@/lib/supabase/templates/selectTemplates"; + +export const updateTemplateBodySchema = 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 UpdateTemplateBody = z.infer; + +export interface ValidatedUpdateTemplateRequest { + templateId: string; + accountId: string; + body: UpdateTemplateBody; +} + +/** + * Validates PATCH /api/agents/templates/{id}: auth, id format, body, and that + * the caller is the template's creator. + */ +export async function validateUpdateTemplateRequest( + 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; + + const body = await safeParseJson(request); + const parsedBody = updateTemplateBodySchema.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 templateId = validatedParams.id; + const accountId = authResult.accountId; + + const [existing] = await selectTemplates({ id: templateId }); + if (!existing) { + return NextResponse.json( + { status: "error", error: "Template not found" }, + { status: 404, headers: getCorsHeaders() }, + ); + } + + if (existing.creator?.id !== accountId) { + return NextResponse.json( + { status: "error", error: "Forbidden" }, + { status: 403, headers: getCorsHeaders() }, + ); + } + + return { templateId, accountId, body: parsedBody.data }; +}