From 76b8d33f9a393af15619591d5aa3ad728855edda Mon Sep 17 00:00:00 2001 From: Evan Petzoldt Date: Sat, 16 May 2026 10:18:15 -0500 Subject: [PATCH 1/7] refactor(api): initial pull from old slackbot PR --- apps/api/src/app/docs/openapi.json/route.ts | 9 + packages/api/src/index.ts | 2 + packages/api/src/router/slack.test.ts | 314 +++++ packages/api/src/router/slack.ts | 1219 +++++++++++++++++++ 4 files changed, 1544 insertions(+) create mode 100644 packages/api/src/router/slack.test.ts create mode 100644 packages/api/src/router/slack.ts diff --git a/apps/api/src/app/docs/openapi.json/route.ts b/apps/api/src/app/docs/openapi.json/route.ts index 1b2b2dd5..d0752f03 100644 --- a/apps/api/src/app/docs/openapi.json/route.ts +++ b/apps/api/src/app/docs/openapi.json/route.ts @@ -157,6 +157,10 @@ As of February 1, 2026, regional admins can only create read-only API keys. If y name: "Me", tags: ["Me"], }, + { + name: "Slack", + tags: ["slack"], + }, ], tags: [ { @@ -203,6 +207,11 @@ As of February 1, 2026, regional admins can only create read-only API keys. If y description: "Map event/workout endpoints for filtering and querying", }, { name: "revalidate", description: "Cache revalidation for map data" }, + { + name: "slack", + description: + "Slack workspace integration endpoints for user/space management and org linking", + }, ], components: { diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 59213601..cf9dff40 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -17,6 +17,7 @@ import { orgRouter } from "./router/org"; import { pingRouter } from "./router/ping"; import { positionRouter } from "./router/position"; import { requestRouter } from "./router/request"; +import { slackRouter } from "./router/slack"; import { userRouter } from "./router/user"; // Re-export webhook event types for external use @@ -39,5 +40,6 @@ export const router = os.prefix(API_PREFIX_V1).router({ org: os.prefix("/org").router(orgRouter), position: os.prefix("/position").router(positionRouter), request: os.prefix("/request").router(requestRouter), + slack: os.prefix("/slack").router(slackRouter), user: os.prefix("/user").router(userRouter), }); diff --git a/packages/api/src/router/slack.test.ts b/packages/api/src/router/slack.test.ts new file mode 100644 index 00000000..9772e1a9 --- /dev/null +++ b/packages/api/src/router/slack.test.ts @@ -0,0 +1,314 @@ +import { eq, or, schema } from "@acme/db"; +import { db } from "@acme/db/client"; +import { Client, Header } from "@acme/shared/common/enums"; +import { createRouterClient } from "@orpc/server"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { router } from "../index"; +import { uniqueId } from "../__tests__/test-utils"; + +/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-return, @typescript-eslint/no-explicit-any */ +vi.mock("@acme/env", async (importOriginal) => { + const actual = (await importOriginal()) as any; + return { + ...actual, + env: { + ...actual.env, + SUPER_ADMIN_API_KEY: "test-admin-key", + }, + }; +}); +/* eslint-enable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-return, @typescript-eslint/no-explicit-any */ + +describe("Slack Router", () => { + const teamId = uniqueId(); + let slackSpaceId: number; + let testOrgId: number; + + const createTestClient = (apiKey?: string) => { + return createRouterClient(router, { + context: () => + Promise.resolve({ + reqHeaders: new Headers({ + [Header.Client]: Client.ORPC, + ...(apiKey ? { "x-api-key": apiKey } : {}), + }), + }), + }); + }; + + beforeAll(async () => { + // Create a test org + const [org] = await db + .insert(schema.orgs) + .values({ + name: `Org-${teamId}`, + orgType: "region", + isActive: true, + }) + .returning(); + testOrgId = org!.id; + + // Create a test slack space + const [space] = await db + .insert(schema.slackSpaces) + .values({ + teamId, + workspaceName: "Test Workspace", + settings: { + welcome_dm_enable: true, + welcome_dm_template: "Welcome!", + }, + }) + .returning(); + slackSpaceId = space!.id; + + // Link them + await db.insert(schema.orgsXSlackSpaces).values({ + orgId: testOrgId, + slackSpaceId, + }); + }); + + afterAll(async () => { + await db + .delete(schema.orgsXSlackSpaces) + .where(eq(schema.orgsXSlackSpaces.slackSpaceId, slackSpaceId)); + await db + .delete(schema.slackSpaces) + .where(eq(schema.slackSpaces.id, slackSpaceId)); + await db.delete(schema.orgs).where(eq(schema.orgs.id, testOrgId)); + }); + + describe("getSpace", () => { + it("should return space settings for a team", async () => { + const client = createTestClient(); + const result = await client.slack.getSpace({ teamId }); + expect(result).not.toBeNull(); + expect(result?.teamId).toBe(teamId); + expect(result?.settings).toHaveProperty( + "welcome_dm_template", + "Welcome!", + ); + }); + + it("should return null for non-existent team", async () => { + const client = createTestClient(); + const result = await client.slack.getSpace({ teamId: "non-existent" }); + expect(result).toBeNull(); + }); + }); + + describe("getOrCreateSpace", () => { + it("should return existing space if it exists", async () => { + const client = createTestClient("test-admin-key"); + const result = await client.slack.getOrCreateSpace({ teamId }); + expect(result).not.toBeNull(); + expect(result!.id).toBe(slackSpaceId); + }); + + it("should create new space if it doesn't exist", async () => { + const newTeamId = uniqueId(); + const client = createTestClient("test-admin-key"); + const result = await client.slack.getOrCreateSpace({ + teamId: newTeamId, + workspaceName: "New Space", + }); + expect(result).not.toBeNull(); + expect(result!.teamId).toBe(newTeamId); + expect(result!.workspaceName).toBe("New Space"); + + // Cleanup + await db + .delete(schema.slackSpaces) + .where(eq(schema.slackSpaces.id, result!.id)); + }); + + it("should store botToken when provided", async () => { + const newTeamId = uniqueId(); + const client = createTestClient("test-admin-key"); + const result = await client.slack.getOrCreateSpace({ + teamId: newTeamId, + workspaceName: "Token Space", + botToken: "xoxb-test-token", + }); + expect(result).not.toBeNull(); + expect(result!.teamId).toBe(newTeamId); + expect(result!.botToken).toBe("xoxb-test-token"); + + // Cleanup + await db + .delete(schema.slackSpaces) + .where(eq(schema.slackSpaces.id, result!.id)); + }); + }); + + describe("updateSpaceSettings", () => { + it("should require an API key", async () => { + const client = createTestClient(); + await expect( + client.slack.updateSpaceSettings({ + teamId, + settings: { welcome_dm_enable: false }, + }), + ).rejects.toThrow("Unauthorized"); + }); + + it("should update settings with a valid API key", async () => { + const client = createTestClient("test-admin-key"); + const result = await client.slack.updateSpaceSettings({ + teamId, + settings: { + welcome_dm_enable: false, + welcome_dm_template: "Updated Welcome!", + }, + }); + + expect(result.success).toBe(true); + + const updated = await client.slack.getSpace({ teamId }); + expect(updated?.settings).toHaveProperty("welcome_dm_enable", false); + expect(updated?.settings).toHaveProperty( + "welcome_dm_template", + "Updated Welcome!", + ); + }); + }); + + describe("getUserBySlackId", () => { + const slackId = `U${uniqueId()}`; + + beforeAll(async () => { + await db.insert(schema.slackUsers).values({ + slackId, + userName: "testuser", + email: "test@example.com", + slackTeamId: teamId, + isAdmin: false, + isOwner: false, + isBot: false, + }); + }); + + afterAll(async () => { + await db + .delete(schema.slackUsers) + .where(eq(schema.slackUsers.slackId, slackId)); + }); + + it("should find a user by slackId", async () => { + const client = createTestClient(); + const result = await client.slack.getUserBySlackId({ slackId, teamId }); + expect(result).not.toBeNull(); + expect(result?.slackId).toBe(slackId); + expect(result?.userName).toBe("testuser"); + }); + }); + + describe("getOrCreateUser", () => { + const existingSlackId = `U${uniqueId()}`; + const newSlackId = `U${uniqueId()}`; + + beforeAll(async () => { + await db.insert(schema.slackUsers).values({ + slackId: existingSlackId, + userName: "existing", + email: "existing@example.com", + slackTeamId: teamId, + isAdmin: false, + isOwner: false, + isBot: false, + }); + }); + + afterAll(async () => { + await db + .delete(schema.slackUsers) + .where( + or( + eq(schema.slackUsers.slackId, existingSlackId), + eq(schema.slackUsers.slackId, newSlackId), + ), + ); + }); + + it("should return existing user if they exist", async () => { + const client = createTestClient("test-admin-key"); + const result = await client.slack.getOrCreateUser({ + slackId: existingSlackId, + teamId, + userName: "ignored", + }); + expect(result).not.toBeNull(); + expect(result!.slackId).toBe(existingSlackId); + expect(result!.userName).toBe("existing"); + }); + + it("should create new user if they don't exist", async () => { + const client = createTestClient("test-admin-key"); + const result = await client.slack.getOrCreateUser({ + slackId: newSlackId, + teamId, + userName: "newuser", + email: "new@example.com", + }); + expect(result).not.toBeNull(); + expect(result!.slackId).toBe(newSlackId); + expect(result!.userName).toBe("newuser"); + }); + }); + + describe("upsertUser", () => { + const newSlackId = `U${uniqueId()}`; + + afterAll(async () => { + await db + .delete(schema.slackUsers) + .where(eq(schema.slackUsers.slackId, newSlackId)); + }); + + it("should create a new slack user", async () => { + const client = createTestClient("test-admin-key"); + const result = await client.slack.upsertUser({ + slackId: newSlackId, + userName: "newuser", + email: "new@example.com", + teamId, + isAdmin: true, + isOwner: false, + isBot: false, + }); + + expect(result.success).toBe(true); + expect(result.action).toBe("created"); + + const user = await client.slack.getUserBySlackId({ + slackId: newSlackId, + teamId, + }); + expect(user?.userName).toBe("newuser"); + expect(user?.isAdmin).toBe(true); + }); + + it("should update an existing slack user", async () => { + const client = createTestClient("test-admin-key"); + const result = await client.slack.upsertUser({ + slackId: newSlackId, + userName: "updateduser", + teamId, + isAdmin: false, + isOwner: false, + isBot: false, + }); + + expect(result.success).toBe(true); + expect(result.action).toBe("updated"); + + const user = await client.slack.getUserBySlackId({ + slackId: newSlackId, + teamId, + }); + expect(user?.userName).toBe("updateduser"); + expect(user?.isAdmin).toBe(false); + }); + }); +}); diff --git a/packages/api/src/router/slack.ts b/packages/api/src/router/slack.ts new file mode 100644 index 00000000..b4ecaf20 --- /dev/null +++ b/packages/api/src/router/slack.ts @@ -0,0 +1,1219 @@ +import { z } from "zod"; + +import { and, eq, ilike, inArray, isNotNull, or, schema } from "@acme/db"; +import { SlackSettingsSchema, SlackUserUpsertSchema } from "@acme/validators"; + +import { apiKeyProcedure, withSessionAndDb } from "../shared"; + +const publicProcedure = withSessionAndDb; + +export const slackRouter = { + getSpace: publicProcedure + .input(z.object({ teamId: z.string() })) + .route({ + method: "GET", + path: "/space", + tags: ["slack"], + summary: "Get Slack space settings", + description: + "Retrieve settings and tokens for a specific Slack workspace", + }) + .handler(async ({ context: ctx, input }) => { + const [space] = await ctx.db + .select() + .from(schema.slackSpaces) + .where(eq(schema.slackSpaces.teamId, input.teamId)); + return space ?? null; + }), + + updateSpaceSettings: apiKeyProcedure + .input( + z.object({ + teamId: z.string(), + settings: SlackSettingsSchema, + }), + ) + .route({ + method: "PATCH", + path: "/space/settings", + tags: ["slack"], + summary: "Update Slack space settings", + description: "Update settings for a specific Slack workspace", + }) + .handler(async ({ context: ctx, input }) => { + const [space] = await ctx.db + .select() + .from(schema.slackSpaces) + .where(eq(schema.slackSpaces.teamId, input.teamId)); + + if (!space) { + throw new Error("Slack space not found"); + } + + const updatedSettings = { + ...(space.settings as Record), + ...input.settings, + }; + + await ctx.db + .update(schema.slackSpaces) + .set({ settings: updatedSettings }) + .where(eq(schema.slackSpaces.teamId, input.teamId)); + + return { success: true }; + }), + + getUserBySlackId: publicProcedure + .input( + z.object({ + slackId: z.string(), + teamId: z.string(), + }), + ) + .route({ + method: "GET", + path: "/user", + tags: ["slack"], + summary: "Get user by Slack ID", + description: "Retrieve a user record using their Slack ID and team ID", + }) + .handler(async ({ context: ctx, input }) => { + // Find the slack user for this specific team + const [slackUser] = await ctx.db + .select() + .from(schema.slackUsers) + .where( + and( + eq(schema.slackUsers.slackId, input.slackId), + eq(schema.slackUsers.slackTeamId, input.teamId), + ), + ); + + if (!slackUser) return null; + + // Also get the user info from users table if it exists + if (slackUser.userId) { + const [user] = await ctx.db + .select() + .from(schema.users) + .where(eq(schema.users.id, slackUser.userId)); + return { ...slackUser, user }; + } + + return slackUser; + }), + + upsertUser: apiKeyProcedure + .input(SlackUserUpsertSchema) + .route({ + method: "PUT", + path: "/user", + tags: ["slack"], + summary: "Upsert Slack user", + description: "Create or update a Slack user record", + }) + .handler(async ({ context: ctx, input }) => { + const [existing] = await ctx.db + .select() + .from(schema.slackUsers) + .where(eq(schema.slackUsers.slackId, input.slackId)); + + if (existing) { + await ctx.db + .update(schema.slackUsers) + .set({ + userName: input.userName, + email: input.email ?? existing.email, + userId: input.userId ?? existing.userId, + isAdmin: input.isAdmin, + isOwner: input.isOwner, + isBot: input.isBot, + }) + .where(eq(schema.slackUsers.slackId, input.slackId)); + return { success: true, action: "updated" }; + } + + await ctx.db.insert(schema.slackUsers).values({ + slackId: input.slackId, + userName: input.userName, + email: input.email ?? "", + userId: input.userId, + slackTeamId: input.teamId, + isAdmin: input.isAdmin, + isOwner: input.isOwner, + isBot: input.isBot, + }); + + return { success: true, action: "created" }; + }), + + getOrCreateSpace: apiKeyProcedure + .input( + z.object({ + teamId: z.string(), + workspaceName: z.string().optional(), + botToken: z.string().optional(), + }), + ) + .route({ + method: "POST", + path: "/get-or-create-space", + tags: ["slack"], + summary: "Get or create Slack space", + description: + "Retrieve slack space settings or create a new record if it doesn't exist", + }) + .handler(async ({ context: ctx, input }) => { + const [space] = await ctx.db + .select() + .from(schema.slackSpaces) + .where(eq(schema.slackSpaces.teamId, input.teamId)); + + if (space) { + return space; + } + + const [newSpace] = await ctx.db + .insert(schema.slackSpaces) + .values({ + teamId: input.teamId, + workspaceName: input.workspaceName ?? null, + botToken: input.botToken ?? null, + settings: {}, + }) + .returning(); + + return newSpace; + }), + + getOrCreateUser: apiKeyProcedure + .input( + z.object({ + slackId: z.string(), + teamId: z.string(), + userName: z.string(), + email: z.string().optional(), + isAdmin: z.boolean().optional(), + isOwner: z.boolean().optional(), + isBot: z.boolean().optional(), + avatarUrl: z.string().optional(), + }), + ) + .route({ + method: "POST", + path: "/get-or-create-user", + tags: ["slack"], + summary: "Get or create Slack user", + description: + "Retrieve a Slack user record or create a new one if it doesn't exist", + }) + .handler(async ({ context: ctx, input }) => { + const [existing] = await ctx.db + .select() + .from(schema.slackUsers) + .where(eq(schema.slackUsers.slackId, input.slackId)); + + if (existing) { + return existing; + } + + const [newUser] = await ctx.db + .insert(schema.slackUsers) + .values({ + slackId: input.slackId, + userName: input.userName, + email: input.email ?? "", + slackTeamId: input.teamId, + isAdmin: input.isAdmin ?? false, + isOwner: input.isOwner ?? false, + isBot: input.isBot ?? false, + avatarUrl: input.avatarUrl ?? null, + }) + .returning(); + + return newUser; + }), + + /** + * Get or create a Slack user with a guaranteed linked F3 user. + * If the Slack user doesn't exist, creates it. + * If the F3 user doesn't exist for the email, creates it. + * Always returns a Slack user with a valid userId linking to an F3 user. + */ + getOrCreateLinkedUser: apiKeyProcedure + .input( + z.object({ + slackId: z.string(), + teamId: z.string(), + userName: z.string(), + email: z.string().email(), + isAdmin: z.boolean().optional(), + isOwner: z.boolean().optional(), + isBot: z.boolean().optional(), + avatarUrl: z.string().optional(), + }), + ) + .route({ + method: "POST", + path: "/get-or-create-linked-user", + tags: ["slack"], + summary: "Get or create Slack user with linked F3 user", + description: + "Retrieve or create a Slack user record with a guaranteed linked F3 user. If no F3 user exists for the email, one will be created.", + }) + .handler(async ({ context: ctx, input }) => { + // Step 1: Find or create the Slack user for this specific team + let [slackUser] = await ctx.db + .select() + .from(schema.slackUsers) + .where( + and( + eq(schema.slackUsers.slackId, input.slackId), + eq(schema.slackUsers.slackTeamId, input.teamId), + ), + ); + + if (!slackUser) { + // Create the Slack user for this team (without userId for now) + [slackUser] = await ctx.db + .insert(schema.slackUsers) + .values({ + slackId: input.slackId, + userName: input.userName, + email: input.email, + slackTeamId: input.teamId, + isAdmin: input.isAdmin ?? false, + isOwner: input.isOwner ?? false, + isBot: input.isBot ?? false, + avatarUrl: input.avatarUrl ?? null, + }) + .returning(); + } + + // Step 2: Ensure F3 user exists and is linked + if (!slackUser!.userId) { + // Try to find an existing F3 user by email + let [f3User] = await ctx.db + .select() + .from(schema.users) + .where(eq(schema.users.email, input.email)); + + if (!f3User) { + // Create a new F3 user + // Parse the userName to extract first/last name if possible + const nameParts = input.userName.trim().split(/\s+/); + const firstName = nameParts[0] ?? input.userName; + const lastName = + nameParts.length > 1 ? nameParts.slice(1).join(" ") : null; + + // For bots or single-word names, use userName as f3Name + const f3Name = + (input.isBot ?? false) || nameParts.length === 1 + ? input.userName + : null; + + [f3User] = await ctx.db + .insert(schema.users) + .values({ + email: input.email, + firstName, + lastName, + f3Name, + avatarUrl: input.avatarUrl ?? null, + // emailVerified is null - user hasn't verified their email + // status defaults to 'active' + }) + .returning(); + } + + // Link the Slack user to the F3 user + await ctx.db + .update(schema.slackUsers) + .set({ userId: f3User!.id }) + .where(eq(schema.slackUsers.id, slackUser!.id)); + + // Update our local reference + slackUser = { ...slackUser!, userId: f3User!.id }; + } + + // Return the Slack user with guaranteed userId + return { + ...slackUser!, + // Explicitly include userId to satisfy the type + userId: slackUser!.userId!, + }; + }), + + /** + * Get the org associated with a Slack workspace. + * The org can be any type (region, area, etc.) depending on how the workspace was configured. + */ + getOrg: publicProcedure + .input(z.object({ teamId: z.string() })) + .route({ + method: "GET", + path: "/org", + tags: ["slack"], + summary: "Get org for Slack space", + description: + "Retrieve the org associated with a Slack workspace. Returns the org ID, name, type, and space details.", + }) + .handler(async ({ context: ctx, input }) => { + const [result] = await ctx.db + .select({ + org: { + id: schema.orgs.id, + name: schema.orgs.name, + orgType: schema.orgs.orgType, + parentId: schema.orgs.parentId, + }, + space: schema.slackSpaces, + }) + .from(schema.slackSpaces) + .innerJoin( + schema.orgsXSlackSpaces, + eq(schema.orgsXSlackSpaces.slackSpaceId, schema.slackSpaces.id), + ) + .innerJoin( + schema.orgs, + eq(schema.orgs.id, schema.orgsXSlackSpaces.orgId), + ) + .where(eq(schema.slackSpaces.teamId, input.teamId)); + + return result ?? null; + }), + + /** + * Check if a Slack user has a specific role on the region org associated with their Slack workspace. + * This checks the F3 role system (rolesXUsersXOrg), not Slack's admin/owner flags. + */ + checkUserRole: apiKeyProcedure + .input( + z.object({ + slackId: z.string(), + teamId: z.string(), + roleName: z + .enum(["user", "editor", "admin"]) + .optional() + .default("admin"), + }), + ) + .route({ + method: "GET", + path: "/check-role", + tags: ["slack"], + summary: "Check user role on region", + description: + "Check if a Slack user has a specific F3 role on the region org associated with their workspace", + }) + .handler(async ({ context: ctx, input }) => { + // First, find the slack user and their linked F3 user + const [slackUser] = await ctx.db + .select() + .from(schema.slackUsers) + .where(eq(schema.slackUsers.slackId, input.slackId)); + + if (!slackUser?.userId) { + return { + hasRole: false, + reason: "no-f3-user-linked", + userId: null, + orgId: null, + }; + } + + // Find the region org associated with this Slack workspace + const [regionResult] = await ctx.db + .select({ + orgId: schema.orgs.id, + orgName: schema.orgs.name, + }) + .from(schema.slackSpaces) + .innerJoin( + schema.orgsXSlackSpaces, + eq(schema.orgsXSlackSpaces.slackSpaceId, schema.slackSpaces.id), + ) + .innerJoin( + schema.orgs, + eq(schema.orgs.id, schema.orgsXSlackSpaces.orgId), + ) + .where(eq(schema.slackSpaces.teamId, input.teamId)); + + if (!regionResult) { + return { + hasRole: false, + reason: "no-region-linked", + userId: slackUser.userId, + orgId: null, + }; + } + + // Get the role ID for the requested role + const [role] = await ctx.db + .select() + .from(schema.roles) + .where(eq(schema.roles.name, input.roleName)); + + if (!role) { + return { + hasRole: false, + reason: "role-not-found", + userId: slackUser.userId, + orgId: regionResult.orgId, + }; + } + + // Check if the user has the role on this org + const [userRole] = await ctx.db + .select() + .from(schema.rolesXUsersXOrg) + .where( + and( + eq(schema.rolesXUsersXOrg.userId, slackUser.userId), + eq(schema.rolesXUsersXOrg.orgId, regionResult.orgId), + eq(schema.rolesXUsersXOrg.roleId, role.id), + ), + ); + + if (userRole) { + return { + hasRole: true, + reason: "direct-permission", + userId: slackUser.userId, + orgId: regionResult.orgId, + roleName: input.roleName, + }; + } + + // Check if user has admin role on any ancestor org (region -> sector -> area -> nation) + // This allows nation/area/sector admins to manage regions + const [orgHierarchy] = await ctx.db + .select({ + parentId: schema.orgs.parentId, + }) + .from(schema.orgs) + .where(eq(schema.orgs.id, regionResult.orgId)); + + const ancestorIds: number[] = []; + let currentParentId = orgHierarchy?.parentId; + + // Walk up the org hierarchy + while (currentParentId) { + ancestorIds.push(currentParentId); + const [parent] = await ctx.db + .select({ parentId: schema.orgs.parentId }) + .from(schema.orgs) + .where(eq(schema.orgs.id, currentParentId)); + currentParentId = parent?.parentId; + } + + if (ancestorIds.length > 0) { + // Check if user has admin role on any ancestor org + const [adminRole] = await ctx.db + .select() + .from(schema.roles) + .where(eq(schema.roles.name, "admin")); + + if (adminRole) { + for (const ancestorId of ancestorIds) { + const [ancestorRole] = await ctx.db + .select() + .from(schema.rolesXUsersXOrg) + .where( + and( + eq(schema.rolesXUsersXOrg.userId, slackUser.userId), + eq(schema.rolesXUsersXOrg.orgId, ancestorId), + eq(schema.rolesXUsersXOrg.roleId, adminRole.id), + ), + ); + + if (ancestorRole) { + return { + hasRole: true, + reason: "ancestor-admin", + userId: slackUser.userId, + orgId: ancestorId, + roleName: "admin", + }; + } + } + } + } + + return { + hasRole: false, + reason: "no-permission", + userId: slackUser.userId, + orgId: regionResult.orgId, + }; + }), + + /** + * Get all F3 roles for a Slack user on the region org. + * Returns role names the user has on the region and any ancestor orgs. + */ + getUserRoles: apiKeyProcedure + .input( + z.object({ + slackId: z.string(), + teamId: z.string(), + }), + ) + .route({ + method: "GET", + path: "/user-roles", + tags: ["slack"], + summary: "Get user roles on region", + description: + "Get all F3 roles a Slack user has on the region org and its ancestors", + }) + .handler(async ({ context: ctx, input }) => { + // First, find the slack user and their linked F3 user + const [slackUser] = await ctx.db + .select() + .from(schema.slackUsers) + .where(eq(schema.slackUsers.slackId, input.slackId)); + + if (!slackUser?.userId) { + return { + roles: [], + userId: null, + regionOrgId: null, + }; + } + + // Find the region org associated with this Slack workspace + const [regionResult] = await ctx.db + .select({ + orgId: schema.orgs.id, + orgName: schema.orgs.name, + }) + .from(schema.slackSpaces) + .innerJoin( + schema.orgsXSlackSpaces, + eq(schema.orgsXSlackSpaces.slackSpaceId, schema.slackSpaces.id), + ) + .innerJoin( + schema.orgs, + eq(schema.orgs.id, schema.orgsXSlackSpaces.orgId), + ) + .where(eq(schema.slackSpaces.teamId, input.teamId)); + + if (!regionResult) { + return { + roles: [], + userId: slackUser.userId, + regionOrgId: null, + }; + } + + // Get org hierarchy (region + ancestors) + const orgIds: number[] = [regionResult.orgId]; + let currentParentId = ( + await ctx.db + .select({ parentId: schema.orgs.parentId }) + .from(schema.orgs) + .where(eq(schema.orgs.id, regionResult.orgId)) + )[0]?.parentId; + + while (currentParentId) { + orgIds.push(currentParentId); + const [parent] = await ctx.db + .select({ parentId: schema.orgs.parentId }) + .from(schema.orgs) + .where(eq(schema.orgs.id, currentParentId)); + currentParentId = parent?.parentId; + } + + // Get all roles for this user on these orgs + const userRoles = await ctx.db + .select({ + orgId: schema.rolesXUsersXOrg.orgId, + orgName: schema.orgs.name, + roleName: schema.roles.name, + }) + .from(schema.rolesXUsersXOrg) + .innerJoin( + schema.roles, + eq(schema.roles.id, schema.rolesXUsersXOrg.roleId), + ) + .innerJoin( + schema.orgs, + eq(schema.orgs.id, schema.rolesXUsersXOrg.orgId), + ) + .where(eq(schema.rolesXUsersXOrg.userId, slackUser.userId)); + + // Filter to only roles on the region or its ancestors + const relevantRoles = userRoles.filter((r) => orgIds.includes(r.orgId)); + + // Determine effective admin status: + // User is admin if they have admin role on region or any ancestor + const isAdmin = relevantRoles.some((r) => r.roleName === "admin"); + const isEditor = relevantRoles.some( + (r) => r.roleName === "editor" || r.roleName === "admin", + ); + + return { + roles: relevantRoles, + userId: slackUser.userId, + regionOrgId: regionResult.orgId, + isAdmin, + isEditor, + }; + }), + + /** + * Connect a Slack workspace to an F3 org (region, area, etc.) + * Creates or finds the org and links it to the Slack space via orgsXSlackSpaces. + */ + connectSpaceToOrg: apiKeyProcedure + .input( + z.object({ + teamId: z.string(), + orgId: z.number().optional(), + newOrgName: z.string().optional(), + orgType: z + .enum(["ao", "region", "area", "sector", "nation"]) + .optional() + .default("region"), + }), + ) + .route({ + method: "POST", + path: "/connect-space-to-org", + tags: ["slack"], + summary: "Connect Slack space to an F3 org", + description: + "Link a Slack workspace to an existing org or create a new one and link it", + }) + .handler(async ({ context: ctx, input }) => { + // Find the slack space + const [space] = await ctx.db + .select() + .from(schema.slackSpaces) + .where(eq(schema.slackSpaces.teamId, input.teamId)); + + if (!space) { + throw new Error("Slack space not found"); + } + + // Check if already connected + const [existingLink] = await ctx.db + .select() + .from(schema.orgsXSlackSpaces) + .where(eq(schema.orgsXSlackSpaces.slackSpaceId, space.id)); + + if (existingLink) { + throw new Error("Slack space is already connected to an org"); + } + + let orgId: number; + + if (input.orgId) { + // Use existing org + const [org] = await ctx.db + .select() + .from(schema.orgs) + .where(eq(schema.orgs.id, input.orgId)); + + if (!org) { + throw new Error("Org not found"); + } + orgId = org.id; + } else if (input.newOrgName) { + // Create new org + const [newOrg] = await ctx.db + .insert(schema.orgs) + .values({ + name: input.newOrgName, + orgType: input.orgType, + isActive: true, + }) + .returning(); + + if (!newOrg) { + throw new Error("Failed to create org"); + } + orgId = newOrg.id; + } else { + throw new Error("Either orgId or newOrgName must be provided"); + } + + // Create the link + await ctx.db.insert(schema.orgsXSlackSpaces).values({ + orgId, + slackSpaceId: space.id, + }); + + return { success: true, orgId }; + }), + + /** + * Get admin users for an org that have linked Slack accounts. + * Returns userId + slackId pairs for populating the admin multi-user select. + */ + getOrgAdmins: apiKeyProcedure + .input( + z.object({ + orgId: z.coerce.number(), + teamId: z.string(), + }), + ) + .route({ + method: "GET", + path: "/org-admins", + tags: ["slack"], + summary: "Get org admin users with Slack IDs", + description: + "Get admin users for an org that have linked Slack accounts in the given team", + }) + .handler(async ({ context: ctx, input }) => { + // Get the admin role ID + const [adminRole] = await ctx.db + .select() + .from(schema.roles) + .where(eq(schema.roles.name, "admin")); + + if (!adminRole) { + return { admins: [] }; + } + + // Join rolesXUsersXOrg -> slackUsers to get admin users with Slack IDs + const admins = await ctx.db + .select({ + userId: schema.rolesXUsersXOrg.userId, + slackId: schema.slackUsers.slackId, + }) + .from(schema.rolesXUsersXOrg) + .innerJoin( + schema.slackUsers, + and( + eq(schema.slackUsers.userId, schema.rolesXUsersXOrg.userId), + eq(schema.slackUsers.slackTeamId, input.teamId), + ), + ) + .where( + and( + eq(schema.rolesXUsersXOrg.orgId, input.orgId), + eq(schema.rolesXUsersXOrg.roleId, adminRole.id), + isNotNull(schema.slackUsers.slackId), + ), + ); + + return { admins }; + }), + + /** + * Replace the full admin list for an org. + * Only removes admin roles for users that have Slack accounts in the given team, + * preserving admins added through other channels (e.g., Maps UI). + */ + setOrgAdmins: apiKeyProcedure + .input( + z.object({ + orgId: z.number(), + userIds: z.array(z.number()), + teamId: z.string(), + }), + ) + .route({ + method: "POST", + path: "/set-org-admins", + tags: ["slack"], + summary: "Replace org admin list", + description: + "Replace admin role assignments for an org, scoped to slack-linked users", + }) + .handler(async ({ context: ctx, input }) => { + // Get the admin role ID + const [adminRole] = await ctx.db + .select() + .from(schema.roles) + .where(eq(schema.roles.name, "admin")); + + if (!adminRole) { + throw new Error("Admin role not found"); + } + + // Find existing admin users that have Slack accounts in this team + const existingSlackAdmins = await ctx.db + .select({ + userId: schema.rolesXUsersXOrg.userId, + }) + .from(schema.rolesXUsersXOrg) + .innerJoin( + schema.slackUsers, + and( + eq(schema.slackUsers.userId, schema.rolesXUsersXOrg.userId), + eq(schema.slackUsers.slackTeamId, input.teamId), + ), + ) + .where( + and( + eq(schema.rolesXUsersXOrg.orgId, input.orgId), + eq(schema.rolesXUsersXOrg.roleId, adminRole.id), + isNotNull(schema.slackUsers.slackId), + ), + ); + + const existingUserIds = existingSlackAdmins.map((a) => a.userId); + + // Delete existing slack-linked admin assignments + if (existingUserIds.length > 0) { + await ctx.db + .delete(schema.rolesXUsersXOrg) + .where( + and( + eq(schema.rolesXUsersXOrg.orgId, input.orgId), + eq(schema.rolesXUsersXOrg.roleId, adminRole.id), + inArray(schema.rolesXUsersXOrg.userId, existingUserIds), + ), + ); + } + + // Insert new admin assignments + if (input.userIds.length > 0) { + const values = input.userIds + .filter((uid) => uid != null) + .map((userId) => ({ + userId, + orgId: input.orgId, + roleId: adminRole.id, + })); + + if (values.length > 0) { + await ctx.db + .insert(schema.rolesXUsersXOrg) + .values(values) + .onConflictDoNothing(); + } + } + + return { success: true }; + }), + + /** + * Typeahead search for users by f3Name, firstName, or lastName. + * Used for Slack external_select elements. + */ + searchUsers: apiKeyProcedure + .input( + z.object({ + searchTerm: z.string().min(1).describe("Search query for user name"), + limit: z + .number() + .min(1) + .max(30) + .default(30) + .optional() + .describe("Maximum number of results to return"), + }), + ) + .route({ + method: "GET", + path: "/search/users", + tags: ["slack"], + summary: "Search users for typeahead", + description: + "Search for F3 users by f3Name, firstName, or lastName. Returns up to 30 results for use in Slack external select elements.", + }) + .handler(async ({ context: ctx, input }) => { + const searchPattern = `%${input.searchTerm}%`; + const limit = input.limit ?? 30; + + // Alias for the home region org join + const homeRegion = schema.orgs; + + const users = await ctx.db + .select({ + id: schema.users.id, + f3Name: schema.users.f3Name, + firstName: schema.users.firstName, + lastName: schema.users.lastName, + homeRegionName: homeRegion.name, + }) + .from(schema.users) + .leftJoin(homeRegion, eq(schema.users.homeRegionId, homeRegion.id)) + .where( + and( + eq(schema.users.status, "active"), + or( + ilike(schema.users.f3Name, searchPattern), + ilike(schema.users.firstName, searchPattern), + ilike(schema.users.lastName, searchPattern), + ), + ), + ) + .limit(limit); + + return users; + }), + + /** + * Get users by their IDs with home region info. + * Used for displaying downrange PAX in backblasts. + */ + getUsersByIds: apiKeyProcedure + .input( + z.object({ + userIds: z + .array(z.number()) + .min(1) + .max(50) + .describe("Array of user IDs to fetch"), + }), + ) + .route({ + method: "POST", + path: "/users/by-ids", + tags: ["slack"], + summary: "Get users by IDs", + description: + "Fetch F3 users by their IDs, including home region name. Used for displaying downrange PAX in backblasts.", + }) + .handler(async ({ context: ctx, input }) => { + const homeRegion = schema.orgs; + + const users = await ctx.db + .select({ + id: schema.users.id, + f3Name: schema.users.f3Name, + firstName: schema.users.firstName, + lastName: schema.users.lastName, + homeRegionName: homeRegion.name, + }) + .from(schema.users) + .leftJoin(homeRegion, eq(schema.users.homeRegionId, homeRegion.id)) + .where(inArray(schema.users.id, input.userIds)); + + return users; + }), + + /** + * Typeahead search for regions by name. + * Used for Slack external_select elements. + */ + searchRegions: apiKeyProcedure + .input( + z.object({ + searchTerm: z.string().min(1).describe("Search query for region name"), + limit: z + .number() + .min(1) + .max(30) + .default(30) + .optional() + .describe("Maximum number of results to return"), + }), + ) + .route({ + method: "GET", + path: "/search/regions", + tags: ["slack"], + summary: "Search regions for typeahead", + description: + "Search for F3 regions by name. Returns up to 30 active regions for use in Slack external select elements.", + }) + .handler(async ({ context: ctx, input }) => { + const searchPattern = `%${input.searchTerm}%`; + const limit = input.limit ?? 30; + + const regions = await ctx.db + .select({ + id: schema.orgs.id, + name: schema.orgs.name, + }) + .from(schema.orgs) + .where( + and( + eq(schema.orgs.orgType, "region"), + eq(schema.orgs.isActive, true), + ilike(schema.orgs.name, searchPattern), + ), + ) + .limit(limit); + + return regions; + }), + + /** + * Get the current user's full F3 profile. + * Includes home region information for populating the user profile form. + */ + getSelfProfile: apiKeyProcedure + .input( + z.object({ + teamId: z.string().describe("Slack workspace ID"), + slackId: z.string().describe("Slack user ID"), + }), + ) + .route({ + method: "GET", + path: "/user/self-profile", + tags: ["slack"], + summary: "Get own F3 profile", + description: + "Get the current user's F3 profile including home region information. Used to populate the user profile edit form.", + }) + .handler(async ({ context: ctx, input }) => { + // Find the slack user + const [slackUser] = await ctx.db + .select() + .from(schema.slackUsers) + .where( + and( + eq(schema.slackUsers.slackId, input.slackId), + eq(schema.slackUsers.slackTeamId, input.teamId), + ), + ); + + if (!slackUser) { + throw new Error("Slack user not found"); + } + + if (!slackUser.userId) { + throw new Error("Slack user is not linked to an F3 user"); + } + + // Get the F3 user with home region info + const homeRegion = schema.orgs; + const [userData] = await ctx.db + .select({ + id: schema.users.id, + f3Name: schema.users.f3Name, + firstName: schema.users.firstName, + lastName: schema.users.lastName, + email: schema.users.email, + avatarUrl: schema.users.avatarUrl, + homeRegionId: schema.users.homeRegionId, + homeRegionName: homeRegion.name, + emergencyContact: schema.users.emergencyContact, + emergencyPhone: schema.users.emergencyPhone, + emergencyNotes: schema.users.emergencyNotes, + meta: schema.users.meta, + }) + .from(schema.users) + .leftJoin(homeRegion, eq(schema.users.homeRegionId, homeRegion.id)) + .where(eq(schema.users.id, slackUser.userId)); + + if (!userData) { + throw new Error("F3 user not found"); + } + + return userData; + }), + + /** + * Update the current user's own F3 profile. + * This allows users to update their profile without admin rights. + * Only updates non-role fields for the linked users table entry. + */ + updateSelfProfile: apiKeyProcedure + .input( + z.object({ + teamId: z.string().describe("Slack workspace ID"), + slackId: z.string().describe("Slack user ID"), + f3Name: z.string().optional().describe("User's F3 name"), + homeRegionId: z + .number() + .optional() + .describe("User's home region org ID"), + avatarUrl: z.string().optional().describe("User's avatar URL"), + emergencyContact: z + .string() + .optional() + .describe("Emergency contact name"), + emergencyPhone: z + .string() + .optional() + .describe("Emergency contact phone"), + emergencyNotes: z + .string() + .optional() + .describe("Emergency contact notes"), + meta: z.record(z.unknown()).optional().describe("Additional metadata"), + }), + ) + .route({ + method: "PUT", + path: "/user/self-profile", + tags: ["slack"], + summary: "Update own profile", + description: + "Update the current user's F3 profile. This allows users to update their own profile information (name, home region, emergency contacts, avatar) without needing admin rights.", + }) + .handler(async ({ context: ctx, input }) => { + // Find the slack user for this team + const [slackUser] = await ctx.db + .select() + .from(schema.slackUsers) + .where( + and( + eq(schema.slackUsers.slackId, input.slackId), + eq(schema.slackUsers.slackTeamId, input.teamId), + ), + ); + + if (!slackUser) { + throw new Error("Slack user not found"); + } + + if (!slackUser.userId) { + throw new Error("Slack user is not linked to an F3 user"); + } + + // Build update object with only provided fields + const updateData: Partial<{ + f3Name: string; + homeRegionId: number; + avatarUrl: string; + emergencyContact: string; + emergencyPhone: string; + emergencyNotes: string; + meta: Record; + }> = {}; + + if (input.f3Name !== undefined) { + updateData.f3Name = input.f3Name; + } + if (input.homeRegionId !== undefined) { + updateData.homeRegionId = input.homeRegionId; + } + if (input.avatarUrl !== undefined) { + updateData.avatarUrl = input.avatarUrl; + } + if (input.emergencyContact !== undefined) { + updateData.emergencyContact = input.emergencyContact; + } + if (input.emergencyPhone !== undefined) { + updateData.emergencyPhone = input.emergencyPhone; + } + if (input.emergencyNotes !== undefined) { + updateData.emergencyNotes = input.emergencyNotes; + } + if (input.meta !== undefined) { + // Merge with existing meta + const [existingUser] = await ctx.db + .select({ meta: schema.users.meta }) + .from(schema.users) + .where(eq(schema.users.id, slackUser.userId)); + + updateData.meta = { + ...(existingUser?.meta as Record | undefined), + ...input.meta, + }; + } + + if (Object.keys(updateData).length === 0) { + return { success: true, message: "No fields to update" }; + } + + // Update the linked F3 user + await ctx.db + .update(schema.users) + .set(updateData) + .where(eq(schema.users.id, slackUser.userId)); + + return { success: true }; + }), +}; From 552e3f7652751ed35cf4c929daea57dcd5a52a97 Mon Sep 17 00:00:00 2001 From: Evan Petzoldt Date: Sat, 6 Jun 2026 07:48:05 -0500 Subject: [PATCH 2/7] fix(api): addressed coderabbit suggestion on testing slackid/teamid duplicate cases --- packages/api/src/router/slack.test.ts | 273 ++++++++++++++++++++++++++ packages/api/src/router/slack.ts | 35 +++- 2 files changed, 303 insertions(+), 5 deletions(-) diff --git a/packages/api/src/router/slack.test.ts b/packages/api/src/router/slack.test.ts index 9772e1a9..6769f3d2 100644 --- a/packages/api/src/router/slack.test.ts +++ b/packages/api/src/router/slack.test.ts @@ -311,4 +311,277 @@ describe("Slack Router", () => { expect(user?.isAdmin).toBe(false); }); }); + + describe("composite slackId and teamId scoping", () => { + const sharedSlackId = `U${uniqueId()}`; + const teamA = uniqueId(); + const teamB = uniqueId(); + + let orgAId: number; + let orgBId: number; + let slackSpaceAId: number; + let slackSpaceBId: number; + let userAId: number; + let userBId: number; + + beforeAll(async () => { + const [adminRole] = await db + .select({ id: schema.roles.id }) + .from(schema.roles) + .where(eq(schema.roles.name, "admin")); + + if (!adminRole) { + throw new Error("Admin role not found"); + } + + const [orgA] = await db + .insert(schema.orgs) + .values({ + name: `Composite Org A-${teamA}`, + orgType: "region", + isActive: true, + }) + .returning(); + const [orgB] = await db + .insert(schema.orgs) + .values({ + name: `Composite Org B-${teamB}`, + orgType: "region", + isActive: true, + }) + .returning(); + + orgAId = orgA!.id; + orgBId = orgB!.id; + + const [spaceA] = await db + .insert(schema.slackSpaces) + .values({ + teamId: teamA, + workspaceName: "Composite Workspace A", + settings: { + welcome_dm_enable: true, + welcome_dm_template: "Hello A", + }, + }) + .returning(); + const [spaceB] = await db + .insert(schema.slackSpaces) + .values({ + teamId: teamB, + workspaceName: "Composite Workspace B", + settings: { + welcome_dm_enable: true, + welcome_dm_template: "Hello B", + }, + }) + .returning(); + + slackSpaceAId = spaceA!.id; + slackSpaceBId = spaceB!.id; + + await db.insert(schema.orgsXSlackSpaces).values([ + { orgId: orgAId, slackSpaceId: slackSpaceAId }, + { orgId: orgBId, slackSpaceId: slackSpaceBId }, + ]); + + const [userA] = await db + .insert(schema.users) + .values({ + email: `composite-a-${uniqueId()}@example.com`, + firstName: "Composite", + lastName: "A", + f3Name: "Composite A", + }) + .returning(); + const [userB] = await db + .insert(schema.users) + .values({ + email: `composite-b-${uniqueId()}@example.com`, + firstName: "Composite", + lastName: "B", + f3Name: "Composite B", + }) + .returning(); + + userAId = userA!.id; + userBId = userB!.id; + + await db.insert(schema.slackUsers).values([ + { + slackId: sharedSlackId, + userName: "team-a-user", + email: "team-a@example.com", + slackTeamId: teamA, + userId: userAId, + isAdmin: false, + isOwner: false, + isBot: false, + }, + { + slackId: sharedSlackId, + userName: "team-b-user", + email: "team-b@example.com", + slackTeamId: teamB, + userId: userBId, + isAdmin: false, + isOwner: false, + isBot: false, + }, + ]); + + await db.insert(schema.rolesXUsersXOrg).values({ + userId: userAId, + orgId: orgAId, + roleId: adminRole.id, + }); + }); + + afterAll(async () => { + await db + .delete(schema.rolesXUsersXOrg) + .where(eq(schema.rolesXUsersXOrg.userId, userAId)); + await db + .delete(schema.slackUsers) + .where(eq(schema.slackUsers.slackId, sharedSlackId)); + await db + .delete(schema.orgsXSlackSpaces) + .where( + or( + eq(schema.orgsXSlackSpaces.slackSpaceId, slackSpaceAId), + eq(schema.orgsXSlackSpaces.slackSpaceId, slackSpaceBId), + ), + ); + await db + .delete(schema.slackSpaces) + .where( + or( + eq(schema.slackSpaces.id, slackSpaceAId), + eq(schema.slackSpaces.id, slackSpaceBId), + ), + ); + await db + .delete(schema.users) + .where(or(eq(schema.users.id, userAId), eq(schema.users.id, userBId))); + await db + .delete(schema.orgs) + .where(or(eq(schema.orgs.id, orgAId), eq(schema.orgs.id, orgBId))); + }); + + it("scopes getUserBySlackId to the requested team", async () => { + const client = createTestClient(); + + const teamAUser = await client.slack.getUserBySlackId({ + slackId: sharedSlackId, + teamId: teamA, + }); + const teamBUser = await client.slack.getUserBySlackId({ + slackId: sharedSlackId, + teamId: teamB, + }); + + expect(teamAUser).not.toBeNull(); + expect(teamAUser?.slackTeamId).toBe(teamA); + expect(teamAUser?.userName).toBe("team-a-user"); + expect(teamAUser?.email).toBe("team-a@example.com"); + + expect(teamBUser).not.toBeNull(); + expect(teamBUser?.slackTeamId).toBe(teamB); + expect(teamBUser?.userName).toBe("team-b-user"); + expect(teamBUser?.email).toBe("team-b@example.com"); + }); + + it("scopes getOrCreateUser to the requested team", async () => { + const client = createTestClient("test-admin-key"); + + const result = await client.slack.getOrCreateUser({ + slackId: sharedSlackId, + teamId: teamB, + userName: "ignored-user", + email: "ignored@example.com", + }); + + expect(result).not.toBeNull(); + expect(result?.slackTeamId).toBe(teamB); + expect(result?.userName).toBe("team-b-user"); + expect(result?.email).toBe("team-b@example.com"); + }); + + it("scopes upsertUser to the requested team", async () => { + const client = createTestClient("test-admin-key"); + + const result = await client.slack.upsertUser({ + slackId: sharedSlackId, + userName: "team-b-updated", + email: "team-b-updated@example.com", + teamId: teamB, + isAdmin: false, + isOwner: false, + isBot: false, + }); + + expect(result.success).toBe(true); + expect(result.action).toBe("updated"); + + const teamAUser = await client.slack.getUserBySlackId({ + slackId: sharedSlackId, + teamId: teamA, + }); + const teamBUser = await client.slack.getUserBySlackId({ + slackId: sharedSlackId, + teamId: teamB, + }); + + expect(teamAUser?.userName).toBe("team-a-user"); + expect(teamAUser?.email).toBe("team-a@example.com"); + expect(teamBUser?.userName).toBe("team-b-updated"); + expect(teamBUser?.email).toBe("team-b-updated@example.com"); + }); + + it("scopes role lookups to the requested team", async () => { + const client = createTestClient("test-admin-key"); + + const teamAHasRole = await client.slack.checkUserRole({ + slackId: sharedSlackId, + teamId: teamA, + }); + const teamBHasRole = await client.slack.checkUserRole({ + slackId: sharedSlackId, + teamId: teamB, + }); + + expect(teamAHasRole.hasRole).toBe(true); + expect(teamAHasRole.userId).toBe(userAId); + expect(teamAHasRole.orgId).toBe(orgAId); + + expect(teamBHasRole.hasRole).toBe(false); + expect(teamBHasRole.userId).toBe(userBId); + expect(teamBHasRole.orgId).toBe(orgBId); + + const teamARoles = await client.slack.getUserRoles({ + slackId: sharedSlackId, + teamId: teamA, + }); + const teamBRoles = await client.slack.getUserRoles({ + slackId: sharedSlackId, + teamId: teamB, + }); + + expect(teamARoles.userId).toBe(userAId); + expect(teamARoles.regionOrgId).toBe(orgAId); + expect(teamARoles.isAdmin).toBe(true); + expect(teamARoles.isEditor).toBe(true); + expect(teamARoles.roles).toHaveLength(1); + expect(teamARoles.roles[0]).toMatchObject({ + orgId: orgAId, + roleName: "admin", + }); + + expect(teamBRoles.userId).toBe(userBId); + expect(teamBRoles.regionOrgId).toBe(orgBId); + expect(teamBRoles.isAdmin).toBe(false); + expect(teamBRoles.isEditor).toBe(false); + expect(teamBRoles.roles).toHaveLength(0); + }); + }); }); diff --git a/packages/api/src/router/slack.ts b/packages/api/src/router/slack.ts index b4ecaf20..3aa457fc 100644 --- a/packages/api/src/router/slack.ts +++ b/packages/api/src/router/slack.ts @@ -116,7 +116,12 @@ export const slackRouter = { const [existing] = await ctx.db .select() .from(schema.slackUsers) - .where(eq(schema.slackUsers.slackId, input.slackId)); + .where( + and( + eq(schema.slackUsers.slackId, input.slackId), + eq(schema.slackUsers.slackTeamId, input.teamId), + ), + ); if (existing) { await ctx.db @@ -129,7 +134,12 @@ export const slackRouter = { isOwner: input.isOwner, isBot: input.isBot, }) - .where(eq(schema.slackUsers.slackId, input.slackId)); + .where( + and( + eq(schema.slackUsers.slackId, input.slackId), + eq(schema.slackUsers.slackTeamId, input.teamId), + ), + ); return { success: true, action: "updated" }; } @@ -211,7 +221,12 @@ export const slackRouter = { const [existing] = await ctx.db .select() .from(schema.slackUsers) - .where(eq(schema.slackUsers.slackId, input.slackId)); + .where( + and( + eq(schema.slackUsers.slackId, input.slackId), + eq(schema.slackUsers.slackTeamId, input.teamId), + ), + ); if (existing) { return existing; @@ -411,7 +426,12 @@ export const slackRouter = { const [slackUser] = await ctx.db .select() .from(schema.slackUsers) - .where(eq(schema.slackUsers.slackId, input.slackId)); + .where( + and( + eq(schema.slackUsers.slackId, input.slackId), + eq(schema.slackUsers.slackTeamId, input.teamId), + ), + ); if (!slackUser?.userId) { return { @@ -572,7 +592,12 @@ export const slackRouter = { const [slackUser] = await ctx.db .select() .from(schema.slackUsers) - .where(eq(schema.slackUsers.slackId, input.slackId)); + .where( + and( + eq(schema.slackUsers.slackId, input.slackId), + eq(schema.slackUsers.slackTeamId, input.teamId), + ), + ); if (!slackUser?.userId) { return { From 7e255ade24801c4ba884d0023da5e2cc09ce88c7 Mon Sep 17 00:00:00 2001 From: Evan Petzoldt Date: Sat, 6 Jun 2026 07:55:32 -0500 Subject: [PATCH 3/7] fix(api): more coderabbit fixes --- packages/api/src/router/slack.ts | 27 ++++++++++----------------- 1 file changed, 10 insertions(+), 17 deletions(-) diff --git a/packages/api/src/router/slack.ts b/packages/api/src/router/slack.ts index 3aa457fc..ae898aa4 100644 --- a/packages/api/src/router/slack.ts +++ b/packages/api/src/router/slack.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -import { and, eq, ilike, inArray, isNotNull, or, schema } from "@acme/db"; +import { and, eq, ilike, inArray, isNotNull, or, schema, sql } from "@acme/db"; import { SlackSettingsSchema, SlackUserUpsertSchema } from "@acme/validators"; import { apiKeyProcedure, withSessionAndDb } from "../shared"; @@ -8,7 +8,7 @@ import { apiKeyProcedure, withSessionAndDb } from "../shared"; const publicProcedure = withSessionAndDb; export const slackRouter = { - getSpace: publicProcedure + getSpace: apiKeyProcedure .input(z.object({ teamId: z.string() })) .route({ method: "GET", @@ -41,25 +41,18 @@ export const slackRouter = { description: "Update settings for a specific Slack workspace", }) .handler(async ({ context: ctx, input }) => { - const [space] = await ctx.db - .select() - .from(schema.slackSpaces) - .where(eq(schema.slackSpaces.teamId, input.teamId)); + const result = await ctx.db + .update(schema.slackSpaces) + .set({ + settings: sql`(COALESCE(${schema.slackSpaces.settings}::jsonb, '{}'::jsonb) || ${JSON.stringify(input.settings)}::jsonb)`, + }) + .where(eq(schema.slackSpaces.teamId, input.teamId)) + .returning({ teamId: schema.slackSpaces.teamId }); - if (!space) { + if (result.length === 0) { throw new Error("Slack space not found"); } - const updatedSettings = { - ...(space.settings as Record), - ...input.settings, - }; - - await ctx.db - .update(schema.slackSpaces) - .set({ settings: updatedSettings }) - .where(eq(schema.slackSpaces.teamId, input.teamId)); - return { success: true }; }), From d394f192595894fcb23460e523aed0a7f4fb567f Mon Sep 17 00:00:00 2001 From: Evan Petzoldt Date: Sat, 6 Jun 2026 09:18:15 -0500 Subject: [PATCH 4/7] fix(api): making more json updates atomic --- packages/api/src/router/slack.test.ts | 31 +++++++++++++++++++ packages/api/src/router/slack.ts | 44 +++++++++------------------ 2 files changed, 46 insertions(+), 29 deletions(-) diff --git a/packages/api/src/router/slack.test.ts b/packages/api/src/router/slack.test.ts index 6769f3d2..4000c5cb 100644 --- a/packages/api/src/router/slack.test.ts +++ b/packages/api/src/router/slack.test.ts @@ -140,6 +140,37 @@ describe("Slack Router", () => { .delete(schema.slackSpaces) .where(eq(schema.slackSpaces.id, result!.id)); }); + + it("should return a single canonical row under concurrent creates", async () => { + const newTeamId = uniqueId(); + const client = createTestClient("test-admin-key"); + + const [first, second] = await Promise.all([ + client.slack.getOrCreateSpace({ + teamId: newTeamId, + workspaceName: "Concurrent Space", + }), + client.slack.getOrCreateSpace({ + teamId: newTeamId, + workspaceName: "Concurrent Space", + }), + ]); + + expect(first).not.toBeNull(); + expect(second).not.toBeNull(); + expect(first!.id).toBe(second!.id); + + const rows = await db + .select({ id: schema.slackSpaces.id }) + .from(schema.slackSpaces) + .where(eq(schema.slackSpaces.teamId, newTeamId)); + + expect(rows).toHaveLength(1); + + await db + .delete(schema.slackSpaces) + .where(eq(schema.slackSpaces.teamId, newTeamId)); + }); }); describe("updateSpaceSettings", () => { diff --git a/packages/api/src/router/slack.ts b/packages/api/src/router/slack.ts index ae898aa4..30ee7add 100644 --- a/packages/api/src/router/slack.ts +++ b/packages/api/src/router/slack.ts @@ -167,15 +167,6 @@ export const slackRouter = { "Retrieve slack space settings or create a new record if it doesn't exist", }) .handler(async ({ context: ctx, input }) => { - const [space] = await ctx.db - .select() - .from(schema.slackSpaces) - .where(eq(schema.slackSpaces.teamId, input.teamId)); - - if (space) { - return space; - } - const [newSpace] = await ctx.db .insert(schema.slackSpaces) .values({ @@ -184,9 +175,19 @@ export const slackRouter = { botToken: input.botToken ?? null, settings: {}, }) + .onConflictDoNothing() .returning(); - return newSpace; + if (newSpace) { + return newSpace; + } + + const [space] = await ctx.db + .select() + .from(schema.slackSpaces) + .where(eq(schema.slackSpaces.teamId, input.teamId)); + + return space ?? null; }), getOrCreateUser: apiKeyProcedure @@ -1181,15 +1182,7 @@ export const slackRouter = { } // Build update object with only provided fields - const updateData: Partial<{ - f3Name: string; - homeRegionId: number; - avatarUrl: string; - emergencyContact: string; - emergencyPhone: string; - emergencyNotes: string; - meta: Record; - }> = {}; + const updateData: Record = {}; if (input.f3Name !== undefined) { updateData.f3Name = input.f3Name; @@ -1210,16 +1203,9 @@ export const slackRouter = { updateData.emergencyNotes = input.emergencyNotes; } if (input.meta !== undefined) { - // Merge with existing meta - const [existingUser] = await ctx.db - .select({ meta: schema.users.meta }) - .from(schema.users) - .where(eq(schema.users.id, slackUser.userId)); - - updateData.meta = { - ...(existingUser?.meta as Record | undefined), - ...input.meta, - }; + // Merge meta at the SQL layer so the update stays atomic with the + // rest of the profile fields and avoids lost updates under concurrency. + updateData.meta = sql`(COALESCE(${schema.users.meta}::jsonb, '{}'::jsonb) || ${JSON.stringify(input.meta)}::jsonb)`; } if (Object.keys(updateData).length === 0) { From 4a719315448697a70b600c628cb30e8cb023056e Mon Sep 17 00:00:00 2001 From: Evan Petzoldt Date: Sun, 7 Jun 2026 06:35:32 -0500 Subject: [PATCH 5/7] fix(api): fixed failing slack route test --- packages/api/src/router/slack.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/api/src/router/slack.ts b/packages/api/src/router/slack.ts index 30ee7add..52d8d0c5 100644 --- a/packages/api/src/router/slack.ts +++ b/packages/api/src/router/slack.ts @@ -8,7 +8,7 @@ import { apiKeyProcedure, withSessionAndDb } from "../shared"; const publicProcedure = withSessionAndDb; export const slackRouter = { - getSpace: apiKeyProcedure + getSpace: publicProcedure .input(z.object({ teamId: z.string() })) .route({ method: "GET", From 2c949026abc2a6b27b959b6aac5ee40877d6620a Mon Sep 17 00:00:00 2001 From: Evan Petzoldt Date: Sun, 7 Jun 2026 06:53:58 -0500 Subject: [PATCH 6/7] fix(api): added a test to apps/api so we can clear coverage --- apps/api/__tests__/docs-routes.test.ts | 94 ++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 apps/api/__tests__/docs-routes.test.ts diff --git a/apps/api/__tests__/docs-routes.test.ts b/apps/api/__tests__/docs-routes.test.ts new file mode 100644 index 00000000..a10b5c9f --- /dev/null +++ b/apps/api/__tests__/docs-routes.test.ts @@ -0,0 +1,94 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { apiReferenceMock, generateMock } = vi.hoisted(() => ({ + apiReferenceMock: vi.fn(() => () => new Response("openapi-ui")), + generateMock: vi.fn(async () => ({ + paths: { + "/v1/ping": { + get: {}, + post: {}, + }, + }, + components: {}, + })), +})); + +vi.mock("@acme/env", () => ({ + env: { + NEXT_PUBLIC_API_URL: "https://api.example.com", + }, +})); + +vi.mock("@acme/api", () => ({ + router: {}, +})); + +vi.mock("@scalar/nextjs-api-reference", () => ({ + ApiReference: apiReferenceMock, +})); + +vi.mock("@orpc/openapi", () => ({ + OpenAPIGenerator: vi.fn(() => ({ + generate: generateMock, + })), +})); + +vi.mock("@orpc/zod", () => ({ + ZodToJsonSchemaConverter: vi.fn(), +})); + +import { GET as getDocs } from "../src/app/docs/route"; +import { GET as getOpenApiJson } from "../src/app/docs/openapi.json/route"; + +describe("docs routes", () => { + beforeEach(() => { + apiReferenceMock.mockClear(); + }); + + it("renders the API reference page with the configured base URL", async () => { + const response = await getDocs(); + + expect(response.status).toBe(200); + await expect(response.text()).resolves.toBe("openapi-ui"); + expect(apiReferenceMock).toHaveBeenCalledWith( + expect.objectContaining({ + url: "/docs/openapi.json", + baseServerURL: "https://api.example.com", + pageTitle: "F3 Nation API Reference", + favicon: "/favicon.ico", + }), + ); + }); + + it("returns an OpenAPI JSON document with client headers injected", async () => { + const response = await getOpenApiJson( + new Request("https://api.example.com/docs/openapi.json"), + ); + + expect(response.status).toBe(200); + + const spec = (await response.json()) as { + components?: { + parameters?: Record; + }; + paths?: Record< + string, + { + get?: { parameters?: { $ref?: string }[] }; + post?: { parameters?: { $ref?: string }[] }; + } + >; + }; + + expect(spec.components?.parameters?.ClientHeader).toMatchObject({ + name: "client", + in: "header", + }); + expect(spec.paths?.["/v1/ping"]?.get?.parameters?.[0]).toMatchObject({ + $ref: "#/components/parameters/ClientHeader", + }); + expect(spec.paths?.["/v1/ping"]?.post?.parameters?.[0]).toMatchObject({ + $ref: "#/components/parameters/ClientHeader", + }); + }); +}); From 6c86da21b27105c1b6c4c415380b1170649ca1cc Mon Sep 17 00:00:00 2001 From: Evan Petzoldt Date: Mon, 8 Jun 2026 06:07:06 -0500 Subject: [PATCH 7/7] refactor(api): using special API token for slack routes --- apps/api/__tests__/docs-routes.test.ts | 9 +++++ apps/api/src/app/docs/openapi.json/route.ts | 12 ++----- packages/api/src/router/slack.test.ts | 8 ++--- packages/api/src/router/slack.ts | 40 ++++++++++----------- packages/api/src/shared.ts | 17 +++++++++ packages/api/vitest.config.ts | 2 +- packages/env/.env.local.example | 1 + packages/env/src/index.ts | 1 + 8 files changed, 55 insertions(+), 35 deletions(-) diff --git a/apps/api/__tests__/docs-routes.test.ts b/apps/api/__tests__/docs-routes.test.ts index a10b5c9f..db07eed6 100644 --- a/apps/api/__tests__/docs-routes.test.ts +++ b/apps/api/__tests__/docs-routes.test.ts @@ -90,5 +90,14 @@ describe("docs routes", () => { expect(spec.paths?.["/v1/ping"]?.post?.parameters?.[0]).toMatchObject({ $ref: "#/components/parameters/ClientHeader", }); + + const generateCalls = generateMock.mock.calls as unknown[][]; + const generateOptions = generateCalls[0]?.[1] as + | { filter?: (args: { path: string[] }) => boolean } + | undefined; + + expect(generateOptions?.filter).toBeTypeOf("function"); + expect(generateOptions?.filter?.({ path: ["slack"] })).toBe(false); + expect(generateOptions?.filter?.({ path: ["ping"] })).toBe(true); }); }); diff --git a/apps/api/src/app/docs/openapi.json/route.ts b/apps/api/src/app/docs/openapi.json/route.ts index d0752f03..632086de 100644 --- a/apps/api/src/app/docs/openapi.json/route.ts +++ b/apps/api/src/app/docs/openapi.json/route.ts @@ -54,6 +54,7 @@ export async function GET(request: Request) { }); const spec = (await generator.generate(router, { + filter: ({ path }) => path[0] !== "slack", info: { title: "F3 Nation API", version: packageJson.version, @@ -106,6 +107,7 @@ API keys inherit the roles and permissions of their owner. Access levels include - **Editor**: Can view and modify data within assigned organizations - **Admin**: Same permissions as Editor, plus the ability to add/remove other Admins and Editors + As of February 1, 2026, regional admins can only create read-only API keys. If you want an API Key edit access to your region, you will need to contact an F3 Nation admin. Right now, regional edit access is highly restrcited. This is because the API is still new and there are almost certainly gaps in security - meaning that a region has the potential to mess up data for other regions. ## Best Practices @@ -119,6 +121,7 @@ As of February 1, 2026, regional admins can only create read-only API keys. If y - **401 Unauthorized**: Missing, invalid, revoked, or expired API key - **403 Forbidden**: Valid API key but insufficient permissions for the requested resource - **429 Too Many Requests**: Rate limit exceeded (200 requests per 60 seconds)`, + contact: { name: "F3 Nation", url: "https://f3nation.com", @@ -157,10 +160,6 @@ As of February 1, 2026, regional admins can only create read-only API keys. If y name: "Me", tags: ["Me"], }, - { - name: "Slack", - tags: ["slack"], - }, ], tags: [ { @@ -207,11 +206,6 @@ As of February 1, 2026, regional admins can only create read-only API keys. If y description: "Map event/workout endpoints for filtering and querying", }, { name: "revalidate", description: "Cache revalidation for map data" }, - { - name: "slack", - description: - "Slack workspace integration endpoints for user/space management and org linking", - }, ], components: { diff --git a/packages/api/src/router/slack.test.ts b/packages/api/src/router/slack.test.ts index 4000c5cb..8ae3e9d2 100644 --- a/packages/api/src/router/slack.test.ts +++ b/packages/api/src/router/slack.test.ts @@ -81,7 +81,7 @@ describe("Slack Router", () => { describe("getSpace", () => { it("should return space settings for a team", async () => { - const client = createTestClient(); + const client = createTestClient("test-admin-key"); const result = await client.slack.getSpace({ teamId }); expect(result).not.toBeNull(); expect(result?.teamId).toBe(teamId); @@ -92,7 +92,7 @@ describe("Slack Router", () => { }); it("should return null for non-existent team", async () => { - const client = createTestClient(); + const client = createTestClient("test-admin-key"); const result = await client.slack.getSpace({ teamId: "non-existent" }); expect(result).toBeNull(); }); @@ -227,7 +227,7 @@ describe("Slack Router", () => { }); it("should find a user by slackId", async () => { - const client = createTestClient(); + const client = createTestClient("test-admin-key"); const result = await client.slack.getUserBySlackId({ slackId, teamId }); expect(result).not.toBeNull(); expect(result?.slackId).toBe(slackId); @@ -500,7 +500,7 @@ describe("Slack Router", () => { }); it("scopes getUserBySlackId to the requested team", async () => { - const client = createTestClient(); + const client = createTestClient("test-admin-key"); const teamAUser = await client.slack.getUserBySlackId({ slackId: sharedSlackId, diff --git a/packages/api/src/router/slack.ts b/packages/api/src/router/slack.ts index 52d8d0c5..fc1d8085 100644 --- a/packages/api/src/router/slack.ts +++ b/packages/api/src/router/slack.ts @@ -3,12 +3,10 @@ import { z } from "zod"; import { and, eq, ilike, inArray, isNotNull, or, schema, sql } from "@acme/db"; import { SlackSettingsSchema, SlackUserUpsertSchema } from "@acme/validators"; -import { apiKeyProcedure, withSessionAndDb } from "../shared"; - -const publicProcedure = withSessionAndDb; +import { slackBotProcedure } from "../shared"; export const slackRouter = { - getSpace: publicProcedure + getSpace: slackBotProcedure .input(z.object({ teamId: z.string() })) .route({ method: "GET", @@ -26,7 +24,7 @@ export const slackRouter = { return space ?? null; }), - updateSpaceSettings: apiKeyProcedure + updateSpaceSettings: slackBotProcedure .input( z.object({ teamId: z.string(), @@ -56,7 +54,7 @@ export const slackRouter = { return { success: true }; }), - getUserBySlackId: publicProcedure + getUserBySlackId: slackBotProcedure .input( z.object({ slackId: z.string(), @@ -96,7 +94,7 @@ export const slackRouter = { return slackUser; }), - upsertUser: apiKeyProcedure + upsertUser: slackBotProcedure .input(SlackUserUpsertSchema) .route({ method: "PUT", @@ -150,7 +148,7 @@ export const slackRouter = { return { success: true, action: "created" }; }), - getOrCreateSpace: apiKeyProcedure + getOrCreateSpace: slackBotProcedure .input( z.object({ teamId: z.string(), @@ -190,7 +188,7 @@ export const slackRouter = { return space ?? null; }), - getOrCreateUser: apiKeyProcedure + getOrCreateUser: slackBotProcedure .input( z.object({ slackId: z.string(), @@ -249,7 +247,7 @@ export const slackRouter = { * If the F3 user doesn't exist for the email, creates it. * Always returns a Slack user with a valid userId linking to an F3 user. */ - getOrCreateLinkedUser: apiKeyProcedure + getOrCreateLinkedUser: slackBotProcedure .input( z.object({ slackId: z.string(), @@ -357,7 +355,7 @@ export const slackRouter = { * Get the org associated with a Slack workspace. * The org can be any type (region, area, etc.) depending on how the workspace was configured. */ - getOrg: publicProcedure + getOrg: slackBotProcedure .input(z.object({ teamId: z.string() })) .route({ method: "GET", @@ -396,7 +394,7 @@ export const slackRouter = { * Check if a Slack user has a specific role on the region org associated with their Slack workspace. * This checks the F3 role system (rolesXUsersXOrg), not Slack's admin/owner flags. */ - checkUserRole: apiKeyProcedure + checkUserRole: slackBotProcedure .input( z.object({ slackId: z.string(), @@ -566,7 +564,7 @@ export const slackRouter = { * Get all F3 roles for a Slack user on the region org. * Returns role names the user has on the region and any ancestor orgs. */ - getUserRoles: apiKeyProcedure + getUserRoles: slackBotProcedure .input( z.object({ slackId: z.string(), @@ -685,7 +683,7 @@ export const slackRouter = { * Connect a Slack workspace to an F3 org (region, area, etc.) * Creates or finds the org and links it to the Slack space via orgsXSlackSpaces. */ - connectSpaceToOrg: apiKeyProcedure + connectSpaceToOrg: slackBotProcedure .input( z.object({ teamId: z.string(), @@ -771,7 +769,7 @@ export const slackRouter = { * Get admin users for an org that have linked Slack accounts. * Returns userId + slackId pairs for populating the admin multi-user select. */ - getOrgAdmins: apiKeyProcedure + getOrgAdmins: slackBotProcedure .input( z.object({ orgId: z.coerce.number(), @@ -827,7 +825,7 @@ export const slackRouter = { * Only removes admin roles for users that have Slack accounts in the given team, * preserving admins added through other channels (e.g., Maps UI). */ - setOrgAdmins: apiKeyProcedure + setOrgAdmins: slackBotProcedure .input( z.object({ orgId: z.number(), @@ -915,7 +913,7 @@ export const slackRouter = { * Typeahead search for users by f3Name, firstName, or lastName. * Used for Slack external_select elements. */ - searchUsers: apiKeyProcedure + searchUsers: slackBotProcedure .input( z.object({ searchTerm: z.string().min(1).describe("Search query for user name"), @@ -972,7 +970,7 @@ export const slackRouter = { * Get users by their IDs with home region info. * Used for displaying downrange PAX in backblasts. */ - getUsersByIds: apiKeyProcedure + getUsersByIds: slackBotProcedure .input( z.object({ userIds: z @@ -1012,7 +1010,7 @@ export const slackRouter = { * Typeahead search for regions by name. * Used for Slack external_select elements. */ - searchRegions: apiKeyProcedure + searchRegions: slackBotProcedure .input( z.object({ searchTerm: z.string().min(1).describe("Search query for region name"), @@ -1059,7 +1057,7 @@ export const slackRouter = { * Get the current user's full F3 profile. * Includes home region information for populating the user profile form. */ - getSelfProfile: apiKeyProcedure + getSelfProfile: slackBotProcedure .input( z.object({ teamId: z.string().describe("Slack workspace ID"), @@ -1127,7 +1125,7 @@ export const slackRouter = { * This allows users to update their profile without admin rights. * Only updates non-role fields for the linked users table entry. */ - updateSelfProfile: apiKeyProcedure + updateSelfProfile: slackBotProcedure .input( z.object({ teamId: z.string().describe("Slack workspace ID"), diff --git a/packages/api/src/shared.ts b/packages/api/src/shared.ts index 27d6bf64..74b7e7e4 100644 --- a/packages/api/src/shared.ts +++ b/packages/api/src/shared.ts @@ -156,6 +156,23 @@ export const revalidateAuthProcedure = withSessionAndDb.use( }, ); +export const slackBotProcedure = withSessionAndDb.use(({ context, next }) => { + const apiKey = context.reqHeaders?.get("x-api-key") ?? ""; + + if (!apiKey) { + throw new ORPCError("UNAUTHORIZED"); + } + + if ( + (env.SLACKBOT_API_KEY && apiKey === env.SLACKBOT_API_KEY) || + (env.SUPER_ADMIN_API_KEY && apiKey === env.SUPER_ADMIN_API_KEY) + ) { + return next({ context }); + } + + throw new ORPCError("UNAUTHORIZED"); +}); + export const nationAdminProcedure = withSessionAndDb.use( ({ context, next }) => { if (!isNationAdminFromSession(context.session)) { diff --git a/packages/api/vitest.config.ts b/packages/api/vitest.config.ts index 1134403c..b4a29e43 100644 --- a/packages/api/vitest.config.ts +++ b/packages/api/vitest.config.ts @@ -8,6 +8,6 @@ export default defineConfig({ environment: "node", setupFiles: ["./src/__tests__/setup.ts"], fileParallelism: false, - env: { NODE_ENV: "test" }, + env: { NODE_ENV: "test", SLACKBOT_API_KEY: "test-slackbot-api-key" }, }, }); diff --git a/packages/env/.env.local.example b/packages/env/.env.local.example index c6f88fb8..c4f7cc7e 100644 --- a/packages/env/.env.local.example +++ b/packages/env/.env.local.example @@ -10,6 +10,7 @@ AUTH_SECRET=local-dev-secret-at-least-32-chars-long # -- API keys (any non-empty string works locally) ---------------------------- API_KEY=local-api-key SUPER_ADMIN_API_KEY=local-super-admin-key +SLACKBOT_API_KEY=local-slackbot-key # -- Email -------------------------------------------------------------------- EMAIL_SERVER=smtp://localhost:1025 diff --git a/packages/env/src/index.ts b/packages/env/src/index.ts index fe86a359..d5b497c9 100644 --- a/packages/env/src/index.ts +++ b/packages/env/src/index.ts @@ -32,6 +32,7 @@ export const env = createEnv({ TEST_DATABASE_URL: z.string().min(1), API_KEY: z.string().min(1), SUPER_ADMIN_API_KEY: z.string().min(1), + SLACKBOT_API_KEY: z.string().min(1), NOTIFY_WEBHOOK_URLS_COMMA_SEPARATED: z.string().optional(), GCS_EMULATOR_HOST: z.string().optional(), },