feat: keep deletions restorable and confirm a pipeline's deals before it is removed
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,302 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
import type { PrismaClient } from '@prisma/client';
|
||||
import { deletionActor, skipDeletionArchive } from './deletion-context';
|
||||
|
||||
const SKIP_MODELS = new Set([
|
||||
'DeletionArchive',
|
||||
'AuditLog',
|
||||
'Notification',
|
||||
'PortalOtp',
|
||||
'AutomationCursor',
|
||||
'MessageAutomationJob',
|
||||
'AccountingSyncJob',
|
||||
'LeadImportJob',
|
||||
'WebhookDelivery',
|
||||
]);
|
||||
|
||||
const MODEL_LABELS: Record<string, string> = {
|
||||
Person: 'مشتری',
|
||||
Company: 'شرکت',
|
||||
Lead: 'لید',
|
||||
Deal: 'معامله',
|
||||
Pipeline: 'مسیر فروش',
|
||||
PipelineStage: 'مرحله مسیر',
|
||||
Activity: 'فعالیت',
|
||||
Note: 'یادداشت',
|
||||
Quote: 'پیشفاکتور',
|
||||
Invoice: 'فاکتور',
|
||||
Ticket: 'تیکت',
|
||||
ProjectCard: 'کارت پروژه',
|
||||
ProjectBoard: 'تخته پروژه',
|
||||
Product: 'محصول',
|
||||
RoutineTask: 'کار روتین',
|
||||
Routine: 'روتین',
|
||||
LeadForm: 'فرم',
|
||||
User: 'کاربر',
|
||||
Team: 'تیم',
|
||||
};
|
||||
|
||||
type Relink = { model: string; field: string; ids: string[] };
|
||||
type Captured = {
|
||||
row: Record<string, unknown>;
|
||||
children: Array<{ model: string; item: Captured }>;
|
||||
relink: Relink[];
|
||||
};
|
||||
|
||||
type ModelField = {
|
||||
name: string;
|
||||
kind: string;
|
||||
type: string;
|
||||
isList: boolean;
|
||||
relationName?: string;
|
||||
relationFromFields?: string[];
|
||||
relationOnDelete?: string;
|
||||
};
|
||||
|
||||
type ModelMeta = { name: string; fields: ModelField[] };
|
||||
|
||||
function models(): ModelMeta[] {
|
||||
return Prisma.dmmf.datamodel.models as unknown as ModelMeta[];
|
||||
}
|
||||
|
||||
function delegateName(model: string) {
|
||||
return model.charAt(0).toLowerCase() + model.slice(1);
|
||||
}
|
||||
|
||||
function delegate(client: PrismaClient, model: string) {
|
||||
return (client as unknown as Record<string, {
|
||||
findMany: (args: unknown) => Promise<Array<Record<string, unknown>>>;
|
||||
findFirst: (args: unknown) => Promise<Record<string, unknown> | null>;
|
||||
create: (args: unknown) => Promise<unknown>;
|
||||
updateMany: (args: unknown) => Promise<unknown>;
|
||||
deleteMany: (args: unknown) => Promise<unknown>;
|
||||
}>)[delegateName(model)];
|
||||
}
|
||||
|
||||
function plain(value: unknown): Record<string, unknown> {
|
||||
return JSON.parse(JSON.stringify(value)) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function modelLabel(model: string) {
|
||||
return MODEL_LABELS[model] ?? model;
|
||||
}
|
||||
|
||||
export function rowLabel(row: Record<string, unknown>) {
|
||||
const person = [row.firstName, row.lastName].filter(Boolean).join(' ').trim();
|
||||
const text =
|
||||
row.title ||
|
||||
row.name ||
|
||||
row.fullName ||
|
||||
row.subject ||
|
||||
person ||
|
||||
row.number ||
|
||||
row.mobile ||
|
||||
row.email ||
|
||||
row.id;
|
||||
return String(text || 'بدون عنوان').slice(0, 180);
|
||||
}
|
||||
|
||||
function cascadeLinks(parentModel: string) {
|
||||
const parent = models().find((item) => item.name === parentModel);
|
||||
if (!parent) return [];
|
||||
const links: Array<{ model: string; fk: string }> = [];
|
||||
for (const field of parent.fields) {
|
||||
if (field.kind !== 'object' || !field.isList) continue;
|
||||
const child = models().find((item) => item.name === field.type);
|
||||
const back = child?.fields.find(
|
||||
(item) =>
|
||||
item.relationName === field.relationName &&
|
||||
(item.relationFromFields?.length ?? 0) > 0,
|
||||
);
|
||||
if (!back || back.relationOnDelete !== 'Cascade') continue;
|
||||
if (SKIP_MODELS.has(field.type)) continue;
|
||||
links.push({ model: field.type, fk: back.relationFromFields![0] });
|
||||
}
|
||||
return links;
|
||||
}
|
||||
|
||||
async function setNullRefs(
|
||||
client: PrismaClient,
|
||||
parentModel: string,
|
||||
id: string,
|
||||
): Promise<Relink[]> {
|
||||
const refs: Relink[] = [];
|
||||
for (const model of models()) {
|
||||
if (SKIP_MODELS.has(model.name)) continue;
|
||||
for (const field of model.fields) {
|
||||
if (field.kind !== 'object' || field.type !== parentModel) continue;
|
||||
if (field.relationOnDelete !== 'SetNull') continue;
|
||||
const fk = field.relationFromFields?.[0];
|
||||
if (!fk) continue;
|
||||
const rows = await delegate(client, model.name).findMany({
|
||||
where: { [fk]: id },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!rows.length) continue;
|
||||
refs.push({
|
||||
model: model.name,
|
||||
field: fk,
|
||||
ids: rows.map((row) => String(row.id)),
|
||||
});
|
||||
}
|
||||
}
|
||||
return refs;
|
||||
}
|
||||
|
||||
async function captureRow(
|
||||
client: PrismaClient,
|
||||
model: string,
|
||||
row: Record<string, unknown>,
|
||||
depth: number,
|
||||
): Promise<Captured> {
|
||||
const id = String(row.id);
|
||||
const relink = depth > 3 ? [] : await setNullRefs(client, model, id);
|
||||
const children: Captured['children'] = [];
|
||||
if (depth < 4) {
|
||||
for (const link of cascadeLinks(model)) {
|
||||
const nested = await delegate(client, link.model).findMany({
|
||||
where: { [link.fk]: id },
|
||||
});
|
||||
for (const child of nested) {
|
||||
children.push({
|
||||
model: link.model,
|
||||
item: await captureRow(client, link.model, child, depth + 1),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return { row: plain(row), children, relink };
|
||||
}
|
||||
|
||||
async function writeArchives(
|
||||
client: PrismaClient,
|
||||
model: string,
|
||||
captured: Captured[],
|
||||
) {
|
||||
const actor = deletionActor();
|
||||
for (const item of captured) {
|
||||
const organizationId =
|
||||
typeof item.row.organizationId === 'string'
|
||||
? item.row.organizationId
|
||||
: actor?.organizationId;
|
||||
if (!organizationId) continue;
|
||||
const deletedById =
|
||||
actor?.userId && actor.organizationId === organizationId ? actor.userId : null;
|
||||
await client.deletionArchive.create({
|
||||
data: {
|
||||
organizationId,
|
||||
model,
|
||||
entityId: String(item.row.id),
|
||||
label: rowLabel(item.row),
|
||||
snapshot: item as unknown as Prisma.InputJsonValue,
|
||||
deletedById,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function snapshotDelete(
|
||||
client: PrismaClient,
|
||||
model: string,
|
||||
action: 'delete' | 'deleteMany',
|
||||
where: Record<string, unknown> | undefined,
|
||||
) {
|
||||
if (!where || model === 'Organization' || SKIP_MODELS.has(model) || deletionActor()?.skip) {
|
||||
return [];
|
||||
}
|
||||
const rows =
|
||||
action === 'delete'
|
||||
? [await delegate(client, model).findFirst({ where })].filter(
|
||||
(row): row is Record<string, unknown> => !!row,
|
||||
)
|
||||
: await delegate(client, model).findMany({ where });
|
||||
if (!rows.length) return [];
|
||||
return Promise.all(rows.map((row) => captureRow(client, model, row, 0)));
|
||||
}
|
||||
|
||||
export function withDeletionArchive<T extends PrismaClient>(base: T) {
|
||||
return base.$extends({
|
||||
query: {
|
||||
$allModels: {
|
||||
async delete({ model, args, query }) {
|
||||
const captured = await snapshotDelete(
|
||||
base,
|
||||
model,
|
||||
'delete',
|
||||
args.where as Record<string, unknown> | undefined,
|
||||
).catch((error) => {
|
||||
console.error('deletion archive snapshot failed', error);
|
||||
return [] as Captured[];
|
||||
});
|
||||
const result = await query(args);
|
||||
if (captured.length) {
|
||||
await writeArchives(base, model, captured).catch((error) => {
|
||||
console.error('deletion archive write failed', error);
|
||||
});
|
||||
}
|
||||
return result;
|
||||
},
|
||||
async deleteMany({ model, args, query }) {
|
||||
const captured = await snapshotDelete(
|
||||
base,
|
||||
model,
|
||||
'deleteMany',
|
||||
args.where as Record<string, unknown> | undefined,
|
||||
).catch((error) => {
|
||||
console.error('deletion archive snapshot failed', error);
|
||||
return [] as Captured[];
|
||||
});
|
||||
const result = await query(args);
|
||||
if (captured.length) {
|
||||
await writeArchives(base, model, captured).catch((error) => {
|
||||
console.error('deletion archive write failed', error);
|
||||
});
|
||||
}
|
||||
return result;
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function restoreArchive(
|
||||
client: PrismaClient,
|
||||
snapshot: Captured,
|
||||
model: string,
|
||||
) {
|
||||
await skipDeletionArchive(async () => {
|
||||
await restoreNode(client, model, snapshot);
|
||||
});
|
||||
}
|
||||
|
||||
async function restoreNode(client: PrismaClient, model: string, node: Captured) {
|
||||
const existing = await delegate(client, model).findFirst({
|
||||
where: { id: node.row.id },
|
||||
select: { id: true },
|
||||
});
|
||||
if (existing) {
|
||||
throw new Error('این مورد هنوز در سامانه وجود دارد.');
|
||||
}
|
||||
try {
|
||||
await delegate(client, model).create({ data: node.row });
|
||||
} catch (error) {
|
||||
const code = (error as { code?: string }).code;
|
||||
if (code === 'P2002') {
|
||||
throw new Error('این مورد یا یک مقدار یکتای آن از قبل وجود دارد.');
|
||||
}
|
||||
if (code === 'P2003') {
|
||||
throw new Error('وابستگی این مورد هنوز وجود ندارد. اول مورد اصلی را برگردانید.');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
for (const child of node.children) {
|
||||
await restoreNode(client, child.model, child.item);
|
||||
}
|
||||
for (const link of node.relink) {
|
||||
if (!link.ids.length || SKIP_MODELS.has(link.model)) continue;
|
||||
await delegate(client, link.model).updateMany({
|
||||
where: { id: { in: link.ids } },
|
||||
data: { [link.field]: node.row.id },
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user