Add Didar gap features: lost-reason reports, audit logs, tags, card assignees, and saved-views API.

Ignore Graphify local artifacts and expose new modules in superadmin.
This commit is contained in:
2026-09-26 14:59:48 +03:30
parent 35a618d0af
commit 6463275c3d
30 changed files with 1406 additions and 21 deletions
@@ -0,0 +1,59 @@
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { 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 { OrgModuleGuard } from '../billing/org-module.guard';
import { RequireOrgModule } from '../billing/org-module.decorator';
import { AuditLogsService } from './audit-logs.service';
class ListAuditQueryDto {
@IsOptional()
@IsString()
q?: string;
@IsOptional()
@IsString()
actorUserId?: string;
@IsOptional()
@IsString()
entityType?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
limit?: number;
}
@Controller('audit-logs')
@UseGuards(JwtAuthGuard, RolesGuard, OrgModuleGuard)
@RequireOrgModule('audit_logs')
@Roles('owner', 'admin')
export class AuditLogsController {
constructor(private readonly auditLogs: AuditLogsService) {}
@Get()
list(
@CurrentUser() user: { organizationId: string },
@Query() query: ListAuditQueryDto,
) {
return this.auditLogs.list(user.organizationId, {
q: query.q,
actorUserId: query.actorUserId,
entityType: query.entityType,
page: query.page,
limit: query.limit,
});
}
}
@@ -0,0 +1,100 @@
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import type { Request } from 'express';
import { Observable, tap } from 'rxjs';
import { AuditLogsService } from './audit-logs.service';
const SKIP_PREFIXES = [
'/api/health',
'/api/auth/login',
'/api/auth/register',
'/api/auth/otp',
'/api/auth/refresh',
'/api/portal',
'/api/public',
'/api/platform',
'/api/audit-logs',
];
type AuthedRequest = Request & {
user?: {
userId?: string;
id?: string;
organizationId?: string;
};
};
function inferEntity(path: string): { entityType: string | null; entityId: string | null } {
const clean = path.replace(/^\/api\/?/, '').split('?')[0] ?? '';
const parts = clean.split('/').filter(Boolean);
if (!parts.length) return { entityType: null, entityId: null };
const entityType = parts[0] ?? null;
const maybeId = parts[1];
const entityId =
maybeId && !['new', 'me', 'summary', 'dashboard', 'board'].includes(maybeId)
? maybeId
: null;
return { entityType, entityId };
}
function actionLabel(method: string, path: string): string {
const m = method.toUpperCase();
if (m === 'POST') return 'ایجاد';
if (m === 'PATCH' || m === 'PUT') return 'ویرایش';
if (m === 'DELETE') return 'حذف';
return m;
void path;
}
@Injectable()
export class AuditLogsInterceptor implements NestInterceptor {
constructor(private readonly audit: AuditLogsService) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const http = context.switchToHttp();
const req = http.getRequest<AuthedRequest>();
const method = (req.method ?? 'GET').toUpperCase();
if (!['POST', 'PATCH', 'PUT', 'DELETE'].includes(method)) {
return next.handle();
}
const path = req.originalUrl || req.url || '';
if (SKIP_PREFIXES.some((p) => path.startsWith(p))) {
return next.handle();
}
const organizationId = req.user?.organizationId;
if (!organizationId) return next.handle();
const actorUserId = req.user?.userId ?? req.user?.id ?? null;
const { entityType, entityId } = inferEntity(path);
const action = actionLabel(method, path);
const summary = `${action} ${entityType ?? 'منبع'} از مسیر ${path.split('?')[0]}`;
const ip =
(req.headers['x-forwarded-for'] as string | undefined)?.split(',')[0]?.trim() ||
req.ip ||
null;
return next.handle().pipe(
tap({
next: () => {
void this.audit.write({
organizationId,
actorUserId,
action,
method,
path: path.split('?')[0] ?? path,
entityType,
entityId,
summary,
ip,
});
},
}),
);
}
}
@@ -0,0 +1,21 @@
import { Module } from '@nestjs/common';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { AuthModule } from '../auth/auth.module';
import { BillingModule } from '../billing/billing.module';
import { AuditLogsController } from './audit-logs.controller';
import { AuditLogsInterceptor } from './audit-logs.interceptor';
import { AuditLogsService } from './audit-logs.service';
@Module({
imports: [AuthModule, BillingModule],
controllers: [AuditLogsController],
providers: [
AuditLogsService,
{
provide: APP_INTERCEPTOR,
useClass: AuditLogsInterceptor,
},
],
exports: [AuditLogsService],
})
export class AuditLogsModule {}
@@ -0,0 +1,118 @@
import { Injectable } from '@nestjs/common';
import type { AuditLogDto, AuditLogListResponse } from '@mento/shared';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
export type AuditWriteInput = {
organizationId: string;
actorUserId?: string | null;
action: string;
method: string;
path: string;
entityType?: string | null;
entityId?: string | null;
summary: string;
meta?: Record<string, unknown> | null;
ip?: string | null;
};
@Injectable()
export class AuditLogsService {
constructor(private readonly prisma: PrismaService) {}
async write(input: AuditWriteInput): Promise<void> {
try {
await this.prisma.auditLog.create({
data: {
organizationId: input.organizationId,
actorUserId: input.actorUserId ?? null,
action: input.action,
method: input.method.slice(0, 16),
path: input.path.slice(0, 400),
entityType: input.entityType ?? null,
entityId: input.entityId ?? null,
summary: input.summary.slice(0, 500),
meta:
input.meta == null
? undefined
: (input.meta as Prisma.InputJsonValue),
ip: input.ip ?? null,
},
});
} catch {
// never break the main request because of audit write failures
}
}
async list(
organizationId: string,
opts: {
q?: string;
actorUserId?: string;
entityType?: string;
page?: number;
limit?: number;
},
): Promise<AuditLogListResponse> {
const page = Math.max(1, opts.page ?? 1);
const limit = Math.min(100, Math.max(1, opts.limit ?? 30));
const where: Prisma.AuditLogWhereInput = {
organizationId,
...(opts.actorUserId ? { actorUserId: opts.actorUserId } : {}),
...(opts.entityType ? { entityType: opts.entityType } : {}),
...(opts.q?.trim()
? {
OR: [
{ summary: { contains: opts.q.trim(), mode: 'insensitive' } },
{ path: { contains: opts.q.trim(), mode: 'insensitive' } },
{ action: { contains: opts.q.trim(), mode: 'insensitive' } },
],
}
: {}),
};
const [total, rows] = await Promise.all([
this.prisma.auditLog.count({ where }),
this.prisma.auditLog.findMany({
where,
include: { actor: { select: { id: true, fullName: true } } },
orderBy: { createdAt: 'desc' },
skip: (page - 1) * limit,
take: limit,
}),
]);
return {
total,
page,
limit,
items: rows.map((row) => this.toDto(row)),
};
}
private toDto(row: {
id: string;
action: string;
method: string;
path: string;
entityType: string | null;
entityId: string | null;
summary: string;
ip: string | null;
createdAt: Date;
actor: { id: string; fullName: string } | null;
}): AuditLogDto {
return {
id: row.id,
action: row.action,
method: row.method,
path: row.path,
entityType: row.entityType,
entityId: row.entityId,
summary: row.summary,
actor: row.actor,
ip: row.ip,
createdAt: row.createdAt.toISOString(),
};
}
}