import { randomUUID } from "node:crypto"; import type { PrismaClient } from "@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; }; export type SnapshotState = { calendarById: Map; noteByContactId: Map; messageById: Map; dealById: Map; }; export type ChangeItem = { entity: "calendar_event" | "contact_note" | "message" | "deal"; action: "created" | "updated" | "deleted"; title: string; before: string; after: string; }; 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 }; export type ChangeSet = { id: string; status: "pending" | "confirmed" | "rolled_back"; createdAt: string; summary: string; items: ChangeItem[]; undo: UndoOp[]; }; 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}` : ""}`; } export async function captureSnapshot(prisma: PrismaClient, teamId: string): Promise { const [calendar, notes, messages, deals] = 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, }), ]); 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, }, ]), ), }; } export function buildChangeSet(before: SnapshotState, after: SnapshotState): ChangeSet | null { const items: ChangeItem[] = []; const undo: UndoOp[] = []; for (const [id, row] of after.calendarById) { const prev = before.calendarById.get(id); if (!prev) { items.push({ entity: "calendar_event", action: "created", title: `Event created: ${row.title}`, before: "", after: toCalendarText(row), }); undo.push({ 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 ) { items.push({ entity: "calendar_event", action: "updated", title: `Event updated: ${row.title}`, before: toCalendarText(prev), after: toCalendarText(row), }); undo.push({ kind: "restore_calendar_event", data: prev }); } } for (const [id, row] of before.calendarById) { if (after.calendarById.has(id)) continue; items.push({ entity: "calendar_event", action: "deleted", title: `Event archived: ${row.title}`, before: toCalendarText(row), after: "", }); undo.push({ kind: "restore_calendar_event", data: row }); } for (const [contactId, row] of after.noteByContactId) { const prev = before.noteByContactId.get(contactId); if (!prev) { items.push({ entity: "contact_note", action: "created", title: `Summary added: ${row.contactName}`, before: "", after: row.content, }); undo.push({ kind: "restore_contact_note", contactId, content: null }); continue; } if (prev.content !== row.content) { items.push({ entity: "contact_note", action: "updated", title: `Summary updated: ${row.contactName}`, before: prev.content, after: row.content, }); undo.push({ kind: "restore_contact_note", contactId, content: prev.content }); } } for (const [contactId, row] of before.noteByContactId) { if (after.noteByContactId.has(contactId)) continue; items.push({ entity: "contact_note", action: "deleted", title: `Summary cleared: ${row.contactName}`, before: row.content, after: "", }); undo.push({ kind: "restore_contact_note", contactId, content: row.content }); } for (const [id, row] of after.messageById) { if (before.messageById.has(id)) continue; items.push({ entity: "message", action: "created", title: `Message created: ${row.contactName}`, before: "", after: toMessageText(row), }); undo.push({ 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)) { items.push({ entity: "deal", action: "updated", title: `Deal updated: ${row.title}`, before: toDealText(prev), after: toDealText(row), }); undo.push({ kind: "restore_deal", id, stage: prev.stage, nextStep: prev.nextStep, summary: prev.summary, }); } } 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, }; } export async function rollbackChangeSet(prisma: PrismaClient, teamId: string, changeSet: ChangeSet) { const ops = [...changeSet.undo].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, }, }); } } }); }