forked from EduCraft/curriculum-project-hub
102 lines
3.9 KiB
TypeScript
102 lines
3.9 KiB
TypeScript
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;
|
|
}
|