import type { PrismaClient } from "@prisma/client"; import { requireActiveFeishuApplicationConnectionInTransaction, scopedFeishuPrincipalId, } from "./identityNamespace.js"; import { lockActiveOrganization } from "../org/status.js"; export type FeishuBindingLifecycleReason = "chat_dissolved" | "bot_removed"; /** * Archive a project's active Feishu chat binding after Feishu has notified this * bot that the chat dissolved or that the bot was removed. The event can be * redelivered; only the first delivery changes state and writes an audit row. */ export async function archiveFeishuBindingForLifecycleEvent( prisma: PrismaClient, input: { readonly chatId: string; readonly eventId: string; readonly reason: FeishuBindingLifecycleReason; }, ): Promise<{ readonly archived: boolean; readonly projectId?: string | undefined }> { const chatId = requireNonEmpty(input.chatId, "Feishu chat id"); const eventId = requireNonEmpty(input.eventId, "Feishu event id"); return prisma.$transaction(async (tx) => { // Feishu WS delivery is at-least-once. Persist the receipt in the same // transaction as the archive so a failed archive remains eligible for a // retry, while an old redelivery cannot archive a later re-binding. const receipt = await tx.feishuEventReceipt.createMany({ data: { eventId, eventType: lifecycleEventType(input.reason), }, skipDuplicates: true, }); if (receipt.count === 0) return { archived: false }; const binding = await tx.projectGroupBinding.findFirst({ where: { chatId, archivedAt: null }, select: { id: true, projectId: true, project: { select: { organizationId: true } } }, }); if (binding === null) return { archived: false }; await lockActiveOrganization(tx, binding.project.organizationId); const connection = await tx.organizationFeishuApplicationConnection.findUnique({ where: { organizationId: binding.project.organizationId }, select: { id: true }, }); if (connection === null) { throw new Error(`Feishu application connection not found for organization ${binding.project.organizationId}`); } const activeConnection = await requireActiveFeishuApplicationConnectionInTransaction(tx, connection.id); if (activeConnection.organizationId !== binding.project.organizationId) { throw new Error("Feishu application connection Organization scope mismatch"); } const now = new Date(); const archived = await tx.projectGroupBinding.updateMany({ where: { id: binding.id, archivedAt: null }, data: { archivedAt: now }, }); if (archived.count === 0) return { archived: false }; await tx.permissionGrant.updateMany({ where: { resourceType: "PROJECT", resourceId: binding.projectId, principalType: "FEISHU_CHAT", // Bindings created before ADR-0024 used the raw chat id. Retire only // that exact legacy principal alongside the current scoped principal. principalId: { in: [scopedFeishuPrincipalId("CHAT", connection.id, chatId), chatId], }, revokedAt: null, }, data: { revokedAt: now }, }); await tx.auditEntry.create({ data: { organizationId: binding.project.organizationId, projectId: binding.projectId, action: "project.chat_binding_archived", metadata: { chatId, reason: input.reason, eventId, }, }, }); return { archived: true, projectId: binding.projectId }; }); } function lifecycleEventType(reason: FeishuBindingLifecycleReason): string { return reason === "chat_dissolved" ? "im.chat.disbanded_v1" : "im.chat.member.bot.deleted_v1"; } function requireNonEmpty(value: string, label: string): string { const trimmed = value.trim(); if (trimmed === "") throw new Error(`${label} is required`); return trimmed; }