Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions app/api/admins/agent-signups/route.ts
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

View workflow job for this annotation

GitHub Actions / lint

Missing JSDoc @returns declaration
* 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

View workflow job for this annotation

GitHub Actions / lint

Missing JSDoc @param "request" description
*/
export async function GET(request: NextRequest): Promise<NextResponse> {
return getAgentSignupsHandler(request);
}

/**

Check failure on line 18 in app/api/admins/agent-signups/route.ts

View workflow job for this annotation

GitHub Actions / lint

Missing JSDoc @returns declaration

Check failure on line 18 in app/api/admins/agent-signups/route.ts

View workflow job for this annotation

GitHub Actions / lint

Missing JSDoc block description
*
*/
export async function OPTIONS(): Promise<NextResponse> {
return new NextResponse(null, { status: 204, headers: getCorsHeaders() });
}
153 changes: 153 additions & 0 deletions lib/admins/agent-signups/__tests__/getAgentSignupsHandler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot Apr 12, 2026

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
Check if this issue is valid — if so, understand the root cause and fix it. At lib/admins/agent-signups/__tests__/getAgentSignupsHandler.test.ts, line 1:

<comment>Split this test file into smaller specs; it exceeds the 100-line file limit.</comment>

<file context>
@@ -0,0 +1,153 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { NextRequest } from "next/server";
+
</file context>
Fix with Cubic

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");
});
});
});
39 changes: 39 additions & 0 deletions lib/admins/agent-signups/getAgentSignupsHandler.ts
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() },
);
}
}
23 changes: 23 additions & 0 deletions lib/admins/agent-signups/getCutoffDate.ts
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();
}
43 changes: 43 additions & 0 deletions lib/admins/agent-signups/validateGetAgentSignupsQuery.ts
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 };
}
47 changes: 47 additions & 0 deletions lib/supabase/account_api_keys/getAgentSignups.ts
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 [];
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot Apr 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Function returns [] on error, deviating from the project's documented Supabase query pattern which returns null on error. This prevents callers from distinguishing a database failure from an empty result set — an admin monitoring endpoint should surface errors, not hide them.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/supabase/account_api_keys/getAgentSignups.ts, line 32:

<comment>Function returns `[]` on error, deviating from the project's documented Supabase query pattern which returns `null` on error. This prevents callers from distinguishing a database failure from an empty result set — an admin monitoring endpoint should surface errors, not hide them.</comment>

<file context>
@@ -0,0 +1,47 @@
+
+  if (error) {
+    console.error("Error fetching agent signups:", error);
+    return [];
+  }
+
</file context>
Fix with Cubic

}

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,
};
});
}
Loading