diff --git a/app/api/admins/coding-agent/slack-tags/route.ts b/app/api/admins/coding-agent/slack-tags/route.ts new file mode 100644 index 00000000..9e0473eb --- /dev/null +++ b/app/api/admins/coding-agent/slack-tags/route.ts @@ -0,0 +1,21 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getSlackTagOptionsHandler } from "@/lib/admins/coding-agent/getSlackTagOptionsHandler"; + +/** + * GET /api/admins/coding-agent/slack-tags + * + * Returns the distinct set of Slack users who have tagged the Recoup Coding Agent bot. + * Used by the admin UI to populate tag filter chips. + * Requires admin authentication. + * + * @param request + */ +export async function GET(request: NextRequest): Promise { + return getSlackTagOptionsHandler(request); +} + +/** CORS preflight handler. */ +export async function OPTIONS(): Promise { + return new NextResponse(null, { status: 204, headers: getCorsHeaders() }); +} diff --git a/lib/admins/coding-agent/__tests__/getSlackTagOptionsHandler.test.ts b/lib/admins/coding-agent/__tests__/getSlackTagOptionsHandler.test.ts new file mode 100644 index 00000000..0c0764f4 --- /dev/null +++ b/lib/admins/coding-agent/__tests__/getSlackTagOptionsHandler.test.ts @@ -0,0 +1,126 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; +import { getSlackTagOptionsHandler } from "../getSlackTagOptionsHandler"; + +import { validateGetSlackTagOptionsQuery } from "../validateGetSlackTagOptionsQuery"; +import { fetchSlackMentions } from "@/lib/admins/slack/fetchSlackMentions"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("../validateGetSlackTagOptionsQuery", () => ({ + validateGetSlackTagOptionsQuery: vi.fn(), +})); + +vi.mock("@/lib/admins/slack/fetchSlackMentions", () => ({ + fetchSlackMentions: vi.fn(), +})); + +const mockMentions = [ + { + user_id: "U001", + user_name: "Bob", + user_avatar: null, + prompt: "hello", + timestamp: "2026-01-01T00:00:00Z", + channel_id: "C001", + channel_name: "general", + pull_requests: [], + }, + { + user_id: "U002", + user_name: "Alice", + user_avatar: "https://example.com/alice.png", + prompt: "fix bug", + timestamp: "2026-01-02T00:00:00Z", + channel_id: "C001", + channel_name: "general", + pull_requests: [], + }, + { + user_id: "U001", + user_name: "Bob", + user_avatar: null, + prompt: "second mention", + timestamp: "2026-01-03T00:00:00Z", + channel_id: "C002", + channel_name: "random", + pull_requests: [], + }, +]; + +/** + * + */ +function makeRequest() { + return new NextRequest("https://api.example.com/api/admins/coding-agent/slack-tags"); +} + +describe("getSlackTagOptionsHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("successful cases", () => { + it("returns unique users sorted alphabetically", async () => { + vi.mocked(validateGetSlackTagOptionsQuery).mockResolvedValue(true); + vi.mocked(fetchSlackMentions).mockResolvedValue(mockMentions); + + const response = await getSlackTagOptionsHandler(makeRequest()); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.status).toBe("success"); + expect(body.total).toBe(2); + // Sorted alphabetically: Alice before Bob + expect(body.tags[0].name).toBe("Alice"); + expect(body.tags[1].name).toBe("Bob"); + }); + + it("deduplicates users appearing in multiple mentions", async () => { + vi.mocked(validateGetSlackTagOptionsQuery).mockResolvedValue(true); + vi.mocked(fetchSlackMentions).mockResolvedValue(mockMentions); + + const response = await getSlackTagOptionsHandler(makeRequest()); + const body = await response.json(); + + expect(body.total).toBe(2); // U001 and U002 only + }); + + it("returns empty tags when no mentions exist", async () => { + vi.mocked(validateGetSlackTagOptionsQuery).mockResolvedValue(true); + vi.mocked(fetchSlackMentions).mockResolvedValue([]); + + const response = await getSlackTagOptionsHandler(makeRequest()); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.total).toBe(0); + expect(body.tags).toEqual([]); + }); + }); + + describe("error cases", () => { + it("returns 401 when auth fails", async () => { + const { NextResponse } = await import("next/server"); + vi.mocked(validateGetSlackTagOptionsQuery).mockResolvedValue( + NextResponse.json({ error: "Unauthorized" }, { status: 401 }), + ); + + const response = await getSlackTagOptionsHandler(makeRequest()); + expect(response.status).toBe(401); + }); + + it("returns 500 when fetchSlackMentions throws", async () => { + vi.mocked(validateGetSlackTagOptionsQuery).mockResolvedValue(true); + vi.mocked(fetchSlackMentions).mockRejectedValue(new Error("Slack API error")); + + const response = await getSlackTagOptionsHandler(makeRequest()); + const body = await response.json(); + + expect(response.status).toBe(500); + expect(body.status).toBe("error"); + }); + }); +}); diff --git a/lib/admins/coding-agent/getSlackTagOptionsHandler.ts b/lib/admins/coding-agent/getSlackTagOptionsHandler.ts new file mode 100644 index 00000000..013ceff9 --- /dev/null +++ b/lib/admins/coding-agent/getSlackTagOptionsHandler.ts @@ -0,0 +1,62 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateGetSlackTagOptionsQuery } from "./validateGetSlackTagOptionsQuery"; +import { fetchSlackMentions } from "@/lib/admins/slack/fetchSlackMentions"; + +export interface SlackTagOption { + id: string; + name: string; + avatar: string | null; +} + +/** + * Handler for GET /api/admins/coding-agent/slack-tags + * + * Returns the distinct set of Slack users who have ever tagged the Recoup Coding Agent bot. + * Each entry is a { id, name, avatar } suitable for use as a filter chip in the admin UI. + * + * Requires admin authentication. + * + * @param request + */ +export async function getSlackTagOptionsHandler(request: NextRequest): Promise { + try { + const validated = await validateGetSlackTagOptionsQuery(request); + if (validated instanceof NextResponse) { + return validated; + } + + const mentions = await fetchSlackMentions("all"); + + const seen = new Set(); + const tags: SlackTagOption[] = []; + for (const mention of mentions) { + if (!seen.has(mention.user_id)) { + seen.add(mention.user_id); + tags.push({ + id: mention.user_id, + name: mention.user_name, + avatar: mention.user_avatar, + }); + } + } + + // Sort alphabetically by name + tags.sort((a, b) => a.name.localeCompare(b.name)); + + return NextResponse.json( + { + status: "success", + total: tags.length, + tags, + }, + { status: 200, headers: getCorsHeaders() }, + ); + } catch (error) { + console.error("[ERROR] getSlackTagOptionsHandler:", error); + return NextResponse.json( + { status: "error", message: "Internal server error" }, + { status: 500, headers: getCorsHeaders() }, + ); + } +} diff --git a/lib/admins/coding-agent/validateGetSlackTagOptionsQuery.ts b/lib/admins/coding-agent/validateGetSlackTagOptionsQuery.ts new file mode 100644 index 00000000..cea7d455 --- /dev/null +++ b/lib/admins/coding-agent/validateGetSlackTagOptionsQuery.ts @@ -0,0 +1,20 @@ +import { NextResponse } from "next/server"; +import { validateAdminAuth } from "@/lib/admins/validateAdminAuth"; +import type { NextRequest } from "next/server"; + +/** + * Validates admin auth for GET /api/admins/coding-agent/slack-tags. + * No query params — always returns all-time unique tags. + * + * @param request - The incoming Next.js request + * @returns A NextResponse on error, or true on success + */ +export async function validateGetSlackTagOptionsQuery( + request: NextRequest, +): Promise { + const authResult = await validateAdminAuth(request); + if (authResult instanceof NextResponse) { + return authResult; + } + return true; +} diff --git a/lib/admins/slack/getSlackTagsHandler.ts b/lib/admins/slack/getSlackTagsHandler.ts index 3569d7fa..e5e4b259 100644 --- a/lib/admins/slack/getSlackTagsHandler.ts +++ b/lib/admins/slack/getSlackTagsHandler.ts @@ -7,7 +7,7 @@ import { fetchSlackMentions } from "./fetchSlackMentions"; * Handler for GET /api/admins/coding/slack * * Returns a list of Slack mentions of the Recoup Coding Agent bot, pulled directly - * from the Slack API as the source of truth. Supports optional time-period filtering. + * from the Slack API as the source of truth. Supports optional time-period and tag filtering. * * Requires admin authentication. * @@ -21,7 +21,12 @@ export async function getSlackTagsHandler(request: NextRequest): Promise t.user_id === query.tag); + } + const totalPullRequests = tags.reduce((sum, tag) => sum + tag.pull_requests.length, 0); const tagsWithPullRequests = tags.filter(tag => tag.pull_requests.length > 0).length; diff --git a/lib/admins/slack/validateGetSlackTagsQuery.ts b/lib/admins/slack/validateGetSlackTagsQuery.ts index f8330daa..0ec792ed 100644 --- a/lib/admins/slack/validateGetSlackTagsQuery.ts +++ b/lib/admins/slack/validateGetSlackTagsQuery.ts @@ -7,6 +7,7 @@ import type { NextRequest } from "next/server"; export const getSlackTagsQuerySchema = z.object({ period: adminPeriodSchema.default("all"), + tag: z.string().optional(), }); export type GetSlackTagsQuery = z.infer; @@ -15,7 +16,7 @@ export type GetSlackTagsQuery = z.infer; * Validates the query parameters and admin auth for GET /api/admins/coding/slack. * * @param request - The incoming Next.js request - * @returns A NextResponse on error, or { period } on success + * @returns A NextResponse on error, or { period, tag } on success */ export async function validateGetSlackTagsQuery( request: NextRequest, @@ -26,7 +27,10 @@ export async function validateGetSlackTagsQuery( } const { searchParams } = new URL(request.url); - const raw = { period: searchParams.get("period") ?? "all" }; + const raw = { + period: searchParams.get("period") ?? "all", + tag: searchParams.get("tag") ?? undefined, + }; const result = getSlackTagsQuerySchema.safeParse(raw);