From 54e229cac91f8c4e775509c3859a9fd47c1facaa Mon Sep 17 00:00:00 2001 From: Maximilian Kaske <56969857+mxkaske@users.noreply.github.com> Date: Mon, 28 Sep 2026 12:02:25 +0200 Subject: [PATCH] chore: slack user id resolution (#2786) * chore: slack user id resolution * fix: --- .../audit-logs-workspace/columns.tsx | 21 ++++ apps/server/slack-manifest.json | 4 +- .../src/libs/test/doubles/slack-test-state.ts | 3 + .../libs/test/doubles/slack-web-api.mock.ts | 6 + apps/server/src/routes/slack/agent.ts | 8 +- apps/server/src/routes/slack/handler.ts | 16 ++- .../src/routes/slack/interactions.test.ts | 79 ++++++++++++ apps/server/src/routes/slack/interactions.ts | 29 ++++- apps/server/src/routes/slack/oauth.test.ts | 2 + apps/server/src/routes/slack/oauth.ts | 3 + .../routes/slack/resolve-slack-user.test.ts | 114 ++++++++++++++++++ .../src/routes/slack/resolve-slack-user.ts | 71 +++++++++++ .../src/routes/slack/service-adapter.ts | 6 +- packages/services/src/context.ts | 2 + .../member/__tests__/find-by-email.test.ts | 100 +++++++++++++++ packages/services/src/member/find-by-email.ts | 34 ++++++ packages/services/src/member/index.ts | 7 +- packages/services/src/member/schemas.ts | 3 + 18 files changed, 499 insertions(+), 9 deletions(-) create mode 100644 apps/server/src/routes/slack/resolve-slack-user.test.ts create mode 100644 apps/server/src/routes/slack/resolve-slack-user.ts create mode 100644 packages/services/src/member/__tests__/find-by-email.test.ts create mode 100644 packages/services/src/member/find-by-email.ts diff --git a/apps/dashboard/src/components/data-table/audit-logs-workspace/columns.tsx b/apps/dashboard/src/components/data-table/audit-logs-workspace/columns.tsx index 2893fe59..be154876 100644 --- a/apps/dashboard/src/components/data-table/audit-logs-workspace/columns.tsx +++ b/apps/dashboard/src/components/data-table/audit-logs-workspace/columns.tsx @@ -14,6 +14,7 @@ import { } from "@openstatus/ui/components/ui/avatar"; import { Badge } from "@openstatus/ui/components/ui/badge"; import type { ColumnDef } from "@tanstack/react-table"; +import type { ComponentType } from "react"; import { cn } from "@/lib/utils"; @@ -45,6 +46,16 @@ function getActionBadgeColor(action: string) { return "bg-muted/10 text-muted-foreground border-muted/20"; } +// Channel marker next to a resolved user; `actorType` is otherwise hidden. +const viaSource: Record< + string, + { Icon: ComponentType<{ className?: string }>; label: string } +> = { + slack: { Icon: SlackIcon, label: "Slack" }, + apiKey: { Icon: KeyIcon, label: "API Key" }, + mcp: { Icon: ModelContextProtocolIcon, label: "MCP Server" }, +}; + export const columns: ColumnDef[] = [ { accessorKey: "user", @@ -107,6 +118,7 @@ export const columns: ColumnDef[] = [ } return -; } + const via = viaSource[type]; const imgSrc = user.photoUrl || // Seed with a hash of the email — dicebear logs request @@ -126,6 +138,15 @@ export const columns: ColumnDef[] = [ {user.name ?? user.email ?? "—"} + {via && ( + + + + )} ); }, diff --git a/apps/server/slack-manifest.json b/apps/server/slack-manifest.json index 22cd0acb..c12e8d22 100644 --- a/apps/server/slack-manifest.json +++ b/apps/server/slack-manifest.json @@ -61,7 +61,9 @@ "groups:history", "groups:read", "groups:write", - "im:history" + "im:history", + "users:read", + "users:read.email" ] }, "pkce_enabled": false diff --git a/apps/server/src/libs/test/doubles/slack-test-state.ts b/apps/server/src/libs/test/doubles/slack-test-state.ts index 117b5254..3ded1791 100644 --- a/apps/server/src/libs/test/doubles/slack-test-state.ts +++ b/apps/server/src/libs/test/doubles/slack-test-state.ts @@ -22,6 +22,8 @@ export interface SlackTestState { runAgentOverride: ((options?: unknown) => Promise) | null; repliesImpl: () => Promise; historyImpl: () => Promise; + /** `users.info` result; the default has no email, so no mapping is created. */ + usersInfoImpl: (args: Record) => Promise; } const g = globalThis as Record; @@ -46,6 +48,7 @@ if (!g.__slackTestState) { Promise.resolve({ messages: [{ user: "U1", text: "channel message", ts: "1.1" }], }), + usersInfoImpl: () => Promise.resolve({ ok: true, user: { profile: {} } }), } satisfies SlackTestState; } diff --git a/apps/server/src/libs/test/doubles/slack-web-api.mock.ts b/apps/server/src/libs/test/doubles/slack-web-api.mock.ts index 39252ada..d3a9078c 100644 --- a/apps/server/src/libs/test/doubles/slack-web-api.mock.ts +++ b/apps/server/src/libs/test/doubles/slack-web-api.mock.ts @@ -83,6 +83,12 @@ export class WebClient { }, }, }; + users = { + info: (args: Record) => { + s.calls.push({ method: "users.info", args }); + return s.usersInfoImpl(args); + }, + }; views = { publish: (args: Record) => { s.calls.push({ method: "views.publish", args }); diff --git a/apps/server/src/routes/slack/agent.ts b/apps/server/src/routes/slack/agent.ts index 3cf1b10a..9e8da209 100644 --- a/apps/server/src/routes/slack/agent.ts +++ b/apps/server/src/routes/slack/agent.ts @@ -84,7 +84,12 @@ export async function runAgent( thread: SlackThreadMessage[], botUserId: string, userText?: string, - origin?: { slackUserId: string; teamId: string | undefined }, + origin?: { + slackUserId: string; + teamId: string | undefined; + /** Member matched by Slack email; reads run without it, approvals require it. */ + userId?: number; + }, options?: AgentOptions, ): Promise { const ctx: ServiceContext = { @@ -93,6 +98,7 @@ export async function runAgent( type: "slack", teamId: origin?.teamId ?? "", slackUserId: origin?.slackUserId ?? "", + userId: origin?.userId, }, tb, }; diff --git a/apps/server/src/routes/slack/handler.ts b/apps/server/src/routes/slack/handler.ts index 57947890..c71b886c 100644 --- a/apps/server/src/routes/slack/handler.ts +++ b/apps/server/src/routes/slack/handler.ts @@ -35,6 +35,7 @@ import { isSlackToolDraft, type SlackToolDraft, } from "./registry-runner"; +import { resolveSlackUserId } from "./resolve-slack-user"; import { abortTurn, endTurn, startTurn } from "./running-turns"; import { buildThreadTitle, @@ -501,6 +502,15 @@ async function processEvent(body: SlackEvent) { return; } + // Only once we answer: an ignored event must not cost a Slack call. + // Overlaps the thread fetch; never rejects, so an early return can drop it. + const slackMember = resolveSlackUserId({ + workspace: resolved.workspace, + teamId, + slackUserId: event.user ?? "", + slack, + }); + let thread: ThreadMessage[] = []; if (prefetchedThread) { thread = prefetchedThread; @@ -535,7 +545,11 @@ async function processEvent(body: SlackEvent) { thread, botUserId, event.text, - { slackUserId: event.user ?? "", teamId }, + { + slackUserId: event.user ?? "", + teamId, + userId: (await slackMember) ?? undefined, + }, { events: reply.progress, signal: turn.signal, diff --git a/apps/server/src/routes/slack/interactions.test.ts b/apps/server/src/routes/slack/interactions.test.ts index a85d5e0d..06ae3b13 100644 --- a/apps/server/src/routes/slack/interactions.test.ts +++ b/apps/server/src/routes/slack/interactions.test.ts @@ -1,5 +1,13 @@ import crypto from "node:crypto"; +import { and, db, eq } from "@openstatus/db"; +import { auditLog } from "@openstatus/db/src/schema"; +import { + addUserToWorkspace, + createPage, + createTestWorkspace, + createUser, +} from "@openstatus/db/src/test/factories"; import { beforeEach, describe, expect, test } from "@openstatus/test-utils"; import { Hono } from "hono"; @@ -14,6 +22,7 @@ import { import { settleBackgroundTasks } from "./background"; import type { SlackEnv } from "./config"; import { handleSlackInteraction } from "./interactions"; +import { resetSlackUserCache } from "./resolve-slack-user"; import { verifySlackSignature } from "./verify"; const redisStore = (globalThis as Record) @@ -32,6 +41,9 @@ const basePending = { function configureSlackDoubles() { slackTestState.calls = []; + resetSlackUserCache(); + slackTestState.usersInfoImpl = () => + Promise.resolve({ ok: true, user: { profile: {} } }); slackTestState.resolveWorkspace = (teamId: string) => teamId === "T_KNOWN" ? Promise.resolve({ botToken: "xoxb-fallback", workspace: { id: 1 } }) @@ -397,6 +409,73 @@ describe("registry-runner execution paths", () => { expect(errCall).toBeDefined(); }); + test("a confirmed action is attributed to the member with the Slack email", async () => { + const { workspace } = await createTestWorkspace(); + const page = await createPage(workspace.id); + const member = await createUser(); + await addUserToWorkspace(member.id, workspace.id, "member"); + slackTestState.resolveWorkspace = () => + Promise.resolve({ + botToken: "xoxb-fallback", + workspace: { id: workspace.id }, + }); + slackTestState.usersInfoImpl = () => + Promise.resolve({ + ok: true, + user: { profile: { email: (member.email as string).toUpperCase() } }, + }); + + const now = Date.now(); + const data = { + ...basePending, + id: "maint-attr", + workspaceId: workspace.id, + threadTs: "3.1", + messageTs: "3.2", + payload: { + toolName: "create_maintenance", + input: { + title: "DB Maintenance", + message: "Scheduled database upgrade.", + from: new Date(now + 86400000).toISOString(), + to: new Date(now + 86400000 + 3600000).toISOString(), + pageId: page.id, + pageComponentIds: [], + }, + }, + }; + redisStore.set("slack:action:maint-attr", JSON.stringify(data)); + redisStore.set("slack:thread:3.1", "maint-attr"); + + try { + const res = await signAndPost(app, { + type: "block_actions", + user: { id: "U_OWNER" }, + channel: { id: "C1" }, + message: { ts: "3.2" }, + team: { id: "T_KNOWN" }, + actions: [{ action_id: "approve_maint-attr" }], + }); + expect(res.status).toBe(200); + + // The verb ran with the matched member, so its audit row carries it. + const created = await db + .select() + .from(auditLog) + .where( + and( + eq(auditLog.workspaceId, workspace.id), + eq(auditLog.action, "maintenance.create"), + ), + ) + .get(); + expect(created?.actorType).toBe("slack"); + expect(created?.actorUserId).toBe(member.id); + } finally { + await db.delete(auditLog).where(eq(auditLog.workspaceId, workspace.id)); + } + }); + test("from after to surfaces typed validation error", async () => { const now = Date.now(); seedCreateMaintenance("maint-bad-time", { diff --git a/apps/server/src/routes/slack/interactions.ts b/apps/server/src/routes/slack/interactions.ts index ddcea6b1..650078f8 100644 --- a/apps/server/src/routes/slack/interactions.ts +++ b/apps/server/src/routes/slack/interactions.ts @@ -9,6 +9,7 @@ import { consume, get } from "./confirmation-store"; import type { PendingAction } from "./confirmation-store"; import { renderToolResult } from "./presenters"; import { executeRegistryAction, getRegistryTool } from "./registry-runner"; +import { resolveSlackUserId } from "./resolve-slack-user"; import { toServiceCtx } from "./service-adapter"; import { resolveWorkspace } from "./workspace-resolver"; @@ -105,6 +106,10 @@ async function processInteraction( return; } + // TODO: refuse an unmapped approver here, before `consume`, with an ephemeral + // "link your openstatus account" so the card stays live. Every Slack + // mutation must resolve to a member (decision 2026-09-28); Cancel is exempt. + // // Atomic consume — prevents double execution from concurrent requests // (e.g. double-click). If another request already won, return. const consumed = await consume(parsed.pendingId); @@ -120,6 +125,14 @@ async function processInteraction( return; } + // Attribution only until the gate above lands; moves up with it. + const memberId = await resolveSlackUserId({ + workspace: resolved.workspace, + teamId: workspaceTeamId, + slackUserId: userId, + slack, + }); + try { await runAndPresent({ pending: consumed, @@ -129,6 +142,7 @@ async function processInteraction( messageTs, slackUserId: userId, teamId: workspaceTeamId, + userId: memberId ?? undefined, }); } catch (err) { logger.error("slack action execution error", { @@ -154,9 +168,18 @@ async function runAndPresent(args: { messageTs: string; slackUserId: string; teamId: string; + userId?: number; }) { - const { pending, flag, slack, channelId, messageTs, slackUserId, teamId } = - args; + const { + pending, + flag, + slack, + channelId, + messageTs, + slackUserId, + teamId, + userId, + } = args; const tool = getRegistryTool(pending.payload.toolName); if (!tool) { throw new Error( @@ -164,7 +187,7 @@ async function runAndPresent(args: { ); } - const ctx = await toServiceCtx({ pending, slackUserId, teamId }); + const ctx = await toServiceCtx({ pending, slackUserId, teamId, userId }); const flagId = tool.approval?.extraFlags?.[0]?.id; const flags: Record = flagId ? { [flagId]: flag } : {}; diff --git a/apps/server/src/routes/slack/oauth.test.ts b/apps/server/src/routes/slack/oauth.test.ts index bc338015..e8b68de2 100644 --- a/apps/server/src/routes/slack/oauth.test.ts +++ b/apps/server/src/routes/slack/oauth.test.ts @@ -98,6 +98,8 @@ describe("handleSlackInstall", () => { "groups:read", "groups:write", "im:history", + "users:read", + "users:read.email", ]; for (const s of expectedScopes) { diff --git a/apps/server/src/routes/slack/oauth.ts b/apps/server/src/routes/slack/oauth.ts index 354a421b..a7c9218c 100644 --- a/apps/server/src/routes/slack/oauth.ts +++ b/apps/server/src/routes/slack/oauth.ts @@ -27,6 +27,9 @@ const BOT_SCOPES = [ "groups:read", "groups:write", "im:history", + // users.info + profile.email: attributes Slack actions to workspace members. + "users:read", + "users:read.email", ].join(","); interface OAuthState { diff --git a/apps/server/src/routes/slack/resolve-slack-user.test.ts b/apps/server/src/routes/slack/resolve-slack-user.test.ts new file mode 100644 index 00000000..58540342 --- /dev/null +++ b/apps/server/src/routes/slack/resolve-slack-user.test.ts @@ -0,0 +1,114 @@ +import { selectWorkspaceSchema } from "@openstatus/db/src/schema"; +import { + addUserToWorkspace, + createTestWorkspace, + createUser, +} from "@openstatus/db/src/test/factories"; +import { WebClient } from "@slack/web-api"; +import { expect } from "@std/expect"; +import { beforeAll, beforeEach, describe, test } from "@std/testing/bdd"; + +// @slack/web-api is the import-mapped double; `users.info` is driven by +// slackTestState.usersInfoImpl. +import { slackTestState } from "@/libs/test/doubles/slack-test-state"; + +import { resetSlackUserCache, resolveSlackUserId } from "./resolve-slack-user"; + +let workspace: ReturnType; +let memberId: number; +let memberEmail: string; +const slack = new WebClient("xoxb-test"); + +const withEmail = (email?: string) => () => + Promise.resolve({ ok: true, user: { profile: email ? { email } : {} } }); + +const infoCalls = () => + slackTestState.calls.filter((c) => c.method === "users.info").length; + +beforeAll(async () => { + const fixture = await createTestWorkspace(); + workspace = selectWorkspaceSchema.parse(fixture.workspace); + const member = await createUser(); + memberId = member.id; + memberEmail = member.email as string; + await addUserToWorkspace(memberId, workspace.id, "member"); +}); + +beforeEach(() => { + slackTestState.calls = []; + slackTestState.usersInfoImpl = withEmail(undefined); + resetSlackUserCache(); +}); + +describe("resolveSlackUserId", () => { + test("matches the member by email and caches the hit", async () => { + slackTestState.usersInfoImpl = withEmail(memberEmail.toUpperCase()); + const args = { workspace, teamId: "T1", slackUserId: "U_A", slack }; + expect(await resolveSlackUserId(args)).toBe(memberId); + expect(await resolveSlackUserId(args)).toBe(memberId); + expect(infoCalls()).toBe(1); + }); + + test("a miss is not retried until the cache is cleared", async () => { + const args = { workspace, teamId: "T1", slackUserId: "U_B", slack }; + expect(await resolveSlackUserId(args)).toBeNull(); + expect(infoCalls()).toBe(1); + + slackTestState.usersInfoImpl = withEmail(memberEmail); + expect(await resolveSlackUserId(args)).toBeNull(); + expect(infoCalls()).toBe(1); + + resetSlackUserCache(); + expect(await resolveSlackUserId(args)).toBe(memberId); + expect(infoCalls()).toBe(2); + }); + + test("returns null when the email matches no member of this workspace", async () => { + const outsider = await createUser(); + slackTestState.usersInfoImpl = withEmail(outsider.email as string); + expect( + await resolveSlackUserId({ + workspace, + teamId: "T1", + slackUserId: "U_C", + slack, + }), + ).toBeNull(); + }); + + test("swallows Slack errors such as missing_scope without caching them", async () => { + slackTestState.usersInfoImpl = () => + Promise.reject( + Object.assign(new Error("An API error occurred: missing_scope"), { + data: { ok: false, error: "missing_scope" }, + }), + ); + const args = { workspace, teamId: "T1", slackUserId: "U_D", slack }; + expect(await resolveSlackUserId(args)).toBeNull(); + expect(infoCalls()).toBe(1); + + slackTestState.usersInfoImpl = withEmail(memberEmail); + expect(await resolveSlackUserId(args)).toBe(memberId); + expect(infoCalls()).toBe(2); + }); + + test("skips resolution entirely without a team or user id", async () => { + expect( + await resolveSlackUserId({ + workspace, + teamId: "", + slackUserId: "U_E", + slack, + }), + ).toBeNull(); + expect( + await resolveSlackUserId({ + workspace, + teamId: "T1", + slackUserId: "", + slack, + }), + ).toBeNull(); + expect(infoCalls()).toBe(0); + }); +}); diff --git a/apps/server/src/routes/slack/resolve-slack-user.ts b/apps/server/src/routes/slack/resolve-slack-user.ts new file mode 100644 index 00000000..33287e99 --- /dev/null +++ b/apps/server/src/routes/slack/resolve-slack-user.ts @@ -0,0 +1,71 @@ +import { getLogger } from "@logtape/logtape"; +import type { Workspace } from "@openstatus/db/src/schema/workspaces/validation"; +import type { ServiceContext } from "@openstatus/services"; +import { findMemberIdByEmail } from "@openstatus/services/member"; +import type { WebClient } from "@slack/web-api"; + +const logger = getLogger(["api-server", "slack", "resolve-user"]); + +// Per process, like the event dedup in handler.ts. Nothing is persisted: the +// audit row and the `*_by` columns record the resolved id where it mattered. +// Misses expire sooner so a freshly invited member is picked up quickly. +const HIT_TTL_MS = 60 * 60_000; +const MISS_TTL_MS = 10 * 60_000; +const cache = new Map(); + +export function resetSlackUserCache() { + cache.clear(); +} + +function readCache(key: string): number | null | undefined { + const now = Date.now(); + for (const [k, entry] of cache) { + if (entry.expiresAt <= now) cache.delete(k); + } + return cache.get(key)?.userId; +} + +/** + * openstatus member behind a Slack user, matched by verified profile email. + * Attribution only, so every failure (missing `users:read.email` scope, no + * email, no unique member) is `null`. + */ +export async function resolveSlackUserId(args: { + workspace: Workspace; + teamId: string; + slackUserId: string; + slack: WebClient; +}): Promise { + const { workspace, teamId, slackUserId, slack } = args; + if (!teamId || !slackUserId) return null; + + const key = `${workspace.id}:${teamId}:${slackUserId}`; + const hit = readCache(key); + if (hit !== undefined) return hit; + + let userId: number | null = null; + try { + const info = await slack.users.info({ user: slackUserId }); + const email = info.user?.profile?.email; + if (email) { + const ctx: ServiceContext = { + workspace, + actor: { type: "system", job: "slack-user-automap" }, + }; + userId = await findMemberIdByEmail({ ctx, input: { email } }); + } + // Only a completed lookup is cached; a thrown call may be transient. + cache.set(key, { + userId, + expiresAt: Date.now() + (userId === null ? MISS_TTL_MS : HIT_TTL_MS), + }); + } catch (err) { + // `missing_scope` until the workspace reinstalls with users:read.email. + logger.warn("slack user resolution failed", { + workspaceId: workspace.id, + teamId, + error: (err as { data?: { error?: string } })?.data?.error ?? String(err), + }); + } + return userId; +} diff --git a/apps/server/src/routes/slack/service-adapter.ts b/apps/server/src/routes/slack/service-adapter.ts index 003ddedb..25e867d0 100644 --- a/apps/server/src/routes/slack/service-adapter.ts +++ b/apps/server/src/routes/slack/service-adapter.ts @@ -11,11 +11,13 @@ import type { PendingAction } from "./confirmation-store"; * Build a `ServiceContext` for a Slack-originated action. Loads the * workspace fresh since `PendingAction` only stores its id, and we want * services to see the latest plan/limits state at execution time. + * `userId` is the member the caller resolved from the Slack email, if any. */ export async function toServiceCtx(args: { pending: PendingAction; slackUserId: string; teamId: string | undefined; + userId?: number; requestId?: string; }): Promise { const row = await db @@ -28,13 +30,13 @@ export async function toServiceCtx(args: { `slack: workspace ${args.pending.workspaceId} not found at action execute time`, ); } - const workspace = selectWorkspaceSchema.parse(row); return { - workspace, + workspace: selectWorkspaceSchema.parse(row), actor: { type: "slack", teamId: args.teamId ?? "", slackUserId: args.slackUserId, + userId: args.userId, }, requestId: args.requestId, }; diff --git a/packages/services/src/context.ts b/packages/services/src/context.ts index 75361b61..44025989 100644 --- a/packages/services/src/context.ts +++ b/packages/services/src/context.ts @@ -17,6 +17,8 @@ export type Actor = | { type: "user"; userId: number } | { type: "apiKey"; keyId: string; userId?: number; scopes: Scope[] } | { type: "mcp"; keyId: string; userId?: number; scopes: Scope[] } + // `userId`: the member whose email matches the Slack profile (or the + // installing user during OAuth). Adapters resolve it before any write. | { type: "slack"; teamId: string; slackUserId: string; userId?: number } | { type: "system"; job: string } | { type: "webhook"; source: string; externalId?: string } diff --git a/packages/services/src/member/__tests__/find-by-email.test.ts b/packages/services/src/member/__tests__/find-by-email.test.ts new file mode 100644 index 00000000..f08197e9 --- /dev/null +++ b/packages/services/src/member/__tests__/find-by-email.test.ts @@ -0,0 +1,100 @@ +import { eq } from "@openstatus/db"; +import { user } from "@openstatus/db/src/schema"; +import { + addUserToWorkspace, + createUser, +} from "@openstatus/db/src/test/factories"; +import { expect } from "@std/expect"; +import { beforeAll, describe, test } from "@std/testing/bdd"; + +import { + createWorkspaceFixture, + makeSystemCtx, + withTestTransaction, +} from "../../../test/helpers"; +import type { ServiceContext } from "../../context"; +import { findMemberIdByEmail } from "../index.ts"; + +let ctx: ServiceContext; +let workspaceId: number; +let MEMBER_ID: number; +let MEMBER_EMAIL: string; + +beforeAll(async () => { + const fixture = await createWorkspaceFixture("team"); + workspaceId = fixture.workspace.id; + ctx = makeSystemCtx(fixture.workspace, { job: "slack-user-automap" }); + const member = await createUser({ + email: `Mixed.Case-${Date.now()}@Example.com`, + }); + MEMBER_ID = member.id; + MEMBER_EMAIL = member.email as string; + await addUserToWorkspace(MEMBER_ID, workspaceId, "member"); +}); + +describe("findMemberIdByEmail", () => { + test("matches case-insensitively", async () => { + expect( + await findMemberIdByEmail({ + ctx, + input: { email: ` ${MEMBER_EMAIL.toUpperCase()} ` }, + }), + ).toBe(MEMBER_ID); + }); + + test("returns null for a user with the email who is not a member here", async () => { + const outsider = await createUser(); + expect( + await findMemberIdByEmail({ + ctx, + input: { email: outsider.email as string }, + }), + ).toBeNull(); + }); + + test("returns null when two members share the email", async () => { + await withTestTransaction(async (tx) => { + const twin = await createUser({ email: MEMBER_EMAIL.toLowerCase() }, tx); + await addUserToWorkspace(twin.id, workspaceId, "member", tx); + expect( + await findMemberIdByEmail({ + ctx: { ...ctx, db: tx }, + input: { email: MEMBER_EMAIL }, + }), + ).toBeNull(); + }); + }); + + test("ignores soft-deleted members", async () => { + await withTestTransaction(async (tx) => { + const gone = await createUser( + { email: `gone-${Date.now()}@example.com` }, + tx, + ); + await addUserToWorkspace(gone.id, workspaceId, "member", tx); + await tx + .update(user) + .set({ deletedAt: new Date() }) + .where(eq(user.id, gone.id)); + expect( + await findMemberIdByEmail({ + ctx: { ...ctx, db: tx }, + input: { email: gone.email as string }, + }), + ).toBeNull(); + }); + }); + + test("never matches an empty email", async () => { + await withTestTransaction(async (tx) => { + const blank = await createUser({ email: "" }, tx); + await addUserToWorkspace(blank.id, workspaceId, "member", tx); + expect( + await findMemberIdByEmail({ + ctx: { ...ctx, db: tx }, + input: { email: " " }, + }), + ).toBeNull(); + }); + }); +}); diff --git a/packages/services/src/member/find-by-email.ts b/packages/services/src/member/find-by-email.ts new file mode 100644 index 00000000..c3f8b21f --- /dev/null +++ b/packages/services/src/member/find-by-email.ts @@ -0,0 +1,34 @@ +import { and, eq, isNull, ne, sql } from "@openstatus/db"; +import { user, usersToWorkspaces } from "@openstatus/db/src/schema"; + +import { type ServiceContext, getReadDb } from "../context"; +import { FindMemberByEmailInput } from "./schemas"; + +/** + * The one live member with this email, case-insensitively, else `null`. + * Ambiguity is `null` too: callers use this to attribute, never to guess. + */ +export async function findMemberIdByEmail(args: { + ctx: ServiceContext; + input: FindMemberByEmailInput; +}): Promise { + const input = FindMemberByEmailInput.parse(args.input); + const email = input.email.trim().toLowerCase(); + if (!email) return null; + + const rows = await getReadDb(args.ctx) + .select({ id: user.id }) + .from(usersToWorkspaces) + .innerJoin(user, eq(user.id, usersToWorkspaces.userId)) + .where( + and( + eq(usersToWorkspaces.workspaceId, args.ctx.workspace.id), + isNull(user.deletedAt), + ne(user.email, ""), + sql`lower(${user.email}) = ${email}`, + ), + ) + .limit(2) + .all(); + return rows.length === 1 ? rows[0].id : null; +} diff --git a/packages/services/src/member/index.ts b/packages/services/src/member/index.ts index 1eb9ab8b..10cc2823 100644 --- a/packages/services/src/member/index.ts +++ b/packages/services/src/member/index.ts @@ -1,3 +1,8 @@ export { deleteMember } from "./delete"; +export { findMemberIdByEmail } from "./find-by-email"; export { listMembers, type Member } from "./list"; -export { DeleteMemberInput, ListMembersInput } from "./schemas"; +export { + DeleteMemberInput, + FindMemberByEmailInput, + ListMembersInput, +} from "./schemas"; diff --git a/packages/services/src/member/schemas.ts b/packages/services/src/member/schemas.ts index 7f5e6f2c..0e24564a 100644 --- a/packages/services/src/member/schemas.ts +++ b/packages/services/src/member/schemas.ts @@ -5,3 +5,6 @@ export type ListMembersInput = z.infer; export const DeleteMemberInput = z.object({ userId: z.number().int() }); export type DeleteMemberInput = z.infer; + +export const FindMemberByEmailInput = z.object({ email: z.string() }); +export type FindMemberByEmailInput = z.infer; -- 2.51.2