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
21 changes: 21 additions & 0 deletions app/api/admins/coding-agent/slack-tags/route.ts
Original file line number Diff line number Diff line change
@@ -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<NextResponse> {
return getSlackTagOptionsHandler(request);
}

/** CORS preflight handler. */
export async function OPTIONS(): Promise<NextResponse> {
return new NextResponse(null, { status: 204, headers: getCorsHeaders() });
}
126 changes: 126 additions & 0 deletions lib/admins/coding-agent/__tests__/getSlackTagOptionsHandler.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
});
62 changes: 62 additions & 0 deletions lib/admins/coding-agent/getSlackTagOptionsHandler.ts
Original file line number Diff line number Diff line change
@@ -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<NextResponse> {
try {
const validated = await validateGetSlackTagOptionsQuery(request);
if (validated instanceof NextResponse) {
return validated;
}

const mentions = await fetchSlackMentions("all");

const seen = new Set<string>();
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() },
);
}
}
20 changes: 20 additions & 0 deletions lib/admins/coding-agent/validateGetSlackTagOptionsQuery.ts
Original file line number Diff line number Diff line change
@@ -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<NextResponse | true> {
const authResult = await validateAdminAuth(request);
if (authResult instanceof NextResponse) {
return authResult;
}
return true;
}
9 changes: 7 additions & 2 deletions lib/admins/slack/getSlackTagsHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand All @@ -21,7 +21,12 @@ export async function getSlackTagsHandler(request: NextRequest): Promise<NextRes
return query;
}

const tags = await fetchSlackMentions(query.period);
let tags = await fetchSlackMentions(query.period);

if (query.tag) {
tags = tags.filter(t => 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;

Expand Down
8 changes: 6 additions & 2 deletions lib/admins/slack/validateGetSlackTagsQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { NextRequest } from "next/server";

export const getSlackTagsQuerySchema = z.object({
period: adminPeriodSchema.default("all"),
tag: z.string().optional(),
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Reject blank tag values to avoid accidental unfiltered responses

If a client sends ?tag=, Line 32 captures "", it passes validation, and filtering is skipped later because the value is falsy. That returns full results instead of signaling invalid filter input.

💡 Proposed fix
   const { searchParams } = new URL(request.url);
+  const rawTag = searchParams.get("tag");
+  if (rawTag !== null && rawTag.trim() === "") {
+    return NextResponse.json(
+      { status: "error", error: "tag must be a non-empty string" },
+      { status: 400, headers: getCorsHeaders() },
+    );
+  }
   const raw = {
     period: searchParams.get("period") ?? "all",
-    tag: searchParams.get("tag") ?? undefined,
+    tag: rawTag ?? undefined,
   };

Also applies to: 30-33

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/admins/slack/validateGetSlackTagsQuery.ts` at line 10, The query schema
currently allows an empty tag string (tag: z.string().optional()), which lets
"?tag=" pass validation and later be treated as falsy, causing unintended
unfiltered results; in validateGetSlackTagsQuery change the tag schema to reject
empty strings (e.g., use z.string().min(1).optional() or z.string().refine(s =>
s.trim().length > 0).optional()) so blank values are invalid (apply same change
to the other occurrences referenced around lines 30-33).

});

export type GetSlackTagsQuery = z.infer<typeof getSlackTagsQuerySchema>;
Expand All @@ -15,7 +16,7 @@ export type GetSlackTagsQuery = z.infer<typeof getSlackTagsQuerySchema>;
* 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,
Expand All @@ -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);

Expand Down
Loading