diff --git a/app/api/admins/agent-signups/route.ts b/app/api/admins/agent-signups/route.ts new file mode 100644 index 000000000..d6473edb4 --- /dev/null +++ b/app/api/admins/agent-signups/route.ts @@ -0,0 +1,23 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getAgentSignupsHandler } from "@/lib/admins/agent-signups/getAgentSignupsHandler"; + +/** + * GET /api/admins/agent-signups + * + * Returns API key sign-up records created by AI agents. + * Supports period filtering: all, daily, weekly, monthly. + * Requires admin authentication. + * + * @param request + */ +export async function GET(request: NextRequest): Promise { + return getAgentSignupsHandler(request); +} + +/** + * + */ +export async function OPTIONS(): Promise { + return new NextResponse(null, { status: 204, headers: getCorsHeaders() }); +} diff --git a/lib/admins/agent-signups/__tests__/getAgentSignupsHandler.test.ts b/lib/admins/agent-signups/__tests__/getAgentSignupsHandler.test.ts new file mode 100644 index 000000000..7c0da5fa8 --- /dev/null +++ b/lib/admins/agent-signups/__tests__/getAgentSignupsHandler.test.ts @@ -0,0 +1,153 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("@/lib/admins/validateAdminAuth", () => ({ + validateAdminAuth: vi.fn(), +})); + +vi.mock("@/lib/supabase/account_api_keys/getAgentSignups", () => ({ + getAgentSignups: vi.fn(), +})); + +import { getAgentSignupsHandler } from "../getAgentSignupsHandler"; +import { validateAdminAuth } from "@/lib/admins/validateAdminAuth"; +import { getAgentSignups } from "@/lib/supabase/account_api_keys/getAgentSignups"; +import type { AgentSignupRow } from "@/lib/supabase/account_api_keys/getAgentSignups"; +import { NextResponse } from "next/server"; + +function makeRequest(period?: string): NextRequest { + const url = new URL("http://localhost/api/admins/agent-signups"); + if (period) url.searchParams.set("period", period); + return new NextRequest(url); +} + +const mockSignups: AgentSignupRow[] = [ + { + id: "key-1", + name: "Agent Key 1", + email: "agent+bot1@example.com", + created_at: "2026-04-10T12:00:00.000Z", + }, + { + id: "key-2", + name: "Agent Key 2", + email: "agent+bot2@example.com", + created_at: "2026-04-09T08:00:00.000Z", + }, +]; + +describe("getAgentSignupsHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("successful cases", () => { + it("returns agent signups with default period (all)", async () => { + vi.mocked(validateAdminAuth).mockResolvedValue({ + accountId: "admin-1", + orgId: null, + authToken: "token", + }); + vi.mocked(getAgentSignups).mockResolvedValue(mockSignups); + + const response = await getAgentSignupsHandler(makeRequest()); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.status).toBe("success"); + expect(body.total).toBe(2); + expect(body.signups).toHaveLength(2); + expect(body.signups[0].email).toBe("agent+bot1@example.com"); + expect(getAgentSignups).toHaveBeenCalledWith(undefined); + }); + + it("returns filtered signups for daily period", async () => { + vi.mocked(validateAdminAuth).mockResolvedValue({ + accountId: "admin-1", + orgId: null, + authToken: "token", + }); + vi.mocked(getAgentSignups).mockResolvedValue([mockSignups[0]]); + + const response = await getAgentSignupsHandler(makeRequest("daily")); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.total).toBe(1); + expect(getAgentSignups).toHaveBeenCalledWith(expect.any(String)); + }); + + it("returns empty array when no agent signups exist", async () => { + vi.mocked(validateAdminAuth).mockResolvedValue({ + accountId: "admin-1", + orgId: null, + authToken: "token", + }); + vi.mocked(getAgentSignups).mockResolvedValue([]); + + const response = await getAgentSignupsHandler(makeRequest()); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.total).toBe(0); + expect(body.signups).toEqual([]); + }); + }); + + describe("error cases", () => { + it("returns 401 when not authenticated", async () => { + vi.mocked(validateAdminAuth).mockResolvedValue( + NextResponse.json( + { status: "error", message: "Unauthorized" }, + { status: 401 }, + ), + ); + + const response = await getAgentSignupsHandler(makeRequest()); + expect(response.status).toBe(401); + }); + + it("returns 403 when not admin", async () => { + vi.mocked(validateAdminAuth).mockResolvedValue( + NextResponse.json( + { status: "error", message: "Forbidden" }, + { status: 403 }, + ), + ); + + const response = await getAgentSignupsHandler(makeRequest()); + expect(response.status).toBe(403); + }); + + it("returns 400 for invalid period", async () => { + vi.mocked(validateAdminAuth).mockResolvedValue({ + accountId: "admin-1", + orgId: null, + authToken: "token", + }); + + const response = await getAgentSignupsHandler(makeRequest("invalid")); + expect(response.status).toBe(400); + }); + + it("returns 500 on unexpected error", async () => { + vi.mocked(validateAdminAuth).mockResolvedValue({ + accountId: "admin-1", + orgId: null, + authToken: "token", + }); + vi.mocked(getAgentSignups).mockRejectedValue(new Error("DB failure")); + + const response = await getAgentSignupsHandler(makeRequest()); + const body = await response.json(); + + expect(response.status).toBe(500); + expect(body.status).toBe("error"); + expect(body.message).toBe("Internal server error"); + }); + }); +}); diff --git a/lib/admins/agent-signups/getAgentSignupsHandler.ts b/lib/admins/agent-signups/getAgentSignupsHandler.ts new file mode 100644 index 000000000..537481e39 --- /dev/null +++ b/lib/admins/agent-signups/getAgentSignupsHandler.ts @@ -0,0 +1,39 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateGetAgentSignupsQuery } from "./validateGetAgentSignupsQuery"; +import { getAgentSignups } from "@/lib/supabase/account_api_keys/getAgentSignups"; +import { getCutoffDate } from "./getCutoffDate"; + +/** + * Handler for GET /api/admins/agent-signups + * + * Returns API key sign-up records created by AI agents (identified by agent+ email prefix). + * Supports period filtering for time-series analysis. + * + * Requires admin authentication. + * + * @param request - The request object + * @returns A NextResponse with { status, total, signups } + */ +export async function getAgentSignupsHandler(request: NextRequest): Promise { + try { + const query = await validateGetAgentSignupsQuery(request); + if (query instanceof NextResponse) { + return query; + } + + const cutoffDate = getCutoffDate(query.period); + const signups = await getAgentSignups(cutoffDate); + + return NextResponse.json( + { status: "success", total: signups.length, signups }, + { status: 200, headers: getCorsHeaders() }, + ); + } catch (error) { + console.error("[ERROR] getAgentSignupsHandler:", error); + return NextResponse.json( + { status: "error", message: "Internal server error" }, + { status: 500, headers: getCorsHeaders() }, + ); + } +} diff --git a/lib/admins/agent-signups/getCutoffDate.ts b/lib/admins/agent-signups/getCutoffDate.ts new file mode 100644 index 000000000..630fb85d7 --- /dev/null +++ b/lib/admins/agent-signups/getCutoffDate.ts @@ -0,0 +1,23 @@ +import type { AdminPeriod } from "@/lib/admins/adminPeriod"; + +const PERIOD_DAYS: Record, number> = { + daily: 1, + weekly: 7, + monthly: 30, +}; + +/** + * Returns an ISO date string cutoff for the given period, or undefined for "all". + * Uses midnight UTC calendar day boundaries. + */ +export function getCutoffDate(period: AdminPeriod): string | undefined { + if (period === "all") return undefined; + + const days = PERIOD_DAYS[period]; + const now = new Date(); + const cutoff = new Date( + Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()) - + (days - 1) * 24 * 60 * 60 * 1000, + ); + return cutoff.toISOString(); +} diff --git a/lib/admins/agent-signups/validateGetAgentSignupsQuery.ts b/lib/admins/agent-signups/validateGetAgentSignupsQuery.ts new file mode 100644 index 000000000..ba37dcaad --- /dev/null +++ b/lib/admins/agent-signups/validateGetAgentSignupsQuery.ts @@ -0,0 +1,43 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAdminAuth } from "@/lib/admins/validateAdminAuth"; +import { z } from "zod"; +import { adminPeriodSchema, type AdminPeriod } from "@/lib/admins/adminPeriod"; + +const getAgentSignupsQuerySchema = z.object({ + period: adminPeriodSchema.default("all"), +}); + +export type GetAgentSignupsQuery = { + period: AdminPeriod; +}; + +/** + * Validates admin auth and query parameters for GET /api/admins/agent-signups. + * + * @param request - The NextRequest object + * @returns A NextResponse with an error if validation fails, or the validated query + */ +export async function validateGetAgentSignupsQuery( + request: NextRequest, +): Promise { + const auth = await validateAdminAuth(request); + if (auth instanceof NextResponse) { + return auth; + } + + const period = request.nextUrl.searchParams.get("period") || undefined; + + const result = getAgentSignupsQuerySchema.safeParse({ period }); + + if (!result.success) { + const firstError = result.error.issues[0]; + return NextResponse.json( + { status: "error", error: firstError.message }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + return { period: result.data.period }; +} diff --git a/lib/supabase/account_api_keys/getAgentSignups.ts b/lib/supabase/account_api_keys/getAgentSignups.ts new file mode 100644 index 000000000..f58269e03 --- /dev/null +++ b/lib/supabase/account_api_keys/getAgentSignups.ts @@ -0,0 +1,47 @@ +import supabase from "@/lib/supabase/serverClient"; + +export interface AgentSignupRow { + id: string; + name: string; + email: string; + created_at: string; +} + +/** + * Returns API key records whose associated account email starts with "agent+". + * Joins account_api_keys -> accounts -> account_emails and filters by the email prefix. + * + * @param cutoffDate - Optional ISO date string; only returns records created on or after this date + * @returns Array of agent sign-up records ordered by created_at descending + */ +export async function getAgentSignups(cutoffDate?: string): Promise { + let query = supabase + .from("account_api_keys") + .select("id, name, created_at, account, accounts!inner(account_emails!inner(email))") + .like("accounts.account_emails.email", "agent+%") + .order("created_at", { ascending: false }); + + if (cutoffDate) { + query = query.gte("created_at", cutoffDate); + } + + const { data, error } = await query; + + if (error) { + console.error("Error fetching agent signups:", error); + return []; + } + + if (!data) return []; + + return data.map((row) => { + const accounts = row.accounts as unknown as { account_emails: { email: string }[] }; + const email = accounts?.account_emails?.[0]?.email ?? ""; + return { + id: row.id, + name: row.name, + email, + created_at: row.created_at, + }; + }); +}