import { randomUUID } from "node:crypto"; import type { PrismaClient } from "../generated/prisma/client"; type CalendarSnapshotRow = { id: string; teamId: string; contactId: string | null; title: string; startsAt: string; endsAt: string | null; note: string | null; isArchived: boolean; archiveNote: string | null; archivedAt: string | null; }; type ContactNoteSnapshotRow = { contactId: string; contactName: string; content: string; }; type MessageSnapshotRow = { id: string; contactId: string; contactName: string; kind: string; direction: string; channel: string; content: string; durationSec: number | null; occurredAt: string; }; type DealSnapshotRow = { id: string; title: string; contactName: string; stage: string; nextStep: string | null; summary: string | null; }; type WorkspaceDocumentSnapshotRow = { id: string; teamId: string; title: string; type: string; owner: string; scope: string; summary: string; body: string; }; export type SnapshotState = { calendarById: Map; noteByContactId: Map; messageById: Map; dealById: Map; documentById: Map; }; export type ChangeItem = { id: string; entity: "calendar_event" | "contact_note" | "message" | "deal" | "workspace_document"; entityId: string | null; action: "created" | "updated" | "deleted"; title: string; before: string; after: string; undo: UndoOp[]; }; type UndoOp = | { kind: "delete_calendar_event"; id: string } | { kind: "restore_calendar_event"; data: CalendarSnapshotRow } | { kind: "delete_contact_message"; id: string } | { kind: "restore_contact_message"; data: MessageSnapshotRow } | { kind: "restore_contact_note"; contactId: string; content: string | null } | { kind: "restore_deal"; id: string; stage: string; nextStep: string | null; summary: string | null } | { kind: "delete_workspace_document"; id: string } | { kind: "restore_workspace_document"; data: WorkspaceDocumentSnapshotRow }; export type ChangeSet = { id: string; status: "pending" | "confirmed" | "rolled_back"; createdAt: string; summary: string; items: ChangeItem[]; undo: UndoOp[]; rolledBackItemIds: string[]; }; function fmt(val: string | null | undefined) { return (val ?? "").trim(); } function toCalendarText(row: CalendarSnapshotRow) { const when = new Date(row.startsAt).toLocaleString("ru-RU"); return `${row.title} · ${when}${row.note ? ` · ${row.note}` : ""}`; } function toMessageText(row: MessageSnapshotRow) { const when = new Date(row.occurredAt).toLocaleString("ru-RU"); return `${row.contactName} · ${row.channel} · ${row.kind.toLowerCase()} · ${when} · ${row.content}`; } function toDealText(row: DealSnapshotRow) { return `${row.title} (${row.contactName}) · ${row.stage}${row.nextStep ? ` · next: ${row.nextStep}` : ""}`; } function toWorkspaceDocumentText(row: WorkspaceDocumentSnapshotRow) { return `${row.title} · ${row.type} · ${row.owner} · ${row.scope} · ${row.summary}`; } export async function captureSnapshot(prisma: PrismaClient, teamId: string): Promise { const [calendar, notes, messages, deals, documents] = await Promise.all([ prisma.calendarEvent.findMany({ where: { teamId }, select: { id: true, teamId: true, contactId: true, title: true, startsAt: true, endsAt: true, note: true, isArchived: true, archiveNote: true, archivedAt: true, }, take: 4000, }), prisma.contactNote.findMany({ where: { contact: { teamId } }, select: { contactId: true, content: true, contact: { select: { name: true } } }, take: 4000, }), prisma.contactMessage.findMany({ where: { contact: { teamId } }, include: { contact: { select: { name: true } } }, orderBy: { createdAt: "asc" }, take: 6000, }), prisma.deal.findMany({ where: { teamId }, include: { contact: { select: { name: true } } }, take: 4000, }), prisma.workspaceDocument.findMany({ where: { teamId }, select: { id: true, teamId: true, title: true, type: true, owner: true, scope: true, summary: true, body: true, }, take: 4000, }), ]); return { calendarById: new Map( calendar.map((row) => [ row.id, { id: row.id, teamId: row.teamId, contactId: row.contactId ?? null, title: row.title, startsAt: row.startsAt.toISOString(), endsAt: row.endsAt?.toISOString() ?? null, note: row.note ?? null, isArchived: Boolean(row.isArchived), archiveNote: row.archiveNote ?? null, archivedAt: row.archivedAt?.toISOString() ?? null, }, ]), ), noteByContactId: new Map( notes.map((row) => [ row.contactId, { contactId: row.contactId, contactName: row.contact.name, content: row.content, }, ]), ), messageById: new Map( messages.map((row) => [ row.id, { id: row.id, contactId: row.contactId, contactName: row.contact.name, kind: row.kind, direction: row.direction, channel: row.channel, content: row.content, durationSec: row.durationSec ?? null, occurredAt: row.occurredAt.toISOString(), }, ]), ), dealById: new Map( deals.map((row) => [ row.id, { id: row.id, title: row.title, contactName: row.contact.name, stage: row.stage, nextStep: row.nextStep ?? null, summary: row.summary ?? null, }, ]), ), documentById: new Map( documents.map((row) => [ row.id, { id: row.id, teamId: row.teamId, title: row.title, type: row.type, owner: row.owner, scope: row.scope, summary: row.summary, body: row.body, }, ]), ), }; } export function buildChangeSet(before: SnapshotState, after: SnapshotState): ChangeSet | null { const items: ChangeItem[] = []; const undo: UndoOp[] = []; const pushItem = (item: Omit) => { const next: ChangeItem = { ...item, id: randomUUID() }; items.push(next); undo.push(...next.undo); }; for (const [id, row] of after.calendarById) { const prev = before.calendarById.get(id); if (!prev) { pushItem({ entity: "calendar_event", entityId: row.id, action: "created", title: `Event created: ${row.title}`, before: "", after: toCalendarText(row), undo: [{ kind: "delete_calendar_event", id }], }); continue; } if ( prev.title !== row.title || prev.startsAt !== row.startsAt || prev.endsAt !== row.endsAt || fmt(prev.note) !== fmt(row.note) || prev.isArchived !== row.isArchived || fmt(prev.archiveNote) !== fmt(row.archiveNote) || fmt(prev.archivedAt) !== fmt(row.archivedAt) || prev.contactId !== row.contactId ) { pushItem({ entity: "calendar_event", entityId: row.id, action: "updated", title: `Event updated: ${row.title}`, before: toCalendarText(prev), after: toCalendarText(row), undo: [{ kind: "restore_calendar_event", data: prev }], }); } } for (const [id, row] of before.calendarById) { if (after.calendarById.has(id)) continue; pushItem({ entity: "calendar_event", entityId: row.id, action: "deleted", title: `Event archived: ${row.title}`, before: toCalendarText(row), after: "", undo: [{ kind: "restore_calendar_event", data: row }], }); } for (const [contactId, row] of after.noteByContactId) { const prev = before.noteByContactId.get(contactId); if (!prev) { pushItem({ entity: "contact_note", entityId: contactId, action: "created", title: `Summary added: ${row.contactName}`, before: "", after: row.content, undo: [{ kind: "restore_contact_note", contactId, content: null }], }); continue; } if (prev.content !== row.content) { pushItem({ entity: "contact_note", entityId: contactId, action: "updated", title: `Summary updated: ${row.contactName}`, before: prev.content, after: row.content, undo: [{ kind: "restore_contact_note", contactId, content: prev.content }], }); } } for (const [contactId, row] of before.noteByContactId) { if (after.noteByContactId.has(contactId)) continue; pushItem({ entity: "contact_note", entityId: contactId, action: "deleted", title: `Summary cleared: ${row.contactName}`, before: row.content, after: "", undo: [{ kind: "restore_contact_note", contactId, content: row.content }], }); } for (const [id, row] of after.messageById) { if (before.messageById.has(id)) continue; pushItem({ entity: "message", entityId: row.id, action: "created", title: `Message created: ${row.contactName}`, before: "", after: toMessageText(row), undo: [{ kind: "delete_contact_message", id }], }); } for (const [id, row] of after.dealById) { const prev = before.dealById.get(id); if (!prev) continue; if (prev.stage !== row.stage || fmt(prev.nextStep) !== fmt(row.nextStep) || fmt(prev.summary) !== fmt(row.summary)) { pushItem({ entity: "deal", entityId: row.id, action: "updated", title: `Deal updated: ${row.title}`, before: toDealText(prev), after: toDealText(row), undo: [ { kind: "restore_deal", id, stage: prev.stage, nextStep: prev.nextStep, summary: prev.summary, }, ], }); } } for (const [id, row] of after.documentById) { const prev = before.documentById.get(id); if (!prev) { pushItem({ entity: "workspace_document", entityId: row.id, action: "created", title: `Document created: ${row.title}`, before: "", after: toWorkspaceDocumentText(row), undo: [{ kind: "delete_workspace_document", id }], }); continue; } if ( prev.title !== row.title || prev.type !== row.type || prev.owner !== row.owner || prev.scope !== row.scope || prev.summary !== row.summary || prev.body !== row.body ) { pushItem({ entity: "workspace_document", entityId: row.id, action: "updated", title: `Document updated: ${row.title}`, before: toWorkspaceDocumentText(prev), after: toWorkspaceDocumentText(row), undo: [{ kind: "restore_workspace_document", data: prev }], }); } } for (const [id, row] of before.documentById) { if (after.documentById.has(id)) continue; pushItem({ entity: "workspace_document", entityId: row.id, action: "deleted", title: `Document deleted: ${row.title}`, before: toWorkspaceDocumentText(row), after: "", undo: [{ kind: "restore_workspace_document", data: row }], }); } if (items.length === 0) return null; const created = items.filter((x) => x.action === "created").length; const updated = items.filter((x) => x.action === "updated").length; const deleted = items.filter((x) => x.action === "deleted").length; return { id: randomUUID(), status: "pending", createdAt: new Date().toISOString(), summary: `Created: ${created}, Updated: ${updated}, Archived: ${deleted}`, items, undo, rolledBackItemIds: [], }; } async function applyUndoOps(prisma: PrismaClient, teamId: string, undoOps: UndoOp[]) { const ops = [...undoOps].reverse(); await prisma.$transaction(async (tx) => { for (const op of ops) { if (op.kind === "delete_calendar_event") { await tx.calendarEvent.deleteMany({ where: { id: op.id, teamId } }); continue; } if (op.kind === "restore_calendar_event") { const row = op.data; await tx.calendarEvent.upsert({ where: { id: row.id }, update: { teamId: row.teamId, contactId: row.contactId, title: row.title, startsAt: new Date(row.startsAt), endsAt: row.endsAt ? new Date(row.endsAt) : null, note: row.note, isArchived: row.isArchived, archiveNote: row.archiveNote, archivedAt: row.archivedAt ? new Date(row.archivedAt) : null, }, create: { id: row.id, teamId: row.teamId, contactId: row.contactId, title: row.title, startsAt: new Date(row.startsAt), endsAt: row.endsAt ? new Date(row.endsAt) : null, note: row.note, isArchived: row.isArchived, archiveNote: row.archiveNote, archivedAt: row.archivedAt ? new Date(row.archivedAt) : null, }, }); continue; } if (op.kind === "delete_contact_message") { await tx.contactMessage.deleteMany({ where: { id: op.id } }); continue; } if (op.kind === "restore_contact_message") { const row = op.data; await tx.contactMessage.upsert({ where: { id: row.id }, update: { contactId: row.contactId, kind: row.kind as any, direction: row.direction as any, channel: row.channel as any, content: row.content, durationSec: row.durationSec, occurredAt: new Date(row.occurredAt), }, create: { id: row.id, contactId: row.contactId, kind: row.kind as any, direction: row.direction as any, channel: row.channel as any, content: row.content, durationSec: row.durationSec, occurredAt: new Date(row.occurredAt), }, }); continue; } if (op.kind === "restore_contact_note") { const contact = await tx.contact.findFirst({ where: { id: op.contactId, teamId }, select: { id: true } }); if (!contact) continue; if (op.content === null) { await tx.contactNote.deleteMany({ where: { contactId: op.contactId } }); } else { await tx.contactNote.upsert({ where: { contactId: op.contactId }, update: { content: op.content }, create: { contactId: op.contactId, content: op.content }, }); } continue; } if (op.kind === "restore_deal") { await tx.deal.updateMany({ where: { id: op.id, teamId }, data: { stage: op.stage, nextStep: op.nextStep, summary: op.summary, }, }); continue; } if (op.kind === "delete_workspace_document") { await tx.workspaceDocument.deleteMany({ where: { id: op.id, teamId } }); continue; } if (op.kind === "restore_workspace_document") { const row = op.data; await tx.workspaceDocument.upsert({ where: { id: row.id }, update: { teamId: row.teamId, title: row.title, type: row.type as any, owner: row.owner, scope: row.scope, summary: row.summary, body: row.body, }, create: { id: row.id, teamId: row.teamId, title: row.title, type: row.type as any, owner: row.owner, scope: row.scope, summary: row.summary, body: row.body, }, }); } } }); } export async function rollbackChangeSet(prisma: PrismaClient, teamId: string, changeSet: ChangeSet) { await applyUndoOps(prisma, teamId, changeSet.undo); } export async function rollbackChangeSetItems( prisma: PrismaClient, teamId: string, changeSet: ChangeSet, itemIds: string[], ) { const wanted = new Set(itemIds.filter(Boolean)); if (!wanted.size) return; const itemUndoOps = changeSet.items .filter((item) => wanted.has(item.id)) .flatMap((item) => (Array.isArray(item.undo) ? item.undo : [])); if (itemUndoOps.length > 0) { await applyUndoOps(prisma, teamId, itemUndoOps); return; } // Legacy fallback for old change sets without per-item undo. if (wanted.size >= changeSet.items.length && Array.isArray(changeSet.undo) && changeSet.undo.length > 0) { await applyUndoOps(prisma, teamId, changeSet.undo); } }