- Prisma 6 → 7: new prisma-client generator, prisma.config.ts, PrismaPg adapter, updated all imports - LangChain 0.x → 1.x: @langchain/core, langgraph, openai - Tailwind 4.1 → 4.2.1, daisyUI 5.5.19, Vue 3.5.29, ai 6.0.99, zod 4.3.6 - Fix MessageDirection bug in crm-updates.ts (OUTBOUND → OUT) - Add server/generated to .gitignore Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
601 lines
17 KiB
TypeScript
601 lines
17 KiB
TypeScript
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<string, CalendarSnapshotRow>;
|
|
noteByContactId: Map<string, ContactNoteSnapshotRow>;
|
|
messageById: Map<string, MessageSnapshotRow>;
|
|
dealById: Map<string, DealSnapshotRow>;
|
|
documentById: Map<string, WorkspaceDocumentSnapshotRow>;
|
|
};
|
|
|
|
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<SnapshotState> {
|
|
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<ChangeItem, "id">) => {
|
|
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);
|
|
}
|
|
}
|