Files
CRM_Montzar/apps/api/src/deletions/deletion-archive.ts
T

303 lines
8.8 KiB
TypeScript
Raw Normal View History

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 },
});
}
}