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:
@@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user