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,20 @@
|
|||||||
|
ALTER TABLE "Organization" ADD COLUMN "deletionRetention" TEXT NOT NULL DEFAULT 'never';
|
||||||
|
|
||||||
|
CREATE TABLE "DeletionArchive" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"organizationId" TEXT NOT NULL,
|
||||||
|
"model" TEXT NOT NULL,
|
||||||
|
"entityId" TEXT NOT NULL,
|
||||||
|
"label" TEXT NOT NULL,
|
||||||
|
"snapshot" JSONB NOT NULL,
|
||||||
|
"deletedById" TEXT,
|
||||||
|
"deletedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "DeletionArchive_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "DeletionArchive_organizationId_deletedAt_idx" ON "DeletionArchive"("organizationId", "deletedAt");
|
||||||
|
CREATE INDEX "DeletionArchive_organizationId_model_idx" ON "DeletionArchive"("organizationId", "model");
|
||||||
|
|
||||||
|
ALTER TABLE "DeletionArchive" ADD CONSTRAINT "DeletionArchive_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "DeletionArchive" ADD CONSTRAINT "DeletionArchive_deletedById_fkey" FOREIGN KEY ("deletedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
@@ -235,6 +235,9 @@ model Organization {
|
|||||||
gamificationBadges GamificationBadge[]
|
gamificationBadges GamificationBadge[]
|
||||||
gamificationUserBadges GamificationUserBadge[]
|
gamificationUserBadges GamificationUserBadge[]
|
||||||
gamificationBonuses GamificationBonus[]
|
gamificationBonuses GamificationBonus[]
|
||||||
|
/// month | two_months | three_months | six_months | year | never
|
||||||
|
deletionRetention String @default("never")
|
||||||
|
deletionArchives DeletionArchive[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
@@ -294,6 +297,7 @@ model User {
|
|||||||
createdRoutineTasks RoutineTask[] @relation("RoutineTaskCreator")
|
createdRoutineTasks RoutineTask[] @relation("RoutineTaskCreator")
|
||||||
routineTaskEvents RoutineTaskEvent[]
|
routineTaskEvents RoutineTaskEvent[]
|
||||||
routineSettings RoutineUserSettings[]
|
routineSettings RoutineUserSettings[]
|
||||||
|
deletionArchives DeletionArchive[] @relation("DeletionActor")
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -1859,6 +1863,24 @@ model AuditLog {
|
|||||||
@@index([organizationId, entityType, createdAt])
|
@@index([organizationId, entityType, createdAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// نسخهٔ قابل بازگشتِ رکوردهای حذفشده
|
||||||
|
model DeletionArchive {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
organizationId String
|
||||||
|
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||||
|
/// نام مدل پریزما
|
||||||
|
model String
|
||||||
|
entityId String
|
||||||
|
label String
|
||||||
|
snapshot Json
|
||||||
|
deletedById String?
|
||||||
|
deletedBy User? @relation("DeletionActor", fields: [deletedById], references: [id], onDelete: SetNull)
|
||||||
|
deletedAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([organizationId, deletedAt])
|
||||||
|
@@index([organizationId, model])
|
||||||
|
}
|
||||||
|
|
||||||
/// فیلترهای ذخیرهشده برای لیستها و مسیرهای فروش
|
/// فیلترهای ذخیرهشده برای لیستها و مسیرهای فروش
|
||||||
/// ستونهای مخفی هر جدول برای هر کاربر (بهازای هر سازمان)
|
/// ستونهای مخفی هر جدول برای هر کاربر (بهازای هر سازمان)
|
||||||
model UserTablePreference {
|
model UserTablePreference {
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ import { LeadsModule } from './leads/leads.module';
|
|||||||
import { ConversationsModule } from './conversations/conversations.module';
|
import { ConversationsModule } from './conversations/conversations.module';
|
||||||
import { RoutinesModule } from './routines/routines.module';
|
import { RoutinesModule } from './routines/routines.module';
|
||||||
import { GamificationModule } from './gamification/gamification.module';
|
import { GamificationModule } from './gamification/gamification.module';
|
||||||
|
import { DeletionsModule } from './deletions/deletions.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -105,6 +106,7 @@ import { GamificationModule } from './gamification/gamification.module';
|
|||||||
ConversationsModule,
|
ConversationsModule,
|
||||||
RoutinesModule,
|
RoutinesModule,
|
||||||
GamificationModule,
|
GamificationModule,
|
||||||
|
DeletionsModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { AsyncLocalStorage } from 'async_hooks';
|
||||||
|
|
||||||
|
export type DeletionActor = {
|
||||||
|
userId: string | null;
|
||||||
|
organizationId: string | null;
|
||||||
|
skip?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deletionContext = new AsyncLocalStorage<DeletionActor>();
|
||||||
|
|
||||||
|
export function deletionActor(): DeletionActor | undefined {
|
||||||
|
return deletionContext.getStore();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function skipDeletionArchive<T>(fn: () => Promise<T>): Promise<T> {
|
||||||
|
const current = deletionContext.getStore();
|
||||||
|
return deletionContext.run(
|
||||||
|
{
|
||||||
|
userId: current?.userId ?? null,
|
||||||
|
organizationId: current?.organizationId ?? null,
|
||||||
|
skip: true,
|
||||||
|
},
|
||||||
|
fn,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { IsIn, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
|
import { CurrentUser } from '../auth/current-user.decorator';
|
||||||
|
import { Roles } from '../auth/roles.decorator';
|
||||||
|
import { RolesGuard } from '../auth/roles.guard';
|
||||||
|
import { DeletionsService } from './deletions.service';
|
||||||
|
|
||||||
|
class ListDeletionsQueryDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
q?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
page?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(100)
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class RetentionDto {
|
||||||
|
@IsIn(['month', 'two_months', 'three_months', 'six_months', 'year', 'never'])
|
||||||
|
retention!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Controller('deletions')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
export class DeletionsController {
|
||||||
|
constructor(private readonly deletions: DeletionsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@Roles('owner', 'admin')
|
||||||
|
list(
|
||||||
|
@CurrentUser() user: { organizationId: string },
|
||||||
|
@Query() query: ListDeletionsQueryDto,
|
||||||
|
) {
|
||||||
|
return this.deletions.list(
|
||||||
|
user.organizationId,
|
||||||
|
query.page,
|
||||||
|
query.limit,
|
||||||
|
query.q,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('settings')
|
||||||
|
@Roles('owner', 'admin')
|
||||||
|
updateSettings(
|
||||||
|
@CurrentUser() user: { organizationId: string },
|
||||||
|
@Body() dto: RetentionDto,
|
||||||
|
) {
|
||||||
|
return this.deletions.updateRetention(user.organizationId, dto.retention);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/restore')
|
||||||
|
@Roles('owner', 'admin')
|
||||||
|
restore(
|
||||||
|
@CurrentUser() user: { organizationId: string },
|
||||||
|
@Param('id') id: string,
|
||||||
|
) {
|
||||||
|
return this.deletions.restore(user.organizationId, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@Roles('owner', 'admin')
|
||||||
|
purge(
|
||||||
|
@CurrentUser() user: { organizationId: string },
|
||||||
|
@Param('id') id: string,
|
||||||
|
) {
|
||||||
|
return this.deletions.purge(user.organizationId, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import {
|
||||||
|
CallHandler,
|
||||||
|
ExecutionContext,
|
||||||
|
Injectable,
|
||||||
|
NestInterceptor,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { deletionContext } from './deletion-context';
|
||||||
|
|
||||||
|
type Authed = {
|
||||||
|
user?: { userId?: string; organizationId?: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DeletionsInterceptor implements NestInterceptor {
|
||||||
|
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||||
|
const req = context.switchToHttp().getRequest<Authed>();
|
||||||
|
const user = req.user;
|
||||||
|
return new Observable((subscriber) => {
|
||||||
|
const subscription = deletionContext.run(
|
||||||
|
{
|
||||||
|
userId: user?.userId ?? null,
|
||||||
|
organizationId: user?.organizationId ?? null,
|
||||||
|
},
|
||||||
|
() => next.handle().subscribe(subscriber),
|
||||||
|
);
|
||||||
|
return () => subscription.unsubscribe();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { APP_INTERCEPTOR } from '@nestjs/core';
|
||||||
|
import { DeletionsController } from './deletions.controller';
|
||||||
|
import { DeletionsInterceptor } from './deletions.interceptor';
|
||||||
|
import { DeletionsService } from './deletions.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [DeletionsController],
|
||||||
|
providers: [
|
||||||
|
DeletionsService,
|
||||||
|
{
|
||||||
|
provide: APP_INTERCEPTOR,
|
||||||
|
useClass: DeletionsInterceptor,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class DeletionsModule {}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Cron } from '@nestjs/schedule';
|
||||||
|
import type { DeletionRetention } from '@mento/shared';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { modelLabel, restoreArchive } from './deletion-archive';
|
||||||
|
|
||||||
|
const RETENTIONS = new Set<DeletionRetention>([
|
||||||
|
'month',
|
||||||
|
'two_months',
|
||||||
|
'three_months',
|
||||||
|
'six_months',
|
||||||
|
'year',
|
||||||
|
'never',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const RETENTION_DAYS: Record<DeletionRetention, number | null> = {
|
||||||
|
month: 30,
|
||||||
|
two_months: 60,
|
||||||
|
three_months: 90,
|
||||||
|
six_months: 180,
|
||||||
|
year: 365,
|
||||||
|
never: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DeletionsService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async list(organizationId: string, page = 1, limit = 30, q?: string) {
|
||||||
|
const take = Math.min(100, Math.max(1, limit));
|
||||||
|
const skip = (Math.max(1, page) - 1) * take;
|
||||||
|
const query = q?.trim();
|
||||||
|
const where = {
|
||||||
|
organizationId,
|
||||||
|
...(query
|
||||||
|
? {
|
||||||
|
OR: [
|
||||||
|
{ label: { contains: query, mode: 'insensitive' as const } },
|
||||||
|
{ model: { contains: query, mode: 'insensitive' as const } },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
const [rows, total, org] = await Promise.all([
|
||||||
|
this.prisma.deletionArchive.findMany({
|
||||||
|
where,
|
||||||
|
include: { deletedBy: { select: { id: true, fullName: true } } },
|
||||||
|
orderBy: { deletedAt: 'desc' },
|
||||||
|
skip,
|
||||||
|
take,
|
||||||
|
}),
|
||||||
|
this.prisma.deletionArchive.count({ where }),
|
||||||
|
this.prisma.organization.findUnique({
|
||||||
|
where: { id: organizationId },
|
||||||
|
select: { deletionRetention: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
items: rows.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
model: row.model,
|
||||||
|
modelLabel: modelLabel(row.model),
|
||||||
|
entityId: row.entityId,
|
||||||
|
label: row.label,
|
||||||
|
deletedById: row.deletedById,
|
||||||
|
deletedByName: row.deletedBy?.fullName ?? null,
|
||||||
|
deletedAt: row.deletedAt.toISOString(),
|
||||||
|
})),
|
||||||
|
total,
|
||||||
|
page: Math.max(1, page),
|
||||||
|
limit: take,
|
||||||
|
retention: this.retentionOf(org?.deletionRetention),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateRetention(organizationId: string, retention: string) {
|
||||||
|
if (!RETENTIONS.has(retention as DeletionRetention)) {
|
||||||
|
throw new BadRequestException('مدت نگهداری معتبر نیست.');
|
||||||
|
}
|
||||||
|
await this.prisma.organization.update({
|
||||||
|
where: { id: organizationId },
|
||||||
|
data: { deletionRetention: retention },
|
||||||
|
});
|
||||||
|
return { retention: retention as DeletionRetention };
|
||||||
|
}
|
||||||
|
|
||||||
|
async restore(organizationId: string, id: string) {
|
||||||
|
const row = await this.owned(organizationId, id);
|
||||||
|
try {
|
||||||
|
await restoreArchive(
|
||||||
|
this.prisma,
|
||||||
|
row.snapshot as never,
|
||||||
|
row.model,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : '';
|
||||||
|
if (message === 'این مورد هنوز در سامانه وجود دارد.') {
|
||||||
|
await this.prisma.deletionArchive.delete({ where: { id: row.id } });
|
||||||
|
}
|
||||||
|
throw new BadRequestException(message || 'بازگردانی ناموفق بود.');
|
||||||
|
}
|
||||||
|
await this.prisma.deletionArchive.delete({ where: { id: row.id } });
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async purge(organizationId: string, id: string) {
|
||||||
|
const row = await this.owned(organizationId, id);
|
||||||
|
await this.prisma.deletionArchive.delete({ where: { id: row.id } });
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Cron('15 3 * * *', { timeZone: 'Asia/Tehran' })
|
||||||
|
async purgeExpired() {
|
||||||
|
const orgs = await this.prisma.organization.findMany({
|
||||||
|
select: { id: true, deletionRetention: true },
|
||||||
|
});
|
||||||
|
const now = Date.now();
|
||||||
|
for (const org of orgs) {
|
||||||
|
const days = RETENTION_DAYS[this.retentionOf(org.deletionRetention)];
|
||||||
|
if (!days) continue;
|
||||||
|
const cutoff = new Date(now - days * 24 * 60 * 60 * 1000);
|
||||||
|
await this.prisma.deletionArchive.deleteMany({
|
||||||
|
where: { organizationId: org.id, deletedAt: { lt: cutoff } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private retentionOf(value: string | null | undefined): DeletionRetention {
|
||||||
|
if (value && RETENTIONS.has(value as DeletionRetention)) {
|
||||||
|
return value as DeletionRetention;
|
||||||
|
}
|
||||||
|
return 'never';
|
||||||
|
}
|
||||||
|
|
||||||
|
private async owned(organizationId: string, id: string) {
|
||||||
|
const row = await this.prisma.deletionArchive.findFirst({
|
||||||
|
where: { id, organizationId },
|
||||||
|
});
|
||||||
|
if (!row) throw new NotFoundException('این حذف در فهرست پیدا نشد.');
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -69,6 +69,15 @@ export class PipelinesController {
|
|||||||
return this.pipelinesService.merge(user.organizationId, pipelineId, dto);
|
return this.pipelinesService.merge(user.organizationId, pipelineId, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Delete(':pipelineId')
|
||||||
|
@Roles('owner', 'admin')
|
||||||
|
remove(
|
||||||
|
@CurrentUser() user: { organizationId: string },
|
||||||
|
@Param('pipelineId') pipelineId: string,
|
||||||
|
) {
|
||||||
|
return this.pipelinesService.remove(user.organizationId, pipelineId);
|
||||||
|
}
|
||||||
|
|
||||||
@Post(':pipelineId/stages')
|
@Post(':pipelineId/stages')
|
||||||
@Roles('owner', 'admin')
|
@Roles('owner', 'admin')
|
||||||
createStage(
|
createStage(
|
||||||
|
|||||||
@@ -107,7 +107,18 @@ export class PipelinesService {
|
|||||||
include: { stages: { orderBy: { position: 'asc' } } },
|
include: { stages: { orderBy: { position: 'asc' } } },
|
||||||
orderBy: [{ isDefault: 'desc' }, { name: 'asc' }],
|
orderBy: [{ isDefault: 'desc' }, { name: 'asc' }],
|
||||||
});
|
});
|
||||||
return rows.map((row) => this.toPipelineDto(row));
|
const counts = await this.prisma.deal.groupBy({
|
||||||
|
by: ['pipelineId'],
|
||||||
|
where: { organizationId },
|
||||||
|
_count: { _all: true },
|
||||||
|
});
|
||||||
|
const dealCountByPipeline = new Map(
|
||||||
|
counts.map((row) => [row.pipelineId, row._count._all]),
|
||||||
|
);
|
||||||
|
return rows.map((row) => ({
|
||||||
|
...this.toPipelineDto(row),
|
||||||
|
dealCount: dealCountByPipeline.get(row.id) ?? 0,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(
|
async create(
|
||||||
@@ -192,6 +203,29 @@ export class PipelinesService {
|
|||||||
return this.getOwnedPipelineDto(organizationId, pipeline.id);
|
return this.getOwnedPipelineDto(organizationId, pipeline.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async remove(organizationId: string, pipelineId: string): Promise<void> {
|
||||||
|
const pipeline = await this.getOwnedPipeline(organizationId, pipelineId);
|
||||||
|
await this.prisma.$transaction(async (tx) => {
|
||||||
|
await tx.deal.deleteMany({
|
||||||
|
where: { organizationId, pipelineId: pipeline.id },
|
||||||
|
});
|
||||||
|
if (pipeline.isDefault) {
|
||||||
|
const next = await tx.pipeline.findFirst({
|
||||||
|
where: { organizationId, id: { not: pipeline.id } },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (next) {
|
||||||
|
await tx.pipeline.update({
|
||||||
|
where: { id: next.id },
|
||||||
|
data: { isDefault: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await tx.pipeline.delete({ where: { id: pipeline.id } });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async deleteStage(
|
async deleteStage(
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
pipelineId: string,
|
pipelineId: string,
|
||||||
|
|||||||
@@ -1,13 +1,34 @@
|
|||||||
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { withDeletionArchive } from '../deletions/deletion-archive';
|
||||||
|
|
||||||
|
const base = new PrismaClient();
|
||||||
|
const client = withDeletionArchive(base);
|
||||||
|
|
||||||
|
export interface PrismaService extends PrismaClient {}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
export class PrismaService implements OnModuleInit, OnModuleDestroy {
|
||||||
|
constructor() {
|
||||||
|
return new Proxy(this, {
|
||||||
|
get: (target, prop, receiver) => {
|
||||||
|
if (Reflect.has(target, prop)) return Reflect.get(target, prop, receiver);
|
||||||
|
const value = (client as unknown as Record<PropertyKey, unknown>)[prop];
|
||||||
|
if (typeof value === 'function') {
|
||||||
|
return (value as (...args: unknown[]) => unknown).bind(client);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async onModuleInit() {
|
async onModuleInit() {
|
||||||
await this.$connect();
|
await client.$connect();
|
||||||
}
|
}
|
||||||
|
|
||||||
async onModuleDestroy() {
|
async onModuleDestroy() {
|
||||||
await this.$disconnect();
|
await client.$disconnect();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type PrismaServiceClient = typeof client;
|
||||||
|
|||||||
@@ -36,7 +36,8 @@ export type NavIconName =
|
|||||||
| 'plus'
|
| 'plus'
|
||||||
| 'user-plus'
|
| 'user-plus'
|
||||||
| 'mail'
|
| 'mail'
|
||||||
| 'phone';
|
| 'phone'
|
||||||
|
| 'trash';
|
||||||
|
|
||||||
export type NavHubTint = 'mint' | 'violet' | 'peach' | 'sky' | 'rose' | 'amber';
|
export type NavHubTint = 'mint' | 'violet' | 'peach' | 'sky' | 'rose' | 'amber';
|
||||||
|
|
||||||
@@ -437,6 +438,14 @@ export const NAV_HUBS: NavHubDef[] = [
|
|||||||
module: 'audit_logs',
|
module: 'audit_logs',
|
||||||
admin: true,
|
admin: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
to: '/settings/deletions',
|
||||||
|
title: 'لاگ حذفیات',
|
||||||
|
desc: 'چه کسی چه چیزی را حذف کرده، بازگردانی یا حذف کامل',
|
||||||
|
icon: 'trash',
|
||||||
|
tint: 'rose',
|
||||||
|
admin: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
to: '/settings/public-api',
|
to: '/settings/public-api',
|
||||||
title: 'API و وبهوک',
|
title: 'API و وبهوک',
|
||||||
|
|||||||
@@ -361,6 +361,12 @@ export const router = createRouter({
|
|||||||
component: () => import('@/views/AuditLogsView.vue'),
|
component: () => import('@/views/AuditLogsView.vue'),
|
||||||
meta: { roles: ['owner', 'admin'] as UserRole[] },
|
meta: { roles: ['owner', 'admin'] as UserRole[] },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'settings/deletions',
|
||||||
|
name: 'deletion-archives',
|
||||||
|
component: () => import('@/views/DeletionArchivesView.vue'),
|
||||||
|
meta: { roles: ['owner', 'admin'] as UserRole[] },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'settings/public-api',
|
path: 'settings/public-api',
|
||||||
name: 'public-api',
|
name: 'public-api',
|
||||||
|
|||||||
@@ -0,0 +1,305 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue';
|
||||||
|
import axios from 'axios';
|
||||||
|
import type { DeletionArchiveListResponse, DeletionRetention } from '@mento/shared';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import { formatJalaliDateTime, toFaDigits } from '@/lib/jalali';
|
||||||
|
import { UiButton, UiCard, UiEmptyState, UiModal, UiPageHeader } from '@/components/ui';
|
||||||
|
|
||||||
|
const RETENTION_OPTIONS: Array<{ value: DeletionRetention; label: string }> = [
|
||||||
|
{ value: 'month', label: 'یک ماه' },
|
||||||
|
{ value: 'two_months', label: 'دو ماه' },
|
||||||
|
{ value: 'three_months', label: 'سه ماه' },
|
||||||
|
{ value: 'six_months', label: 'شش ماه' },
|
||||||
|
{ value: 'year', label: 'یک سال' },
|
||||||
|
{ value: 'never', label: 'هرگز' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const savingRetention = ref(false);
|
||||||
|
const busyId = ref('');
|
||||||
|
const error = ref('');
|
||||||
|
const okMsg = ref('');
|
||||||
|
const q = ref('');
|
||||||
|
const page = ref(1);
|
||||||
|
const retention = ref<DeletionRetention>('never');
|
||||||
|
const data = ref<DeletionArchiveListResponse | null>(null);
|
||||||
|
const purgeId = ref<string | null>(null);
|
||||||
|
|
||||||
|
const totalPages = computed(() => {
|
||||||
|
if (!data.value) return 1;
|
||||||
|
return Math.max(1, Math.ceil(data.value.total / data.value.limit));
|
||||||
|
});
|
||||||
|
|
||||||
|
const purgeItem = computed(() => data.value?.items.find((item) => item.id === purgeId.value) ?? null);
|
||||||
|
|
||||||
|
function apiError(e: unknown, fallback: string) {
|
||||||
|
if (axios.isAxiosError(e)) {
|
||||||
|
const msg = (e.response?.data as { message?: string | string[] })?.message;
|
||||||
|
if (!e.response || e.response.status === 404) return fallback;
|
||||||
|
if (Array.isArray(msg)) return msg.join('، ');
|
||||||
|
if (msg) return msg;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load(resetPage = false) {
|
||||||
|
if (resetPage) page.value = 1;
|
||||||
|
loading.value = true;
|
||||||
|
error.value = '';
|
||||||
|
try {
|
||||||
|
const { data: res } = await api.get<DeletionArchiveListResponse>('/deletions', {
|
||||||
|
params: { q: q.value.trim() || undefined, page: page.value, limit: 30 },
|
||||||
|
});
|
||||||
|
data.value = res;
|
||||||
|
retention.value = res.retention;
|
||||||
|
} catch (e) {
|
||||||
|
error.value = apiError(e, 'بارگذاری لاگ حذف ناموفق بود.');
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveRetention() {
|
||||||
|
savingRetention.value = true;
|
||||||
|
error.value = '';
|
||||||
|
okMsg.value = '';
|
||||||
|
try {
|
||||||
|
await api.patch('/deletions/settings', { retention: retention.value });
|
||||||
|
okMsg.value = 'مدت نگهداری ذخیره شد.';
|
||||||
|
} catch (e) {
|
||||||
|
error.value = apiError(e, 'ذخیره مدت نگهداری ناموفق بود.');
|
||||||
|
} finally {
|
||||||
|
savingRetention.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restore(id: string) {
|
||||||
|
busyId.value = id;
|
||||||
|
error.value = '';
|
||||||
|
okMsg.value = '';
|
||||||
|
try {
|
||||||
|
await api.post(`/deletions/${id}/restore`);
|
||||||
|
okMsg.value = 'مورد برگردانده شد.';
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
error.value = apiError(e, 'بازگردانی ناموفق بود.');
|
||||||
|
} finally {
|
||||||
|
busyId.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmPurge() {
|
||||||
|
const id = purgeId.value;
|
||||||
|
if (!id) return;
|
||||||
|
busyId.value = id;
|
||||||
|
error.value = '';
|
||||||
|
okMsg.value = '';
|
||||||
|
try {
|
||||||
|
await api.delete(`/deletions/${id}`);
|
||||||
|
purgeId.value = null;
|
||||||
|
okMsg.value = 'این مورد برای همیشه پاک شد.';
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
error.value = apiError(e, 'حذف کامل ناموفق بود.');
|
||||||
|
} finally {
|
||||||
|
busyId.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function go(delta: number) {
|
||||||
|
page.value = Math.min(totalPages.value, Math.max(1, page.value + delta));
|
||||||
|
void load();
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void load();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="page">
|
||||||
|
<UiPageHeader
|
||||||
|
title="لاگ حذفیات"
|
||||||
|
lead="هر حذف اینجا میماند. میتوانید برگردانید یا برای همیشه پاک کنید."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p v-if="okMsg" class="ok">{{ okMsg }}</p>
|
||||||
|
<p v-if="error" class="error">{{ error }}</p>
|
||||||
|
|
||||||
|
<UiCard>
|
||||||
|
<div class="retention">
|
||||||
|
<label for="deletion-retention">حذف خودکار از این فهرست</label>
|
||||||
|
<select id="deletion-retention" v-model="retention">
|
||||||
|
<option v-for="option in RETENTION_OPTIONS" :key="option.value" :value="option.value">
|
||||||
|
{{ option.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<UiButton size="sm" :loading="savingRetention" @click="saveRetention">ذخیره</UiButton>
|
||||||
|
</div>
|
||||||
|
<p class="hint">
|
||||||
|
تا پایان این مدت میتوان مورد را برگرداند. «هرگز» یعنی فقط با حذف کامل از بین میرود.
|
||||||
|
</p>
|
||||||
|
</UiCard>
|
||||||
|
|
||||||
|
<form class="search" @submit.prevent="load(true)">
|
||||||
|
<input v-model="q" type="search" placeholder="جستجو در عنوان یا نوع" />
|
||||||
|
<UiButton type="submit" variant="secondary" size="sm">جستجو</UiButton>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p v-if="loading" class="muted">در حال بارگذاری…</p>
|
||||||
|
<UiEmptyState
|
||||||
|
v-else-if="!error && !data?.items.length"
|
||||||
|
title="حذفی ثبت نشده."
|
||||||
|
description="از این به بعد حذفها با نام کاربر و زمان اینجا دیده میشوند."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div v-else-if="data?.items.length" class="list">
|
||||||
|
<article v-for="item in data.items" :key="item.id" class="row">
|
||||||
|
<div class="info">
|
||||||
|
<strong>{{ item.label }}</strong>
|
||||||
|
<span>{{ item.modelLabel }}</span>
|
||||||
|
<span>
|
||||||
|
{{ item.deletedByName || 'سیستم' }}
|
||||||
|
·
|
||||||
|
{{ formatJalaliDateTime(new Date(item.deletedAt)) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="ops">
|
||||||
|
<UiButton size="sm" variant="secondary" :disabled="busyId === item.id" @click="restore(item.id)">
|
||||||
|
بازگردانی
|
||||||
|
</UiButton>
|
||||||
|
<UiButton size="sm" variant="danger" :disabled="busyId === item.id" @click="purgeId = item.id">
|
||||||
|
حذف کامل
|
||||||
|
</UiButton>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="data && data.total > data.limit" class="pager">
|
||||||
|
<UiButton size="sm" variant="ghost" :disabled="page <= 1" @click="go(-1)">قبلی</UiButton>
|
||||||
|
<span>{{ toFaDigits(page) }} از {{ toFaDigits(totalPages) }}</span>
|
||||||
|
<UiButton size="sm" variant="ghost" :disabled="page >= totalPages" @click="go(1)">بعدی</UiButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UiModal :open="!!purgeItem" title="حذف کامل" @close="purgeId = null">
|
||||||
|
<p v-if="purgeItem" class="confirm">
|
||||||
|
«{{ purgeItem.label }}» برای همیشه پاک شود؟ بعد از این دیگر قابل بازگشت نیست.
|
||||||
|
</p>
|
||||||
|
<template #footer>
|
||||||
|
<UiButton variant="ghost" :disabled="!!busyId" @click="purgeId = null">انصراف</UiButton>
|
||||||
|
<UiButton variant="danger" :loading="!!busyId" @click="confirmPurge">حذف کامل</UiButton>
|
||||||
|
</template>
|
||||||
|
</UiModal>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.retention {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.retention label {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.retention select,
|
||||||
|
.search input {
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fff;
|
||||||
|
color: #0f172a;
|
||||||
|
font: inherit;
|
||||||
|
padding: 0.45rem 0.7rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint,
|
||||||
|
.muted {
|
||||||
|
margin: 0.55rem 0 0;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search input {
|
||||||
|
flex: 1 1 12rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.8rem 0.9rem;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e7eef2;
|
||||||
|
border-radius: 14px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.15rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info strong,
|
||||||
|
.info span {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info span {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pager {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm {
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ok {
|
||||||
|
margin: 0;
|
||||||
|
color: #0f766e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
margin: 0;
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -20,6 +20,9 @@ const pipelines = ref<PipelineListItem[]>([]);
|
|||||||
const name = ref('');
|
const name = ref('');
|
||||||
const mergeOpen = ref(false);
|
const mergeOpen = ref(false);
|
||||||
const mergeSourceId = ref<string | null>(null);
|
const mergeSourceId = ref<string | null>(null);
|
||||||
|
const deleteTarget = ref<PipelineListItem | null>(null);
|
||||||
|
const deleteCountReady = ref(false);
|
||||||
|
const deleting = ref(false);
|
||||||
|
|
||||||
const canMerge = computed(() => pipelines.value.length >= 2);
|
const canMerge = computed(() => pipelines.value.length >= 2);
|
||||||
|
|
||||||
@@ -67,6 +70,61 @@ async function createPipeline() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function deletePrompt(pipeline: PipelineListItem) {
|
||||||
|
if (!deleteCountReady.value) return 'در حال شمارش معاملات این مسیر…';
|
||||||
|
const count = pipeline.dealCount ?? 0;
|
||||||
|
const name = pipeline.name;
|
||||||
|
if (count > 0) {
|
||||||
|
return `مسیر «${name}» شامل ${toFaDigits(count)} معامله است. با حذف، این معاملات هم پاک میشوند. از حذف مطمئن هستید؟`;
|
||||||
|
}
|
||||||
|
return `مسیر «${name}» معاملهای ندارد. از حذف آن مطمئن هستید؟`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openDelete(pipeline: PipelineListItem) {
|
||||||
|
deleteTarget.value = pipeline;
|
||||||
|
deleteCountReady.value = pipeline.dealCount != null;
|
||||||
|
if (deleteCountReady.value) return;
|
||||||
|
try {
|
||||||
|
const { data } = await api.get<{ dealCount: number }>(
|
||||||
|
`/pipelines/${pipeline.id}/merge-preview`,
|
||||||
|
);
|
||||||
|
if (deleteTarget.value?.id !== pipeline.id) return;
|
||||||
|
deleteTarget.value = { ...pipeline, dealCount: data.dealCount };
|
||||||
|
} catch (e) {
|
||||||
|
if (deleteTarget.value?.id === pipeline.id) {
|
||||||
|
error.value = apiError(e, 'شمارش معاملات این مسیر ناموفق بود.');
|
||||||
|
deleteTarget.value = null;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
} finally {
|
||||||
|
if (deleteTarget.value?.id === pipeline.id) deleteCountReady.value = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDelete() {
|
||||||
|
if (deleting.value) return;
|
||||||
|
deleteTarget.value = null;
|
||||||
|
deleteCountReady.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDelete() {
|
||||||
|
const pipeline = deleteTarget.value;
|
||||||
|
if (!pipeline || deleting.value) return;
|
||||||
|
deleting.value = true;
|
||||||
|
error.value = '';
|
||||||
|
okMsg.value = '';
|
||||||
|
try {
|
||||||
|
await api.delete(`/pipelines/${pipeline.id}`);
|
||||||
|
okMsg.value = 'کاریز حذف شد.';
|
||||||
|
deleteTarget.value = null;
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
error.value = apiError(e, 'حذف کاریز ناموفق بود.');
|
||||||
|
} finally {
|
||||||
|
deleting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function openMerge(pipeline: PipelineListItem) {
|
function openMerge(pipeline: PipelineListItem) {
|
||||||
if (!canMerge.value) return;
|
if (!canMerge.value) return;
|
||||||
mergeSourceId.value = pipeline.id;
|
mergeSourceId.value = pipeline.id;
|
||||||
@@ -142,6 +200,9 @@ onMounted(load);
|
|||||||
>
|
>
|
||||||
ادغام
|
ادغام
|
||||||
</UiButton>
|
</UiButton>
|
||||||
|
<UiButton size="sm" variant="danger" @click="openDelete(pipeline)">
|
||||||
|
حذف
|
||||||
|
</UiButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</UiCard>
|
</UiCard>
|
||||||
@@ -153,6 +214,25 @@ onMounted(load);
|
|||||||
description="اولین مسیر فروش را اضافه کنید."
|
description="اولین مسیر فروش را اضافه کنید."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<UiModal
|
||||||
|
:open="!!deleteTarget"
|
||||||
|
title="حذف کاریز"
|
||||||
|
@close="closeDelete"
|
||||||
|
>
|
||||||
|
<p v-if="deleteTarget" class="confirm">{{ deletePrompt(deleteTarget) }}</p>
|
||||||
|
<template #footer>
|
||||||
|
<UiButton variant="ghost" :disabled="deleting" @click="closeDelete">انصراف</UiButton>
|
||||||
|
<UiButton
|
||||||
|
variant="danger"
|
||||||
|
:loading="deleting"
|
||||||
|
:disabled="!deleteCountReady"
|
||||||
|
@click="confirmDelete"
|
||||||
|
>
|
||||||
|
حذف کاریز
|
||||||
|
</UiButton>
|
||||||
|
</template>
|
||||||
|
</UiModal>
|
||||||
|
|
||||||
<UiModal :open="mergeOpen" title="ادغام کاریز" @close="closeMerge">
|
<UiModal :open="mergeOpen" title="ادغام کاریز" @close="closeMerge">
|
||||||
<PipelineMergeWizard
|
<PipelineMergeWizard
|
||||||
:open="mergeOpen"
|
:open="mergeOpen"
|
||||||
@@ -242,6 +322,12 @@ onMounted(load);
|
|||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.confirm {
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.8;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
.ok {
|
.ok {
|
||||||
color: #0f766e;
|
color: #0f766e;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|||||||
@@ -930,6 +930,8 @@ export interface PipelineDto {
|
|||||||
name: string;
|
name: string;
|
||||||
isDefault: boolean;
|
isDefault: boolean;
|
||||||
stages: PipelineStageDto[];
|
stages: PipelineStageDto[];
|
||||||
|
/** تعداد معاملات؛ در فهرست کاریزها پر میشود */
|
||||||
|
dealCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DealDto {
|
export interface DealDto {
|
||||||
@@ -4011,6 +4013,33 @@ export interface AuditLogListResponse {
|
|||||||
limit: number;
|
limit: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type DeletionRetention =
|
||||||
|
| 'month'
|
||||||
|
| 'two_months'
|
||||||
|
| 'three_months'
|
||||||
|
| 'six_months'
|
||||||
|
| 'year'
|
||||||
|
| 'never';
|
||||||
|
|
||||||
|
export interface DeletionArchiveDto {
|
||||||
|
id: string;
|
||||||
|
model: string;
|
||||||
|
modelLabel: string;
|
||||||
|
entityId: string;
|
||||||
|
label: string;
|
||||||
|
deletedById: string | null;
|
||||||
|
deletedByName: string | null;
|
||||||
|
deletedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeletionArchiveListResponse {
|
||||||
|
items: DeletionArchiveDto[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
retention: DeletionRetention;
|
||||||
|
}
|
||||||
|
|
||||||
/* ——— فیلترهای ذخیرهشده ——— */
|
/* ——— فیلترهای ذخیرهشده ——— */
|
||||||
|
|
||||||
export type SavedViewScope =
|
export type SavedViewScope =
|
||||||
|
|||||||
Reference in New Issue
Block a user