-
Notifications
You must be signed in to change notification settings - Fork 9
feat: add GET /api/admins/agent-signups endpoint #428
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: test
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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"; | ||
|
|
||
| /** | ||
|
Check failure on line 5 in app/api/admins/agent-signups/route.ts
|
||
| * 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 | ||
|
Check failure on line 12 in app/api/admins/agent-signups/route.ts
|
||
| */ | ||
| export async function GET(request: NextRequest): Promise<NextResponse> { | ||
| return getAgentSignupsHandler(request); | ||
| } | ||
|
|
||
| /** | ||
|
Check failure on line 18 in app/api/admins/agent-signups/route.ts
|
||
| * | ||
| */ | ||
| export async function OPTIONS(): Promise<NextResponse> { | ||
| return new NextResponse(null, { status: 204, headers: getCorsHeaders() }); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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"); | ||
| }); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<NextResponse> { | ||
| 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() }, | ||
| ); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| import type { AdminPeriod } from "@/lib/admins/adminPeriod"; | ||
|
|
||
| const PERIOD_DAYS: Record<Exclude<AdminPeriod, "all">, 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(); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<NextResponse | GetAgentSignupsQuery> { | ||
| 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 }; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<AgentSignupRow[]> { | ||
| 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 []; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Function returns Prompt for AI agents |
||
| } | ||
|
|
||
| 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, | ||
| }; | ||
| }); | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P2: Custom agent: Enforce Clear Code Style and Maintainability Practices
Split this test file into smaller specs; it exceeds the 100-line file limit.
Prompt for AI agents