303 lines
8.8 KiB
TypeScript
303 lines
8.8 KiB
TypeScript
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 },
|
|
});
|
|
}
|
|
}
|