From 87b62d16085b1bbf97e7f513788a6bd4afd0f104 Mon Sep 17 00:00:00 2001 From: Recoup Agent Date: Mon, 23 Mar 2026 11:24:01 +0000 Subject: [PATCH] progress: api - add GET /api/admins/coding-agent/slack-tags endpoint Co-Authored-By: Claude Sonnet 4.6 --- .../admins/coding-agent/slack-tags/route.ts | 19 ++ .../__tests__/getSlackTagsHandler.test.ts | 112 +++++++++++ .../validateGetSlackTagsQuery.test.ts | 87 +++++++++ lib/admins/coding-agent/fetchSlackMentions.ts | 180 ++++++++++++++++++ .../coding-agent/getSlackTagsHandler.ts | 37 ++++ .../coding-agent/validateGetSlackTagsQuery.ts | 45 +++++ 6 files changed, 480 insertions(+) create mode 100644 app/api/admins/coding-agent/slack-tags/route.ts create mode 100644 lib/admins/coding-agent/__tests__/getSlackTagsHandler.test.ts create mode 100644 lib/admins/coding-agent/__tests__/validateGetSlackTagsQuery.test.ts create mode 100644 lib/admins/coding-agent/fetchSlackMentions.ts create mode 100644 lib/admins/coding-agent/getSlackTagsHandler.ts create mode 100644 lib/admins/coding-agent/validateGetSlackTagsQuery.ts 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..db1463bf --- /dev/null +++ b/app/api/admins/coding-agent/slack-tags/route.ts @@ -0,0 +1,19 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getSlackTagsHandler } from "@/lib/admins/coding-agent/getSlackTagsHandler"; + +/** + * GET /api/admins/coding-agent/slack-tags + * + * Returns Slack tagging analytics for the Recoup Coding Agent bot. + * Pulls directly from the Slack API as the source of truth. + * Supports period filtering: all (default), daily, weekly, monthly. + * Requires admin authentication. + */ +export async function GET(request: NextRequest): Promise { + return getSlackTagsHandler(request); +} + +export async function OPTIONS(): Promise { + return new NextResponse(null, { status: 204, headers: getCorsHeaders() }); +} diff --git a/lib/admins/coding-agent/__tests__/getSlackTagsHandler.test.ts b/lib/admins/coding-agent/__tests__/getSlackTagsHandler.test.ts new file mode 100644 index 00000000..71dc5e83 --- /dev/null +++ b/lib/admins/coding-agent/__tests__/getSlackTagsHandler.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { getSlackTagsHandler } from "../getSlackTagsHandler"; +import { validateGetSlackTagsQuery } from "../validateGetSlackTagsQuery"; +import { fetchSlackMentions } from "../fetchSlackMentions"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("../validateGetSlackTagsQuery", () => ({ + validateGetSlackTagsQuery: vi.fn(), +})); + +vi.mock("../fetchSlackMentions", () => ({ + fetchSlackMentions: vi.fn(), +})); + +const mockTags = [ + { + user_id: "U012AB3CD", + user_name: "Jane Smith", + user_avatar: "https://avatars.slack-edge.com/jane.jpg", + prompt: "add dark mode support", + timestamp: "2024-01-15T10:30:00.000Z", + channel_id: "C012AB3CD", + channel_name: "dev-team", + }, + { + user_id: "U098ZY7WX", + user_name: "Bob Lee", + user_avatar: null, + prompt: "fix login bug", + timestamp: "2024-01-14T08:00:00.000Z", + channel_id: "C012AB3CD", + channel_name: "dev-team", + }, +]; + +function makeRequest(period = "all") { + return new NextRequest( + `https://example.com/api/admins/coding-agent/slack-tags?period=${period}`, + ); +} + +describe("getSlackTagsHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("successful cases", () => { + it("returns 200 with tags and total", async () => { + vi.mocked(validateGetSlackTagsQuery).mockResolvedValue({ period: "all" }); + vi.mocked(fetchSlackMentions).mockResolvedValue(mockTags); + + const response = await getSlackTagsHandler(makeRequest()); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.status).toBe("success"); + expect(body.total).toBe(2); + expect(body.tags).toEqual(mockTags); + }); + + it("returns 200 with empty tags when no mentions found", async () => { + vi.mocked(validateGetSlackTagsQuery).mockResolvedValue({ period: "daily" }); + vi.mocked(fetchSlackMentions).mockResolvedValue([]); + + const response = await getSlackTagsHandler(makeRequest("daily")); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.status).toBe("success"); + expect(body.total).toBe(0); + expect(body.tags).toEqual([]); + }); + + it("passes the period to fetchSlackMentions", async () => { + vi.mocked(validateGetSlackTagsQuery).mockResolvedValue({ period: "weekly" }); + vi.mocked(fetchSlackMentions).mockResolvedValue([]); + + await getSlackTagsHandler(makeRequest("weekly")); + + expect(fetchSlackMentions).toHaveBeenCalledWith("weekly"); + }); + }); + + describe("error cases", () => { + it("returns auth error when validateGetSlackTagsQuery returns NextResponse", async () => { + const errorResponse = NextResponse.json( + { status: "error", message: "Forbidden" }, + { status: 403 }, + ); + vi.mocked(validateGetSlackTagsQuery).mockResolvedValue(errorResponse); + + const response = await getSlackTagsHandler(makeRequest()); + + expect(response.status).toBe(403); + }); + + it("returns 500 when fetchSlackMentions throws", async () => { + vi.mocked(validateGetSlackTagsQuery).mockResolvedValue({ period: "all" }); + vi.mocked(fetchSlackMentions).mockRejectedValue(new Error("Slack API error")); + + const response = await getSlackTagsHandler(makeRequest()); + const body = await response.json(); + + expect(response.status).toBe(500); + expect(body.status).toBe("error"); + }); + }); +}); diff --git a/lib/admins/coding-agent/__tests__/validateGetSlackTagsQuery.test.ts b/lib/admins/coding-agent/__tests__/validateGetSlackTagsQuery.test.ts new file mode 100644 index 00000000..cb20337f --- /dev/null +++ b/lib/admins/coding-agent/__tests__/validateGetSlackTagsQuery.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { validateGetSlackTagsQuery } from "../validateGetSlackTagsQuery"; +import { validateAdminAuth } from "@/lib/admins/validateAdminAuth"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("@/lib/admins/validateAdminAuth", () => ({ + validateAdminAuth: vi.fn(), +})); + +const mockAuth = { accountId: "test-account", orgId: null, authToken: "token" }; + +function makeRequest(period?: string) { + const url = period + ? `https://example.com/api/admins/coding-agent/slack-tags?period=${period}` + : "https://example.com/api/admins/coding-agent/slack-tags"; + return new NextRequest(url); +} + +describe("validateGetSlackTagsQuery", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("successful cases", () => { + it("returns all period by default", async () => { + vi.mocked(validateAdminAuth).mockResolvedValue(mockAuth); + + const result = await validateGetSlackTagsQuery(makeRequest()); + + expect(result).toEqual({ period: "all" }); + }); + + it("returns daily when period=daily", async () => { + vi.mocked(validateAdminAuth).mockResolvedValue(mockAuth); + + const result = await validateGetSlackTagsQuery(makeRequest("daily")); + + expect(result).toEqual({ period: "daily" }); + }); + + it("returns weekly when period=weekly", async () => { + vi.mocked(validateAdminAuth).mockResolvedValue(mockAuth); + + const result = await validateGetSlackTagsQuery(makeRequest("weekly")); + + expect(result).toEqual({ period: "weekly" }); + }); + + it("returns monthly when period=monthly", async () => { + vi.mocked(validateAdminAuth).mockResolvedValue(mockAuth); + + const result = await validateGetSlackTagsQuery(makeRequest("monthly")); + + expect(result).toEqual({ period: "monthly" }); + }); + }); + + describe("error cases", () => { + it("returns auth error when validateAdminAuth returns NextResponse", async () => { + const errorResponse = NextResponse.json( + { status: "error", message: "Unauthorized" }, + { status: 401 }, + ); + vi.mocked(validateAdminAuth).mockResolvedValue(errorResponse); + + const result = await validateGetSlackTagsQuery(makeRequest()); + + expect(result).toBeInstanceOf(NextResponse); + const body = await (result as NextResponse).json(); + expect(body.status).toBe("error"); + }); + + it("returns 400 for invalid period value", async () => { + vi.mocked(validateAdminAuth).mockResolvedValue(mockAuth); + + const result = await validateGetSlackTagsQuery(makeRequest("yearly")); + + expect(result).toBeInstanceOf(NextResponse); + const body = await (result as NextResponse).json(); + expect(body.status).toBe("error"); + }); + }); +}); diff --git a/lib/admins/coding-agent/fetchSlackMentions.ts b/lib/admins/coding-agent/fetchSlackMentions.ts new file mode 100644 index 00000000..7af371e0 --- /dev/null +++ b/lib/admins/coding-agent/fetchSlackMentions.ts @@ -0,0 +1,180 @@ +import type { SlackTagsPeriod } from "./validateGetSlackTagsQuery"; + +export interface SlackTag { + user_id: string; + user_name: string; + user_avatar: string | null; + prompt: string; + timestamp: string; + channel_id: string; + channel_name: string; +} + +interface SlackApiResponse { + ok: boolean; + error?: string; +} + +interface AuthTestResponse extends SlackApiResponse { + user_id?: string; +} + +interface ConversationsListResponse extends SlackApiResponse { + channels?: Array<{ id: string; name: string }>; + response_metadata?: { next_cursor?: string }; +} + +interface ConversationsHistoryResponse extends SlackApiResponse { + messages?: Array<{ + type: string; + user?: string; + text?: string; + ts?: string; + bot_id?: string; + }>; + response_metadata?: { next_cursor?: string }; +} + +interface UsersInfoResponse extends SlackApiResponse { + user?: { + id: string; + real_name?: string; + profile?: { + display_name?: string; + real_name?: string; + image_48?: string; + }; + }; +} + +const SLACK_API_BASE = "https://slack.com/api"; + +async function slackGet( + endpoint: string, + token: string, + params: Record = {}, +): Promise { + const url = new URL(`${SLACK_API_BASE}/${endpoint}`); + for (const [key, value] of Object.entries(params)) { + url.searchParams.set(key, value); + } + const res = await fetch(url.toString(), { + headers: { Authorization: `Bearer ${token}` }, + }); + return res.json() as Promise; +} + +function getCutoffTs(period: SlackTagsPeriod): number | null { + if (period === "all") return null; + const days = period === "daily" ? 1 : period === "weekly" ? 7 : 30; + return (Date.now() - days * 24 * 60 * 60 * 1000) / 1000; +} + +/** + * Fetches all Slack messages where the Recoup Coding Agent bot was mentioned. + * Pulls directly from the Slack API using the bot token as the source of truth. + * + * @param period - Time period filter: "all", "daily", "weekly", or "monthly" + * @returns Array of SlackTag objects representing each mention event + */ +export async function fetchSlackMentions(period: SlackTagsPeriod): Promise { + const token = process.env.SLACK_BOT_TOKEN; + if (!token) { + throw new Error("SLACK_BOT_TOKEN is not configured"); + } + + // Get the bot's own user ID so we can detect mentions + const authTest = await slackGet("auth.test", token); + if (!authTest.ok || !authTest.user_id) { + throw new Error(`Slack auth.test failed: ${authTest.error ?? "unknown error"}`); + } + const botUserId = authTest.user_id; + const mentionPattern = `<@${botUserId}>`; + + // Get all channels the bot is a member of + const channels: Array<{ id: string; name: string }> = []; + let channelCursor: string | undefined; + + do { + const params: Record = { types: "public_channel,private_channel", limit: "200" }; + if (channelCursor) params.cursor = channelCursor; + + const resp = await slackGet("conversations.list", token, params); + if (!resp.ok) break; + + if (resp.channels) { + channels.push(...resp.channels); + } + channelCursor = resp.response_metadata?.next_cursor || undefined; + } while (channelCursor); + + const cutoffTs = getCutoffTs(period); + const tags: SlackTag[] = []; + const userCache: Record = {}; + + for (const channel of channels) { + let cursor: string | undefined; + + do { + const params: Record = { channel: channel.id, limit: "200" }; + if (cursor) params.cursor = cursor; + if (cutoffTs) params.oldest = String(cutoffTs); + + const history = await slackGet( + "conversations.history", + token, + params, + ); + if (!history.ok) break; + + for (const msg of history.messages ?? []) { + // Only process human messages (not bot messages) that mention the bot + if (msg.bot_id) continue; + if (!msg.user) continue; + if (!msg.text?.includes(mentionPattern)) continue; + + const userId = msg.user; + + // Resolve user info (cache to avoid repeated API calls) + if (!userCache[userId]) { + const userResp = await slackGet("users.info", token, { + user: userId, + }); + const profile = userResp.user?.profile; + userCache[userId] = { + name: + profile?.display_name || + profile?.real_name || + userResp.user?.real_name || + userId, + avatar: profile?.image_48 ?? null, + }; + } + + const { name, avatar } = userCache[userId]; + + // Strip the bot mention from the prompt text + const prompt = (msg.text ?? "") + .replace(new RegExp(`<@${botUserId}>\\s*`, "g"), "") + .trim(); + + tags.push({ + user_id: userId, + user_name: name, + user_avatar: avatar, + prompt, + timestamp: new Date(parseFloat(msg.ts ?? "0") * 1000).toISOString(), + channel_id: channel.id, + channel_name: channel.name, + }); + } + + cursor = history.response_metadata?.next_cursor || undefined; + } while (cursor); + } + + // Sort newest first + tags.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()); + + return tags; +} diff --git a/lib/admins/coding-agent/getSlackTagsHandler.ts b/lib/admins/coding-agent/getSlackTagsHandler.ts new file mode 100644 index 00000000..98649a99 --- /dev/null +++ b/lib/admins/coding-agent/getSlackTagsHandler.ts @@ -0,0 +1,37 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateGetSlackTagsQuery } from "./validateGetSlackTagsQuery"; +import { fetchSlackMentions } from "./fetchSlackMentions"; + +/** + * Handler for GET /api/admins/coding-agent/slack-tags + * + * 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. + * + * Requires admin authentication. + * + * @param request - The request object + * @returns A NextResponse with { status, total, tags } + */ +export async function getSlackTagsHandler(request: NextRequest): Promise { + try { + const query = await validateGetSlackTagsQuery(request); + if (query instanceof NextResponse) { + return query; + } + + const tags = await fetchSlackMentions(query.period); + + return NextResponse.json( + { status: "success", total: tags.length, tags }, + { status: 200, headers: getCorsHeaders() }, + ); + } catch (error) { + console.error("[ERROR] getSlackTagsHandler:", error); + return NextResponse.json( + { status: "error", message: "Internal server error" }, + { status: 500, headers: getCorsHeaders() }, + ); + } +} diff --git a/lib/admins/coding-agent/validateGetSlackTagsQuery.ts b/lib/admins/coding-agent/validateGetSlackTagsQuery.ts new file mode 100644 index 00000000..39e6edf9 --- /dev/null +++ b/lib/admins/coding-agent/validateGetSlackTagsQuery.ts @@ -0,0 +1,45 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAdminAuth } from "@/lib/admins/validateAdminAuth"; +import type { NextRequest } from "next/server"; + +export const slackTagsPeriodSchema = z.enum(["all", "daily", "weekly", "monthly"]).default("all"); + +export type SlackTagsPeriod = z.infer; + +export const getSlackTagsQuerySchema = z.object({ + period: slackTagsPeriodSchema, +}); + +export type GetSlackTagsQuery = z.infer; + +/** + * Validates the query parameters and admin auth for GET /api/admins/coding-agent/slack-tags. + * + * @param request - The incoming Next.js request + * @returns A NextResponse on error, or { period } on success + */ +export async function validateGetSlackTagsQuery( + request: NextRequest, +): Promise { + const authResult = await validateAdminAuth(request); + if (authResult instanceof NextResponse) { + return authResult; + } + + const { searchParams } = new URL(request.url); + const raw = { period: searchParams.get("period") ?? "all" }; + + const result = getSlackTagsQuerySchema.safeParse(raw); + + if (!result.success) { + const firstError = result.error.issues[0]; + return NextResponse.json( + { status: "error", error: firstError.message }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + return result.data; +}