feat: implement audit logging system with service, controller, and interceptor to track user actions across core services
This commit is contained in:
16
package-lock.json
generated
16
package-lock.json
generated
@@ -57,6 +57,7 @@
|
|||||||
"mime-types": "^3.0.2",
|
"mime-types": "^3.0.2",
|
||||||
"multer": "^2.0.2",
|
"multer": "^2.0.2",
|
||||||
"nest-winston": "^1.10.2",
|
"nest-winston": "^1.10.2",
|
||||||
|
"nestjs-cls": "^6.2.0",
|
||||||
"nestjs-pino": "^4.5.0",
|
"nestjs-pino": "^4.5.0",
|
||||||
"nodemailer": "^7.0.11",
|
"nodemailer": "^7.0.11",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
@@ -14909,6 +14910,21 @@
|
|||||||
"winston": "^3.0.0"
|
"winston": "^3.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/nestjs-cls": {
|
||||||
|
"version": "6.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/nestjs-cls/-/nestjs-cls-6.2.0.tgz",
|
||||||
|
"integrity": "sha512-b2Remha7gV5gId3ezjr2tupjqqgYK7/JqjqX6oZ0ZIDFATUggKH1/32+ul2lOe7FepnHasDONDoePuWEE64cug==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@nestjs/common": ">= 10 < 12",
|
||||||
|
"@nestjs/core": ">= 10 < 12",
|
||||||
|
"reflect-metadata": "*",
|
||||||
|
"rxjs": ">= 7"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/nestjs-pino": {
|
"node_modules/nestjs-pino": {
|
||||||
"version": "4.5.0",
|
"version": "4.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/nestjs-pino/-/nestjs-pino-4.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/nestjs-pino/-/nestjs-pino-4.5.0.tgz",
|
||||||
|
|||||||
@@ -75,6 +75,7 @@
|
|||||||
"mime-types": "^3.0.2",
|
"mime-types": "^3.0.2",
|
||||||
"multer": "^2.0.2",
|
"multer": "^2.0.2",
|
||||||
"nest-winston": "^1.10.2",
|
"nest-winston": "^1.10.2",
|
||||||
|
"nestjs-cls": "^6.2.0",
|
||||||
"nestjs-pino": "^4.5.0",
|
"nestjs-pino": "^4.5.0",
|
||||||
"nodemailer": "^7.0.11",
|
"nodemailer": "^7.0.11",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
|
|||||||
@@ -166,6 +166,9 @@ model User {
|
|||||||
// Verification actions (admin)
|
// Verification actions (admin)
|
||||||
verificationActions VerificationHistory[] @relation("VerificationActions")
|
verificationActions VerificationHistory[] @relation("VerificationActions")
|
||||||
|
|
||||||
|
// Audit Log (actor)
|
||||||
|
auditLogs AuditLog[]
|
||||||
|
|
||||||
@@index([email])
|
@@index([email])
|
||||||
@@index([role])
|
@@index([role])
|
||||||
@@index([status])
|
@@index([status])
|
||||||
@@ -797,3 +800,27 @@ model VerificationHistory {
|
|||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
@@map("verification_history")
|
@@map("verification_history")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// AUDIT LOG
|
||||||
|
// ============================================
|
||||||
|
model AuditLog {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
actorId String? // null = system / unauthenticated
|
||||||
|
actorRole String? // USER, AGENT, ADMIN, SUPER_ADMIN, SYSTEM
|
||||||
|
action String // see AuditAction enum in audit.constants.ts
|
||||||
|
resourceType String? // User, AgentProfile, Subscription, Payment, etc.
|
||||||
|
resourceId String?
|
||||||
|
metadata Json? // { before, after, reason, params, ... }
|
||||||
|
ipAddress String?
|
||||||
|
userAgent String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
actor User? @relation(fields: [actorId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
@@index([actorId, createdAt])
|
||||||
|
@@index([resourceType, resourceId])
|
||||||
|
@@index([action, createdAt])
|
||||||
|
@@index([createdAt])
|
||||||
|
@@map("audit_logs")
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
|
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
|
||||||
import { PrismaService } from '../prisma';
|
import { PrismaService } from '../prisma';
|
||||||
import { CreateAgentTypeDto, UpdateAgentTypeDto } from './dto';
|
import { CreateAgentTypeDto, UpdateAgentTypeDto } from './dto';
|
||||||
|
import { AuditService } from '../audit/audit.service';
|
||||||
|
import { AuditAction } from '../audit/audit.constants';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AgentTypesService {
|
export class AgentTypesService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly audit: AuditService,
|
||||||
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new agent type
|
* Create a new agent type
|
||||||
@@ -88,13 +93,22 @@ export class AgentTypesService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.prisma.agentType.update({
|
const updated = await this.prisma.agentType.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: {
|
||||||
...(name && { name }),
|
...(name && { name }),
|
||||||
...rest,
|
...rest,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.audit.log({
|
||||||
|
action: AuditAction.AGENT_TYPE_UPDATE,
|
||||||
|
resourceType: 'AgentType',
|
||||||
|
resourceId: id,
|
||||||
|
metadata: { name: updated.name },
|
||||||
|
});
|
||||||
|
|
||||||
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -111,8 +125,17 @@ export class AgentTypesService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.prisma.agentType.delete({
|
const deleted = await this.prisma.agentType.delete({
|
||||||
where: { id },
|
where: { id },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.audit.log({
|
||||||
|
action: AuditAction.AGENT_TYPE_UPDATE,
|
||||||
|
resourceType: 'AgentType',
|
||||||
|
resourceId: id,
|
||||||
|
metadata: { action: 'delete', name: agentType.name },
|
||||||
|
});
|
||||||
|
|
||||||
|
return deleted;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ import {
|
|||||||
} from './dto';
|
} from './dto';
|
||||||
import { Prisma, Prisma as PrismaTypes, VerificationStatus } from '@prisma/client';
|
import { Prisma, Prisma as PrismaTypes, VerificationStatus } from '@prisma/client';
|
||||||
import { ConnectionRequestsService } from '../connection-requests/connection-requests.service';
|
import { ConnectionRequestsService } from '../connection-requests/connection-requests.service';
|
||||||
|
import { AuditService } from '../audit/audit.service';
|
||||||
|
import { AuditAction } from '../audit/audit.constants';
|
||||||
|
import { buildDiff } from '../audit/diff.util';
|
||||||
|
|
||||||
function generateSlug(firstName: string, lastName: string): string {
|
function generateSlug(firstName: string, lastName: string): string {
|
||||||
const base = `${firstName}-${lastName}`
|
const base = `${firstName}-${lastName}`
|
||||||
@@ -29,6 +32,7 @@ export class AgentsService {
|
|||||||
constructor(
|
constructor(
|
||||||
private prisma: PrismaService,
|
private prisma: PrismaService,
|
||||||
private connectionRequestsService: ConnectionRequestsService,
|
private connectionRequestsService: ConnectionRequestsService,
|
||||||
|
private audit: AuditService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -218,6 +222,29 @@ export class AgentsService {
|
|||||||
await this.syncServiceAreasToFieldValues(profile.id, serviceAreas);
|
await this.syncServiceAreasToFieldValues(profile.id, serviceAreas);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const diff = buildDiff(
|
||||||
|
profile as unknown as Record<string, unknown>,
|
||||||
|
updatedProfile as unknown as Record<string, unknown>,
|
||||||
|
[
|
||||||
|
'firstName',
|
||||||
|
'lastName',
|
||||||
|
'displayName',
|
||||||
|
'bio',
|
||||||
|
'phone',
|
||||||
|
'agentTypeId',
|
||||||
|
'isFeatured',
|
||||||
|
'slug',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
if (diff) {
|
||||||
|
this.audit.log({
|
||||||
|
action: AuditAction.AGENT_PROFILE_UPDATE,
|
||||||
|
resourceType: 'AgentProfile',
|
||||||
|
resourceId: profile.id,
|
||||||
|
metadata: diff,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return updatedProfile;
|
return updatedProfile;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -899,6 +926,25 @@ export class AgentsService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const auditAction =
|
||||||
|
data.status === VerificationStatus.APPROVED
|
||||||
|
? AuditAction.AGENT_VERIFICATION_APPROVE
|
||||||
|
: data.status === VerificationStatus.REJECTED
|
||||||
|
? AuditAction.AGENT_VERIFICATION_REJECT
|
||||||
|
: AuditAction.AGENT_VERIFICATION_SUBMIT;
|
||||||
|
|
||||||
|
this.audit.log({
|
||||||
|
action: auditAction,
|
||||||
|
resourceType: 'AgentProfile',
|
||||||
|
resourceId: profileId,
|
||||||
|
metadata: {
|
||||||
|
previousStatus: profile.verificationStatus,
|
||||||
|
newStatus: data.status,
|
||||||
|
note: data.note ?? null,
|
||||||
|
agentUserId: profile.userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
return updatedProfile;
|
return updatedProfile;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
|
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
|
||||||
import { APP_GUARD } from '@nestjs/core';
|
import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
|
||||||
import { ScheduleModule } from '@nestjs/schedule';
|
import { ScheduleModule } from '@nestjs/schedule';
|
||||||
import { EventEmitterModule } from '@nestjs/event-emitter';
|
import { EventEmitterModule } from '@nestjs/event-emitter';
|
||||||
|
import { ClsModule } from 'nestjs-cls';
|
||||||
|
|
||||||
import { configuration } from './config';
|
import { configuration } from './config';
|
||||||
import { RedisModule } from './common/redis/redis.module';
|
import { RedisModule } from './common/redis/redis.module';
|
||||||
import { PrismaModule } from './prisma';
|
import { PrismaModule } from './prisma';
|
||||||
import { AuthModule, JwtAuthGuard, RolesGuard } from './auth';
|
import { AuthModule, JwtAuthGuard, RolesGuard } from './auth';
|
||||||
|
import { AuditModule } from './audit';
|
||||||
|
import { AuditContextInterceptor } from './common/interceptors';
|
||||||
import { UsersModule } from './users';
|
import { UsersModule } from './users';
|
||||||
import { EmailModule } from './email';
|
import { EmailModule } from './email';
|
||||||
import { AgentTypesModule } from './agent-types';
|
import { AgentTypesModule } from './agent-types';
|
||||||
@@ -53,9 +56,18 @@ import { AppService } from './app.service';
|
|||||||
// Event Emitter
|
// Event Emitter
|
||||||
EventEmitterModule.forRoot(),
|
EventEmitterModule.forRoot(),
|
||||||
|
|
||||||
|
// Async Local Storage (request-scoped audit context)
|
||||||
|
ClsModule.forRoot({
|
||||||
|
global: true,
|
||||||
|
middleware: { mount: true, generateId: true },
|
||||||
|
}),
|
||||||
|
|
||||||
// Database
|
// Database
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
|
|
||||||
|
// Audit Log (global)
|
||||||
|
AuditModule,
|
||||||
|
|
||||||
// Feature Modules
|
// Feature Modules
|
||||||
AuthModule,
|
AuthModule,
|
||||||
UsersModule,
|
UsersModule,
|
||||||
@@ -92,6 +104,11 @@ import { AppService } from './app.service';
|
|||||||
provide: APP_GUARD,
|
provide: APP_GUARD,
|
||||||
useClass: RolesGuard,
|
useClass: RolesGuard,
|
||||||
},
|
},
|
||||||
|
// Global Audit Context Interceptor (populates CLS for AuditService)
|
||||||
|
{
|
||||||
|
provide: APP_INTERCEPTOR,
|
||||||
|
useClass: AuditContextInterceptor,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
67
src/audit/audit.constants.ts
Normal file
67
src/audit/audit.constants.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
export const AuditAction = {
|
||||||
|
// Auth
|
||||||
|
AUTH_SIGNUP: 'AUTH_SIGNUP',
|
||||||
|
AUTH_LOGIN_SUCCESS: 'AUTH_LOGIN_SUCCESS',
|
||||||
|
AUTH_LOGIN_FAIL: 'AUTH_LOGIN_FAIL',
|
||||||
|
AUTH_LOGOUT: 'AUTH_LOGOUT',
|
||||||
|
AUTH_PASSWORD_RESET_REQUEST: 'AUTH_PASSWORD_RESET_REQUEST',
|
||||||
|
AUTH_PASSWORD_RESET_CONFIRM: 'AUTH_PASSWORD_RESET_CONFIRM',
|
||||||
|
AUTH_PASSWORD_CHANGE: 'AUTH_PASSWORD_CHANGE',
|
||||||
|
AUTH_EMAIL_CHANGE: 'AUTH_EMAIL_CHANGE',
|
||||||
|
AUTH_EMAIL_VERIFY: 'AUTH_EMAIL_VERIFY',
|
||||||
|
AUTH_2FA_ENABLE: 'AUTH_2FA_ENABLE',
|
||||||
|
AUTH_2FA_DISABLE: 'AUTH_2FA_DISABLE',
|
||||||
|
|
||||||
|
// User / role
|
||||||
|
USER_UPDATE: 'USER_UPDATE',
|
||||||
|
USER_DELETE: 'USER_DELETE',
|
||||||
|
USER_SUSPEND: 'USER_SUSPEND',
|
||||||
|
USER_UNSUSPEND: 'USER_UNSUSPEND',
|
||||||
|
USER_ROLE_CHANGE: 'USER_ROLE_CHANGE',
|
||||||
|
|
||||||
|
// Agent
|
||||||
|
AGENT_PROFILE_UPDATE: 'AGENT_PROFILE_UPDATE',
|
||||||
|
AGENT_VERIFICATION_SUBMIT: 'AGENT_VERIFICATION_SUBMIT',
|
||||||
|
AGENT_VERIFICATION_APPROVE: 'AGENT_VERIFICATION_APPROVE',
|
||||||
|
AGENT_VERIFICATION_REJECT: 'AGENT_VERIFICATION_REJECT',
|
||||||
|
|
||||||
|
// Billing
|
||||||
|
SUBSCRIPTION_CREATE: 'SUBSCRIPTION_CREATE',
|
||||||
|
SUBSCRIPTION_CANCEL: 'SUBSCRIPTION_CANCEL',
|
||||||
|
SUBSCRIPTION_PLAN_CHANGE: 'SUBSCRIPTION_PLAN_CHANGE',
|
||||||
|
PAYMENT_SUCCESS: 'PAYMENT_SUCCESS',
|
||||||
|
PAYMENT_FAILURE: 'PAYMENT_FAILURE',
|
||||||
|
PAYMENT_REFUND: 'PAYMENT_REFUND',
|
||||||
|
|
||||||
|
// Moderation
|
||||||
|
REPORT_STATUS_CHANGE: 'REPORT_STATUS_CHANGE',
|
||||||
|
CONTACT_MESSAGE_RESPOND: 'CONTACT_MESSAGE_RESPOND',
|
||||||
|
SUPPORT_TICKET_CLOSE: 'SUPPORT_TICKET_CLOSE',
|
||||||
|
|
||||||
|
// CMS
|
||||||
|
CMS_BLOG_PUBLISH: 'CMS_BLOG_PUBLISH',
|
||||||
|
CMS_BLOG_UNPUBLISH: 'CMS_BLOG_UNPUBLISH',
|
||||||
|
CMS_BLOG_DELETE: 'CMS_BLOG_DELETE',
|
||||||
|
CMS_TESTIMONIAL_CHANGE: 'CMS_TESTIMONIAL_CHANGE',
|
||||||
|
|
||||||
|
// Schema (high-impact admin config)
|
||||||
|
PROFILE_FIELD_UPDATE: 'PROFILE_FIELD_UPDATE',
|
||||||
|
PROFILE_SECTION_UPDATE: 'PROFILE_SECTION_UPDATE',
|
||||||
|
AGENT_TYPE_UPDATE: 'AGENT_TYPE_UPDATE',
|
||||||
|
|
||||||
|
// Connections
|
||||||
|
CONNECTION_REQUEST_ACCEPT: 'CONNECTION_REQUEST_ACCEPT',
|
||||||
|
CONNECTION_REQUEST_REJECT: 'CONNECTION_REQUEST_REJECT',
|
||||||
|
CONNECTION_REQUEST_CANCEL: 'CONNECTION_REQUEST_CANCEL',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type AuditAction = (typeof AuditAction)[keyof typeof AuditAction];
|
||||||
|
|
||||||
|
export const AUDIT_CTX_KEY = 'auditCtx';
|
||||||
|
|
||||||
|
export interface AuditContext {
|
||||||
|
userId?: string | null;
|
||||||
|
role?: string | null;
|
||||||
|
ip?: string | null;
|
||||||
|
userAgent?: string | null;
|
||||||
|
}
|
||||||
59
src/audit/audit.controller.ts
Normal file
59
src/audit/audit.controller.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||||
|
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger';
|
||||||
|
import { Prisma, UserRole } from '@prisma/client';
|
||||||
|
import { JwtAuthGuard, RolesGuard } from '../auth/guards';
|
||||||
|
import { Roles } from '../auth/decorators';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { AuditQueryDto } from './dto';
|
||||||
|
|
||||||
|
@ApiTags('Admin - Audit Log')
|
||||||
|
@Controller('admin/audit-log')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.ADMIN, UserRole.SUPER_ADMIN)
|
||||||
|
@ApiBearerAuth('JWT-auth')
|
||||||
|
export class AuditController {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({ summary: 'List audit log entries (admin)' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Paginated audit log' })
|
||||||
|
async list(@Query() query: AuditQueryDto) {
|
||||||
|
const page = query.page ?? 1;
|
||||||
|
const limit = query.limit ?? 25;
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
|
const where: Prisma.AuditLogWhereInput = {};
|
||||||
|
if (query.actorId) where.actorId = query.actorId;
|
||||||
|
if (query.action) where.action = query.action;
|
||||||
|
if (query.resourceType) where.resourceType = query.resourceType;
|
||||||
|
if (query.resourceId) where.resourceId = query.resourceId;
|
||||||
|
if (query.from || query.to) {
|
||||||
|
where.createdAt = {};
|
||||||
|
if (query.from) (where.createdAt as Prisma.DateTimeFilter).gte = new Date(query.from);
|
||||||
|
if (query.to) (where.createdAt as Prisma.DateTimeFilter).lte = new Date(query.to);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [items, total] = await Promise.all([
|
||||||
|
this.prisma.auditLog.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
skip,
|
||||||
|
take: limit,
|
||||||
|
include: {
|
||||||
|
actor: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
role: true,
|
||||||
|
userProfile: { select: { firstName: true, lastName: true } },
|
||||||
|
agentProfile: { select: { firstName: true, lastName: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.prisma.auditLog.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { items, total, page, limit };
|
||||||
|
}
|
||||||
|
}
|
||||||
11
src/audit/audit.module.ts
Normal file
11
src/audit/audit.module.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { AuditService } from './audit.service';
|
||||||
|
import { AuditController } from './audit.controller';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [AuditService],
|
||||||
|
controllers: [AuditController],
|
||||||
|
exports: [AuditService],
|
||||||
|
})
|
||||||
|
export class AuditModule {}
|
||||||
58
src/audit/audit.service.ts
Normal file
58
src/audit/audit.service.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ClsService } from 'nestjs-cls';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { AuditAction, AuditContext, AUDIT_CTX_KEY } from './audit.constants';
|
||||||
|
|
||||||
|
interface AuditLogInput {
|
||||||
|
action: AuditAction;
|
||||||
|
resourceType?: string;
|
||||||
|
resourceId?: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
/**
|
||||||
|
* Use when the actor differs from the current request user — e.g. failed
|
||||||
|
* logins (no req.user), system-driven webhook events, or impersonation.
|
||||||
|
*/
|
||||||
|
actorOverride?: { id: string | null; role: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuditService {
|
||||||
|
private readonly logger = new Logger(AuditService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly cls: ClsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist an audit row. Fire-and-forget: never awaited by callers, never
|
||||||
|
* surfaces errors to the request path.
|
||||||
|
*
|
||||||
|
* Future archival cutover point: a scheduled job can move rows older than
|
||||||
|
* the retention window to S3 and delete them here without app changes.
|
||||||
|
*/
|
||||||
|
log(input: AuditLogInput): void {
|
||||||
|
const ctx = this.cls.get<AuditContext>(AUDIT_CTX_KEY) ?? {};
|
||||||
|
const actor = input.actorOverride ?? { id: ctx.userId ?? null, role: ctx.role ?? 'SYSTEM' };
|
||||||
|
|
||||||
|
this.prisma.auditLog
|
||||||
|
.create({
|
||||||
|
data: {
|
||||||
|
actorId: actor.id ?? null,
|
||||||
|
actorRole: actor.role ?? 'SYSTEM',
|
||||||
|
action: input.action,
|
||||||
|
resourceType: input.resourceType ?? null,
|
||||||
|
resourceId: input.resourceId ?? null,
|
||||||
|
metadata: (input.metadata ?? null) as never,
|
||||||
|
ipAddress: ctx.ip ?? null,
|
||||||
|
userAgent: ctx.userAgent ?? null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.catch((err: Error) => {
|
||||||
|
this.logger.error(
|
||||||
|
`Audit write failed (action=${input.action}): ${err.message}`,
|
||||||
|
err.stack,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
39
src/audit/diff.util.ts
Normal file
39
src/audit/diff.util.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
function isDeepEqual(a: unknown, b: unknown): boolean {
|
||||||
|
if (a === b) return true;
|
||||||
|
if (a === null || b === null || a === undefined || b === undefined) return false;
|
||||||
|
if (typeof a !== typeof b) return false;
|
||||||
|
if (typeof a !== 'object') return false;
|
||||||
|
if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime();
|
||||||
|
if (Array.isArray(a) && Array.isArray(b)) {
|
||||||
|
if (a.length !== b.length) return false;
|
||||||
|
return a.every((v, i) => isDeepEqual(v, b[i]));
|
||||||
|
}
|
||||||
|
const ka = Object.keys(a as object);
|
||||||
|
const kb = Object.keys(b as object);
|
||||||
|
if (ka.length !== kb.length) return false;
|
||||||
|
return ka.every((k) =>
|
||||||
|
isDeepEqual((a as Record<string, unknown>)[k], (b as Record<string, unknown>)[k]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a before/after diff limited to the explicit field allowlist.
|
||||||
|
* Returns null when nothing in the allowlist changed.
|
||||||
|
*
|
||||||
|
* NEVER include sensitive fields (password, passwordHash, tokens) in the allowlist.
|
||||||
|
*/
|
||||||
|
export function buildDiff<T extends Record<string, unknown>>(
|
||||||
|
before: T,
|
||||||
|
after: T,
|
||||||
|
fields: (keyof T)[],
|
||||||
|
): { before: Partial<T>; after: Partial<T> } | null {
|
||||||
|
const b: Partial<T> = {};
|
||||||
|
const a: Partial<T> = {};
|
||||||
|
for (const f of fields) {
|
||||||
|
if (!isDeepEqual(before[f], after[f])) {
|
||||||
|
b[f] = before[f];
|
||||||
|
a[f] = after[f];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Object.keys(a).length === 0 ? null : { before: b, after: a };
|
||||||
|
}
|
||||||
50
src/audit/dto/audit-query.dto.ts
Normal file
50
src/audit/dto/audit-query.dto.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { IsOptional, IsString, IsInt, Min, Max, IsISO8601 } from 'class-validator';
|
||||||
|
|
||||||
|
export class AuditQueryDto {
|
||||||
|
@ApiPropertyOptional({ description: 'Filter by actor user id' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
actorId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Filter by AuditAction' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
action?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Filter by resource type (e.g. AgentProfile)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
resourceType?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Filter by resource id' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
resourceId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'ISO date — inclusive lower bound' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsISO8601()
|
||||||
|
from?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'ISO date — inclusive upper bound' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsISO8601()
|
||||||
|
to?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ default: 1, minimum: 1 })
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
page?: number = 1;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ default: 25, minimum: 1, maximum: 100 })
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(100)
|
||||||
|
limit?: number = 25;
|
||||||
|
}
|
||||||
1
src/audit/dto/index.ts
Normal file
1
src/audit/dto/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './audit-query.dto';
|
||||||
4
src/audit/index.ts
Normal file
4
src/audit/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export * from './audit.module';
|
||||||
|
export * from './audit.service';
|
||||||
|
export * from './audit.constants';
|
||||||
|
export * from './diff.util';
|
||||||
@@ -27,6 +27,8 @@ import {
|
|||||||
} from './dto';
|
} from './dto';
|
||||||
import { UserRole, AuthProvider, UserStatus } from '@prisma/client';
|
import { UserRole, AuthProvider, UserStatus } from '@prisma/client';
|
||||||
import { TwoFactorService } from './two-factor/two-factor.service';
|
import { TwoFactorService } from './two-factor/two-factor.service';
|
||||||
|
import { AuditService } from '../audit/audit.service';
|
||||||
|
import { AuditAction } from '../audit/audit.constants';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
@@ -37,6 +39,7 @@ export class AuthService {
|
|||||||
private readonly eventEmitter: EventEmitter2,
|
private readonly eventEmitter: EventEmitter2,
|
||||||
@Inject(forwardRef(() => TwoFactorService))
|
@Inject(forwardRef(() => TwoFactorService))
|
||||||
private readonly twoFactorService: TwoFactorService,
|
private readonly twoFactorService: TwoFactorService,
|
||||||
|
private readonly audit: AuditService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -119,6 +122,14 @@ export class AuthService {
|
|||||||
lastName: dto.lastName,
|
lastName: dto.lastName,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.audit.log({
|
||||||
|
action: AuditAction.AUTH_SIGNUP,
|
||||||
|
resourceType: 'User',
|
||||||
|
resourceId: user.id,
|
||||||
|
metadata: { email: user.email, role: user.role, provider: 'LOCAL' },
|
||||||
|
actorOverride: { id: user.id, role: user.role },
|
||||||
|
});
|
||||||
|
|
||||||
// Return message to verify email - no tokens until email is verified
|
// Return message to verify email - no tokens until email is verified
|
||||||
return {
|
return {
|
||||||
message: 'Registration successful! Please check your email to verify your account before logging in.',
|
message: 'Registration successful! Please check your email to verify your account before logging in.',
|
||||||
@@ -145,21 +156,47 @@ export class AuthService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
|
this.audit.log({
|
||||||
|
action: AuditAction.AUTH_LOGIN_FAIL,
|
||||||
|
metadata: { email: dto.email.toLowerCase(), reason: 'no_user' },
|
||||||
|
actorOverride: { id: null, role: 'SYSTEM' },
|
||||||
|
});
|
||||||
throw new UnauthorizedException('Invalid email or password');
|
throw new UnauthorizedException('Invalid email or password');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user.password) {
|
if (!user.password) {
|
||||||
|
this.audit.log({
|
||||||
|
action: AuditAction.AUTH_LOGIN_FAIL,
|
||||||
|
resourceType: 'User',
|
||||||
|
resourceId: user.id,
|
||||||
|
metadata: { email: user.email, reason: 'social_account' },
|
||||||
|
actorOverride: { id: null, role: 'SYSTEM' },
|
||||||
|
});
|
||||||
throw new UnauthorizedException(
|
throw new UnauthorizedException(
|
||||||
'This account uses social login. Please sign in with Google or Facebook.',
|
'This account uses social login. Please sign in with Google or Facebook.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user.status !== UserStatus.ACTIVE) {
|
if (user.status !== UserStatus.ACTIVE) {
|
||||||
|
this.audit.log({
|
||||||
|
action: AuditAction.AUTH_LOGIN_FAIL,
|
||||||
|
resourceType: 'User',
|
||||||
|
resourceId: user.id,
|
||||||
|
metadata: { email: user.email, reason: 'inactive', status: user.status },
|
||||||
|
actorOverride: { id: null, role: 'SYSTEM' },
|
||||||
|
});
|
||||||
throw new UnauthorizedException('Your account has been deactivated. Please contact support.');
|
throw new UnauthorizedException('Your account has been deactivated. Please contact support.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if email is verified
|
// Check if email is verified
|
||||||
if (!user.emailVerified) {
|
if (!user.emailVerified) {
|
||||||
|
this.audit.log({
|
||||||
|
action: AuditAction.AUTH_LOGIN_FAIL,
|
||||||
|
resourceType: 'User',
|
||||||
|
resourceId: user.id,
|
||||||
|
metadata: { email: user.email, reason: 'email_unverified' },
|
||||||
|
actorOverride: { id: null, role: 'SYSTEM' },
|
||||||
|
});
|
||||||
throw new UnauthorizedException(
|
throw new UnauthorizedException(
|
||||||
'Please verify your email before logging in. Check your inbox for the verification link.',
|
'Please verify your email before logging in. Check your inbox for the verification link.',
|
||||||
);
|
);
|
||||||
@@ -169,6 +206,13 @@ export class AuthService {
|
|||||||
const isPasswordValid = await argon2.verify(user.password, dto.password);
|
const isPasswordValid = await argon2.verify(user.password, dto.password);
|
||||||
|
|
||||||
if (!isPasswordValid) {
|
if (!isPasswordValid) {
|
||||||
|
this.audit.log({
|
||||||
|
action: AuditAction.AUTH_LOGIN_FAIL,
|
||||||
|
resourceType: 'User',
|
||||||
|
resourceId: user.id,
|
||||||
|
metadata: { email: user.email, reason: 'invalid_password' },
|
||||||
|
actorOverride: { id: null, role: 'SYSTEM' },
|
||||||
|
});
|
||||||
throw new UnauthorizedException('Invalid email or password');
|
throw new UnauthorizedException('Invalid email or password');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,6 +263,14 @@ export class AuthService {
|
|||||||
data: { lastLoginAt: new Date() },
|
data: { lastLoginAt: new Date() },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.audit.log({
|
||||||
|
action: AuditAction.AUTH_LOGIN_SUCCESS,
|
||||||
|
resourceType: 'User',
|
||||||
|
resourceId: user.id,
|
||||||
|
metadata: { provider: 'LOCAL', email: user.email },
|
||||||
|
actorOverride: { id: user.id, role: user.role },
|
||||||
|
});
|
||||||
|
|
||||||
// Get profile based on role
|
// Get profile based on role
|
||||||
const profile =
|
const profile =
|
||||||
user.role === UserRole.AGENT ? user.agentProfile : user.userProfile;
|
user.role === UserRole.AGENT ? user.agentProfile : user.userProfile;
|
||||||
@@ -367,6 +419,14 @@ export class AuthService {
|
|||||||
data: { lastLoginAt: new Date() },
|
data: { lastLoginAt: new Date() },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.audit.log({
|
||||||
|
action: AuditAction.AUTH_LOGIN_SUCCESS,
|
||||||
|
resourceType: 'User',
|
||||||
|
resourceId: user.id,
|
||||||
|
metadata: { provider: dto.provider?.toUpperCase(), email: user.email },
|
||||||
|
actorOverride: { id: user.id, role: user.role },
|
||||||
|
});
|
||||||
|
|
||||||
const profile =
|
const profile =
|
||||||
user.role === UserRole.AGENT ? user.agentProfile : user.userProfile;
|
user.role === UserRole.AGENT ? user.agentProfile : user.userProfile;
|
||||||
|
|
||||||
@@ -418,6 +478,14 @@ export class AuthService {
|
|||||||
resetUrl: `${this.configService.get<string>('app.frontendUrl')}/reset-password?token=${resetToken}`,
|
resetUrl: `${this.configService.get<string>('app.frontendUrl')}/reset-password?token=${resetToken}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.audit.log({
|
||||||
|
action: AuditAction.AUTH_PASSWORD_RESET_REQUEST,
|
||||||
|
resourceType: 'User',
|
||||||
|
resourceId: user.id,
|
||||||
|
metadata: { email: user.email },
|
||||||
|
actorOverride: { id: user.id, role: user.role },
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
message: 'Password reset link has been sent to your email',
|
message: 'Password reset link has been sent to your email',
|
||||||
};
|
};
|
||||||
@@ -464,6 +532,14 @@ export class AuthService {
|
|||||||
email: user.email,
|
email: user.email,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.audit.log({
|
||||||
|
action: AuditAction.AUTH_PASSWORD_RESET_CONFIRM,
|
||||||
|
resourceType: 'User',
|
||||||
|
resourceId: user.id,
|
||||||
|
metadata: { email: user.email, sessionsInvalidated: true },
|
||||||
|
actorOverride: { id: user.id, role: user.role },
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
message: 'Password reset successful. Please login with your new password.',
|
message: 'Password reset successful. Please login with your new password.',
|
||||||
};
|
};
|
||||||
@@ -529,6 +605,14 @@ export class AuthService {
|
|||||||
email: user.email,
|
email: user.email,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.audit.log({
|
||||||
|
action: AuditAction.AUTH_PASSWORD_CHANGE,
|
||||||
|
resourceType: 'User',
|
||||||
|
resourceId: user.id,
|
||||||
|
metadata: { email: user.email },
|
||||||
|
actorOverride: { id: user.id, role: user.role },
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
message: 'Password changed successfully',
|
message: 'Password changed successfully',
|
||||||
};
|
};
|
||||||
@@ -578,6 +662,14 @@ export class AuthService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.audit.log({
|
||||||
|
action: AuditAction.AUTH_EMAIL_CHANGE,
|
||||||
|
resourceType: 'User',
|
||||||
|
resourceId: user.id,
|
||||||
|
metadata: { from: user.email, to: newEmail },
|
||||||
|
actorOverride: { id: user.id, role: user.role },
|
||||||
|
});
|
||||||
|
|
||||||
return { message: 'Email changed and verified successfully' };
|
return { message: 'Email changed and verified successfully' };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -622,6 +714,14 @@ export class AuthService {
|
|||||||
name: profile?.firstName,
|
name: profile?.firstName,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.audit.log({
|
||||||
|
action: AuditAction.AUTH_EMAIL_VERIFY,
|
||||||
|
resourceType: 'User',
|
||||||
|
resourceId: user.id,
|
||||||
|
metadata: { email: user.email },
|
||||||
|
actorOverride: { id: user.id, role: user.role },
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
message: 'Email verified successfully',
|
message: 'Email verified successfully',
|
||||||
};
|
};
|
||||||
@@ -856,6 +956,13 @@ export class AuthService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.audit.log({
|
||||||
|
action: AuditAction.AUTH_LOGOUT,
|
||||||
|
resourceType: 'User',
|
||||||
|
resourceId: userId,
|
||||||
|
metadata: { allSessions: !accessToken },
|
||||||
|
});
|
||||||
|
|
||||||
return { message: 'Logged out successfully' };
|
return { message: 'Logged out successfully' };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -908,6 +1015,14 @@ export class AuthService {
|
|||||||
data: { lastLoginAt: new Date() },
|
data: { lastLoginAt: new Date() },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.audit.log({
|
||||||
|
action: AuditAction.AUTH_LOGIN_SUCCESS,
|
||||||
|
resourceType: 'User',
|
||||||
|
resourceId: user.id,
|
||||||
|
metadata: { provider: 'LOCAL', email: user.email },
|
||||||
|
actorOverride: { id: user.id, role: user.role },
|
||||||
|
});
|
||||||
|
|
||||||
// Get profile based on role
|
// Get profile based on role
|
||||||
const profile =
|
const profile =
|
||||||
user.role === UserRole.AGENT ? user.agentProfile : user.userProfile;
|
user.role === UserRole.AGENT ? user.agentProfile : user.userProfile;
|
||||||
@@ -977,6 +1092,14 @@ export class AuthService {
|
|||||||
data: { lastLoginAt: new Date() },
|
data: { lastLoginAt: new Date() },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.audit.log({
|
||||||
|
action: AuditAction.AUTH_LOGIN_SUCCESS,
|
||||||
|
resourceType: 'User',
|
||||||
|
resourceId: user.id,
|
||||||
|
metadata: { provider: 'LOCAL', email: user.email },
|
||||||
|
actorOverride: { id: user.id, role: user.role },
|
||||||
|
});
|
||||||
|
|
||||||
// Get profile based on role
|
// Get profile based on role
|
||||||
const profile =
|
const profile =
|
||||||
user.role === UserRole.AGENT ? user.agentProfile : user.userProfile;
|
user.role === UserRole.AGENT ? user.agentProfile : user.userProfile;
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { CreateCmsContentDto, UpdateCmsContentDto } from './dto';
|
import { CreateCmsContentDto, UpdateCmsContentDto } from './dto';
|
||||||
|
import { AuditService } from '../audit/audit.service';
|
||||||
|
import { AuditAction } from '../audit/audit.constants';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CmsService {
|
export class CmsService {
|
||||||
constructor(private prisma: PrismaService) {}
|
constructor(
|
||||||
|
private prisma: PrismaService,
|
||||||
|
private audit: AuditService,
|
||||||
|
) {}
|
||||||
|
|
||||||
async findByPage(pageSlug: string) {
|
async findByPage(pageSlug: string) {
|
||||||
return this.prisma.cmsContent.findMany({
|
return this.prisma.cmsContent.findMany({
|
||||||
@@ -49,10 +54,26 @@ export class CmsService {
|
|||||||
if (!existing) {
|
if (!existing) {
|
||||||
throw new NotFoundException(`CMS content with ID ${id} not found`);
|
throw new NotFoundException(`CMS content with ID ${id} not found`);
|
||||||
}
|
}
|
||||||
return this.prisma.cmsContent.update({
|
const updated = await this.prisma.cmsContent.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: dto,
|
data: dto,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof dto.isPublished === 'boolean' &&
|
||||||
|
existing.isPublished !== dto.isPublished
|
||||||
|
) {
|
||||||
|
this.audit.log({
|
||||||
|
action: dto.isPublished
|
||||||
|
? AuditAction.CMS_BLOG_PUBLISH
|
||||||
|
: AuditAction.CMS_BLOG_UNPUBLISH,
|
||||||
|
resourceType: 'CmsContent',
|
||||||
|
resourceId: id,
|
||||||
|
metadata: { pageSlug: existing.pageSlug, sectionKey: existing.sectionKey },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
async remove(id: string) {
|
async remove(id: string) {
|
||||||
@@ -60,6 +81,13 @@ export class CmsService {
|
|||||||
if (!existing) {
|
if (!existing) {
|
||||||
throw new NotFoundException(`CMS content with ID ${id} not found`);
|
throw new NotFoundException(`CMS content with ID ${id} not found`);
|
||||||
}
|
}
|
||||||
return this.prisma.cmsContent.delete({ where: { id } });
|
const deleted = await this.prisma.cmsContent.delete({ where: { id } });
|
||||||
|
this.audit.log({
|
||||||
|
action: AuditAction.CMS_BLOG_DELETE,
|
||||||
|
resourceType: 'CmsContent',
|
||||||
|
resourceId: id,
|
||||||
|
metadata: { pageSlug: existing.pageSlug, sectionKey: existing.sectionKey },
|
||||||
|
});
|
||||||
|
return deleted;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
46
src/common/interceptors/audit-context.interceptor.ts
Normal file
46
src/common/interceptors/audit-context.interceptor.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import {
|
||||||
|
CallHandler,
|
||||||
|
ExecutionContext,
|
||||||
|
Injectable,
|
||||||
|
NestInterceptor,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { ClsService } from 'nestjs-cls';
|
||||||
|
import { AUDIT_CTX_KEY, AuditContext } from '../../audit/audit.constants';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Populates the CLS store with the actor + request metadata so AuditService
|
||||||
|
* can read it without callers having to thread context through every call.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class AuditContextInterceptor implements NestInterceptor {
|
||||||
|
constructor(private readonly cls: ClsService) {}
|
||||||
|
|
||||||
|
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||||
|
if (context.getType() !== 'http') {
|
||||||
|
return next.handle();
|
||||||
|
}
|
||||||
|
|
||||||
|
const req = context.switchToHttp().getRequest();
|
||||||
|
const user = req?.user as { id?: string; role?: string } | undefined;
|
||||||
|
const forwardedFor =
|
||||||
|
typeof req?.headers?.['x-forwarded-for'] === 'string'
|
||||||
|
? (req.headers['x-forwarded-for'] as string).split(',')[0].trim()
|
||||||
|
: undefined;
|
||||||
|
const ip = forwardedFor || req?.ip || req?.socket?.remoteAddress || null;
|
||||||
|
const userAgent =
|
||||||
|
typeof req?.headers?.['user-agent'] === 'string'
|
||||||
|
? (req.headers['user-agent'] as string)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const ctx: AuditContext = {
|
||||||
|
userId: user?.id ?? null,
|
||||||
|
role: user?.role ?? null,
|
||||||
|
ip,
|
||||||
|
userAgent,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.cls.set(AUDIT_CTX_KEY, ctx);
|
||||||
|
return next.handle();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1 +1,2 @@
|
|||||||
export * from './transform.interceptor';
|
export * from './transform.interceptor';
|
||||||
|
export * from './audit-context.interceptor';
|
||||||
|
|||||||
@@ -9,12 +9,15 @@ import { EventEmitter2 } from '@nestjs/event-emitter';
|
|||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { ConnectionStatus } from '@prisma/client';
|
import { ConnectionStatus } from '@prisma/client';
|
||||||
import { CreateConnectionRequestDto, RespondConnectionRequestDto } from './dto';
|
import { CreateConnectionRequestDto, RespondConnectionRequestDto } from './dto';
|
||||||
|
import { AuditService } from '../audit/audit.service';
|
||||||
|
import { AuditAction } from '../audit/audit.constants';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ConnectionRequestsService {
|
export class ConnectionRequestsService {
|
||||||
constructor(
|
constructor(
|
||||||
private prisma: PrismaService,
|
private prisma: PrismaService,
|
||||||
private eventEmitter: EventEmitter2,
|
private eventEmitter: EventEmitter2,
|
||||||
|
private audit: AuditService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -320,6 +323,16 @@ export class ConnectionRequestsService {
|
|||||||
connectionRequestId: requestId,
|
connectionRequestId: requestId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.audit.log({
|
||||||
|
action:
|
||||||
|
dto.status === ConnectionStatus.ACCEPTED
|
||||||
|
? AuditAction.CONNECTION_REQUEST_ACCEPT
|
||||||
|
: AuditAction.CONNECTION_REQUEST_REJECT,
|
||||||
|
resourceType: 'ConnectionRequest',
|
||||||
|
resourceId: requestId,
|
||||||
|
metadata: { userId: request.userId, agentProfileId },
|
||||||
|
});
|
||||||
|
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -385,9 +398,22 @@ export class ConnectionRequestsService {
|
|||||||
throw new BadRequestException('Only pending or accepted connections can be removed');
|
throw new BadRequestException('Only pending or accepted connections can be removed');
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.prisma.connectionRequest.delete({
|
const deleted = await this.prisma.connectionRequest.delete({
|
||||||
where: { id: requestId },
|
where: { id: requestId },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.audit.log({
|
||||||
|
action: AuditAction.CONNECTION_REQUEST_CANCEL,
|
||||||
|
resourceType: 'ConnectionRequest',
|
||||||
|
resourceId: requestId,
|
||||||
|
metadata: {
|
||||||
|
userId: request.userId,
|
||||||
|
agentProfileId: request.agentProfileId,
|
||||||
|
previousStatus: request.status,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return deleted;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { AuditService } from '../audit/audit.service';
|
||||||
|
import { AuditAction } from '../audit/audit.constants';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ContactService {
|
export class ContactService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly audit: AuditService,
|
||||||
|
) {}
|
||||||
|
|
||||||
async createMessage(data: { name: string; email: string; phone?: string; message: string }) {
|
async createMessage(data: { name: string; email: string; phone?: string; message: string }) {
|
||||||
return this.prisma.contactMessage.create({ data });
|
return this.prisma.contactMessage.create({ data });
|
||||||
@@ -20,16 +25,32 @@ export class ContactService {
|
|||||||
async markAsRead(id: string) {
|
async markAsRead(id: string) {
|
||||||
const msg = await this.prisma.contactMessage.findUnique({ where: { id } });
|
const msg = await this.prisma.contactMessage.findUnique({ where: { id } });
|
||||||
if (!msg) throw new NotFoundException('Contact message not found');
|
if (!msg) throw new NotFoundException('Contact message not found');
|
||||||
return this.prisma.contactMessage.update({
|
const result = await this.prisma.contactMessage.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { isRead: true },
|
data: { isRead: true },
|
||||||
});
|
});
|
||||||
|
if (!msg.isRead) {
|
||||||
|
this.audit.log({
|
||||||
|
action: AuditAction.CONTACT_MESSAGE_RESPOND,
|
||||||
|
resourceType: 'ContactMessage',
|
||||||
|
resourceId: id,
|
||||||
|
metadata: { email: msg.email, action: 'mark_read' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteMessage(id: string) {
|
async deleteMessage(id: string) {
|
||||||
const msg = await this.prisma.contactMessage.findUnique({ where: { id } });
|
const msg = await this.prisma.contactMessage.findUnique({ where: { id } });
|
||||||
if (!msg) throw new NotFoundException('Contact message not found');
|
if (!msg) throw new NotFoundException('Contact message not found');
|
||||||
return this.prisma.contactMessage.delete({ where: { id } });
|
const result = await this.prisma.contactMessage.delete({ where: { id } });
|
||||||
|
this.audit.log({
|
||||||
|
action: AuditAction.CONTACT_MESSAGE_RESPOND,
|
||||||
|
resourceType: 'ContactMessage',
|
||||||
|
resourceId: id,
|
||||||
|
metadata: { email: msg.email, action: 'delete' },
|
||||||
|
});
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getCounts() {
|
async getCounts() {
|
||||||
|
|||||||
@@ -2,10 +2,15 @@ import { Injectable, NotFoundException, ConflictException, BadRequestException }
|
|||||||
import { Prisma, FieldType } from '@prisma/client';
|
import { Prisma, FieldType } from '@prisma/client';
|
||||||
import { PrismaService } from '../prisma';
|
import { PrismaService } from '../prisma';
|
||||||
import { CreateFieldDto, UpdateFieldDto } from './dto';
|
import { CreateFieldDto, UpdateFieldDto } from './dto';
|
||||||
|
import { AuditService } from '../audit/audit.service';
|
||||||
|
import { AuditAction } from '../audit/audit.constants';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ProfileFieldsService {
|
export class ProfileFieldsService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly audit: AuditService,
|
||||||
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a URL-friendly slug from the name
|
* Generate a URL-friendly slug from the name
|
||||||
@@ -153,7 +158,7 @@ export class ProfileFieldsService {
|
|||||||
data.slug = newSlug;
|
data.slug = newSlug;
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.prisma.profileField.update({
|
const updated = await this.prisma.profileField.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data,
|
data,
|
||||||
include: {
|
include: {
|
||||||
@@ -162,6 +167,15 @@ export class ProfileFieldsService {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.audit.log({
|
||||||
|
action: AuditAction.PROFILE_FIELD_UPDATE,
|
||||||
|
resourceType: 'ProfileField',
|
||||||
|
resourceId: id,
|
||||||
|
metadata: { sectionId: field.sectionId, slug: updated.slug, name: updated.name },
|
||||||
|
});
|
||||||
|
|
||||||
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -181,9 +195,18 @@ export class ProfileFieldsService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.prisma.profileField.delete({
|
const deleted = await this.prisma.profileField.delete({
|
||||||
where: { id },
|
where: { id },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.audit.log({
|
||||||
|
action: AuditAction.PROFILE_FIELD_UPDATE,
|
||||||
|
resourceType: 'ProfileField',
|
||||||
|
resourceId: id,
|
||||||
|
metadata: { action: 'delete' },
|
||||||
|
});
|
||||||
|
|
||||||
|
return deleted;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import { Injectable, NotFoundException, ConflictException, ForbiddenException } from '@nestjs/common';
|
import { Injectable, NotFoundException, ConflictException, ForbiddenException } from '@nestjs/common';
|
||||||
import { PrismaService } from '../prisma';
|
import { PrismaService } from '../prisma';
|
||||||
import { CreateSectionDto, UpdateSectionDto, AssignSectionDto, UpdateAssignmentDto } from './dto';
|
import { CreateSectionDto, UpdateSectionDto, AssignSectionDto, UpdateAssignmentDto } from './dto';
|
||||||
|
import { AuditService } from '../audit/audit.service';
|
||||||
|
import { AuditAction } from '../audit/audit.constants';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ProfileSectionsService {
|
export class ProfileSectionsService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly audit: AuditService,
|
||||||
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a URL-friendly slug from the name
|
* Generate a URL-friendly slug from the name
|
||||||
@@ -127,7 +132,7 @@ export class ProfileSectionsService {
|
|||||||
data.slug = effectiveSlug;
|
data.slug = effectiveSlug;
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.prisma.profileSection.update({
|
const updated = await this.prisma.profileSection.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data,
|
data,
|
||||||
include: {
|
include: {
|
||||||
@@ -136,6 +141,15 @@ export class ProfileSectionsService {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.audit.log({
|
||||||
|
action: AuditAction.PROFILE_SECTION_UPDATE,
|
||||||
|
resourceType: 'ProfileSection',
|
||||||
|
resourceId: id,
|
||||||
|
metadata: { name: updated.name, slug: updated.slug },
|
||||||
|
});
|
||||||
|
|
||||||
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -158,9 +172,18 @@ export class ProfileSectionsService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.prisma.profileSection.delete({
|
const deleted = await this.prisma.profileSection.delete({
|
||||||
where: { id },
|
where: { id },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.audit.log({
|
||||||
|
action: AuditAction.PROFILE_SECTION_UPDATE,
|
||||||
|
resourceType: 'ProfileSection',
|
||||||
|
resourceId: id,
|
||||||
|
metadata: { action: 'delete', name: section.name },
|
||||||
|
});
|
||||||
|
|
||||||
|
return deleted;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import { StripeService } from './stripe.service';
|
|||||||
import { SubscriptionService } from './subscription.service';
|
import { SubscriptionService } from './subscription.service';
|
||||||
import { Public } from '../auth/decorators/public.decorator';
|
import { Public } from '../auth/decorators/public.decorator';
|
||||||
import { SubscriptionStatus } from '@prisma/client';
|
import { SubscriptionStatus } from '@prisma/client';
|
||||||
|
import { AuditService } from '../audit/audit.service';
|
||||||
|
import { AuditAction } from '../audit/audit.constants';
|
||||||
|
|
||||||
@ApiTags('Stripe Webhooks')
|
@ApiTags('Stripe Webhooks')
|
||||||
@Controller('stripe')
|
@Controller('stripe')
|
||||||
@@ -23,6 +25,7 @@ export class StripeWebhookController {
|
|||||||
constructor(
|
constructor(
|
||||||
private stripeService: StripeService,
|
private stripeService: StripeService,
|
||||||
private subscriptionService: SubscriptionService,
|
private subscriptionService: SubscriptionService,
|
||||||
|
private audit: AuditService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@@ -131,6 +134,14 @@ export class StripeWebhookController {
|
|||||||
currentPeriodEnd,
|
currentPeriodEnd,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.audit.log({
|
||||||
|
action: AuditAction.SUBSCRIPTION_CREATE,
|
||||||
|
resourceType: 'AgentSubscription',
|
||||||
|
resourceId: stripeSubscriptionId,
|
||||||
|
metadata: { userId, planId, stripeCustomerId },
|
||||||
|
actorOverride: { id: null, role: 'SYSTEM' },
|
||||||
|
});
|
||||||
|
|
||||||
this.logger.log(`Created subscription for user ${userId}, plan ${planId}`);
|
this.logger.log(`Created subscription for user ${userId}, plan ${planId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,6 +167,19 @@ export class StripeWebhookController {
|
|||||||
receiptUrl: invoice.hosted_invoice_url,
|
receiptUrl: invoice.hosted_invoice_url,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.audit.log({
|
||||||
|
action: AuditAction.PAYMENT_SUCCESS,
|
||||||
|
resourceType: 'Payment',
|
||||||
|
resourceId: invoice.id,
|
||||||
|
metadata: {
|
||||||
|
userId: subscription.userId,
|
||||||
|
amount: invoice.amount_paid,
|
||||||
|
currency: invoice.currency,
|
||||||
|
stripeSubscriptionId,
|
||||||
|
},
|
||||||
|
actorOverride: { id: null, role: 'SYSTEM' },
|
||||||
|
});
|
||||||
|
|
||||||
// Update subscription period
|
// Update subscription period
|
||||||
const periodEnd = invoice.lines?.data?.[0]?.period?.end;
|
const periodEnd = invoice.lines?.data?.[0]?.period?.end;
|
||||||
const periodStart = invoice.lines?.data?.[0]?.period?.start;
|
const periodStart = invoice.lines?.data?.[0]?.period?.start;
|
||||||
@@ -193,6 +217,19 @@ export class StripeWebhookController {
|
|||||||
SubscriptionStatus.PAST_DUE,
|
SubscriptionStatus.PAST_DUE,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.audit.log({
|
||||||
|
action: AuditAction.PAYMENT_FAILURE,
|
||||||
|
resourceType: 'Payment',
|
||||||
|
resourceId: invoice.id,
|
||||||
|
metadata: {
|
||||||
|
userId: subscription.userId,
|
||||||
|
amount: invoice.amount_due,
|
||||||
|
currency: invoice.currency,
|
||||||
|
stripeSubscriptionId,
|
||||||
|
},
|
||||||
|
actorOverride: { id: null, role: 'SYSTEM' },
|
||||||
|
});
|
||||||
|
|
||||||
this.logger.warn(`Payment failed for subscription ${stripeSubscriptionId}`);
|
this.logger.warn(`Payment failed for subscription ${stripeSubscriptionId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,6 +261,14 @@ export class StripeWebhookController {
|
|||||||
{ canceledAt: new Date() },
|
{ canceledAt: new Date() },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.audit.log({
|
||||||
|
action: AuditAction.SUBSCRIPTION_CANCEL,
|
||||||
|
resourceType: 'AgentSubscription',
|
||||||
|
resourceId: subscription.id,
|
||||||
|
metadata: { source: 'stripe_webhook' },
|
||||||
|
actorOverride: { id: null, role: 'SYSTEM' },
|
||||||
|
});
|
||||||
|
|
||||||
this.logger.log(`Subscription ${subscription.id} cancelled`);
|
this.logger.log(`Subscription ${subscription.id} cancelled`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { Injectable, Logger, BadRequestException } from '@nestjs/common';
|
|||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import Stripe from 'stripe';
|
import Stripe from 'stripe';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { AuditService } from '../audit/audit.service';
|
||||||
|
import { AuditAction } from '../audit/audit.constants';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class StripeService {
|
export class StripeService {
|
||||||
@@ -11,6 +13,7 @@ export class StripeService {
|
|||||||
constructor(
|
constructor(
|
||||||
private configService: ConfigService,
|
private configService: ConfigService,
|
||||||
private prisma: PrismaService,
|
private prisma: PrismaService,
|
||||||
|
private audit: AuditService,
|
||||||
) {
|
) {
|
||||||
const secretKey = this.configService.get<string>('stripe.secretKey');
|
const secretKey = this.configService.get<string>('stripe.secretKey');
|
||||||
if (secretKey) {
|
if (secretKey) {
|
||||||
@@ -180,13 +183,20 @@ export class StripeService {
|
|||||||
stripeSubscriptionId: string,
|
stripeSubscriptionId: string,
|
||||||
atPeriodEnd = true,
|
atPeriodEnd = true,
|
||||||
): Promise<Stripe.Subscription> {
|
): Promise<Stripe.Subscription> {
|
||||||
if (atPeriodEnd) {
|
const result = atPeriodEnd
|
||||||
return this.getStripe().subscriptions.update(stripeSubscriptionId, {
|
? await this.getStripe().subscriptions.update(stripeSubscriptionId, {
|
||||||
cancel_at_period_end: true,
|
cancel_at_period_end: true,
|
||||||
});
|
})
|
||||||
}
|
: await this.getStripe().subscriptions.cancel(stripeSubscriptionId);
|
||||||
|
|
||||||
return this.getStripe().subscriptions.cancel(stripeSubscriptionId);
|
this.audit.log({
|
||||||
|
action: AuditAction.SUBSCRIPTION_CANCEL,
|
||||||
|
resourceType: 'AgentSubscription',
|
||||||
|
resourceId: stripeSubscriptionId,
|
||||||
|
metadata: { atPeriodEnd, source: 'agent_action' },
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getSubscription(
|
async getSubscription(
|
||||||
|
|||||||
@@ -5,10 +5,15 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { SupportChatStatus } from '@prisma/client';
|
import { SupportChatStatus } from '@prisma/client';
|
||||||
|
import { AuditService } from '../audit/audit.service';
|
||||||
|
import { AuditAction } from '../audit/audit.constants';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SupportChatService {
|
export class SupportChatService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly audit: AuditService,
|
||||||
|
) {}
|
||||||
|
|
||||||
private readonly chatInclude = {
|
private readonly chatInclude = {
|
||||||
user: {
|
user: {
|
||||||
@@ -212,9 +217,18 @@ export class SupportChatService {
|
|||||||
throw new NotFoundException('Support chat not found');
|
throw new NotFoundException('Support chat not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.prisma.supportChat.update({
|
const updated = await this.prisma.supportChat.update({
|
||||||
where: { id: chatId },
|
where: { id: chatId },
|
||||||
data: { status: SupportChatStatus.CLOSED },
|
data: { status: SupportChatStatus.CLOSED },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.audit.log({
|
||||||
|
action: AuditAction.SUPPORT_TICKET_CLOSE,
|
||||||
|
resourceType: 'SupportChat',
|
||||||
|
resourceId: chatId,
|
||||||
|
metadata: { previousStatus: chat.status, userId: chat.userId },
|
||||||
|
});
|
||||||
|
|
||||||
|
return updated;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,15 @@ import { Injectable, NotFoundException, BadRequestException } from '@nestjs/comm
|
|||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { ReportStatus } from '@prisma/client';
|
import { ReportStatus } from '@prisma/client';
|
||||||
import { CreateReportDto } from './dto';
|
import { CreateReportDto } from './dto';
|
||||||
|
import { AuditService } from '../audit/audit.service';
|
||||||
|
import { AuditAction } from '../audit/audit.constants';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UserReportsService {
|
export class UserReportsService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly audit: AuditService,
|
||||||
|
) {}
|
||||||
|
|
||||||
private readonly reportInclude = {
|
private readonly reportInclude = {
|
||||||
reporter: {
|
reporter: {
|
||||||
@@ -84,11 +89,28 @@ export class UserReportsService {
|
|||||||
throw new NotFoundException('Report not found');
|
throw new NotFoundException('Report not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.prisma.userReport.update({
|
const updated = await this.prisma.userReport.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { status, adminNotes },
|
data: { status, adminNotes },
|
||||||
include: this.reportInclude,
|
include: this.reportInclude,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (report.status !== status) {
|
||||||
|
this.audit.log({
|
||||||
|
action: AuditAction.REPORT_STATUS_CHANGE,
|
||||||
|
resourceType: 'UserReport',
|
||||||
|
resourceId: id,
|
||||||
|
metadata: {
|
||||||
|
before: { status: report.status },
|
||||||
|
after: { status },
|
||||||
|
reportedUserId: report.reportedUserId,
|
||||||
|
reporterId: report.reporterId,
|
||||||
|
adminNotes: adminNotes ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getReportCounts() {
|
async getReportCounts() {
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { UserStatus, UserRole, Prisma, VerificationStatus } from '@prisma/client
|
|||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
import * as argon2 from 'argon2';
|
import * as argon2 from 'argon2';
|
||||||
|
import { AuditService } from '../audit/audit.service';
|
||||||
|
import { AuditAction } from '../audit/audit.constants';
|
||||||
|
|
||||||
type UserWithProfiles = Prisma.UserGetPayload<{
|
type UserWithProfiles = Prisma.UserGetPayload<{
|
||||||
include: { userProfile: true; agentProfile: { include: { agentType: true } } };
|
include: { userProfile: true; agentProfile: { include: { agentType: true } } };
|
||||||
@@ -13,6 +15,7 @@ export class UsersService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly eventEmitter: EventEmitter2,
|
private readonly eventEmitter: EventEmitter2,
|
||||||
|
private readonly audit: AuditService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// =============================================
|
// =============================================
|
||||||
@@ -323,10 +326,34 @@ export class UsersService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async updateStatus(id: string, status: UserStatus) {
|
async updateStatus(id: string, status: UserStatus) {
|
||||||
return this.prisma.user.update({
|
const before = await this.prisma.user.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { status: true, email: true, role: true },
|
||||||
|
});
|
||||||
|
const result = await this.prisma.user.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { status },
|
data: { status },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (before && before.status !== status) {
|
||||||
|
const action =
|
||||||
|
status === UserStatus.SUSPENDED
|
||||||
|
? AuditAction.USER_SUSPEND
|
||||||
|
: before.status === UserStatus.SUSPENDED && status === UserStatus.ACTIVE
|
||||||
|
? AuditAction.USER_UNSUSPEND
|
||||||
|
: AuditAction.USER_UPDATE;
|
||||||
|
this.audit.log({
|
||||||
|
action,
|
||||||
|
resourceType: 'User',
|
||||||
|
resourceId: id,
|
||||||
|
metadata: {
|
||||||
|
email: before.email,
|
||||||
|
before: { status: before.status },
|
||||||
|
after: { status },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateAgentType(userId: string, agentTypeId: string) {
|
async updateAgentType(userId: string, agentTypeId: string) {
|
||||||
@@ -482,6 +509,24 @@ export class UsersService {
|
|||||||
agentName: `${updatedProfile.firstName || ''} ${updatedProfile.lastName || ''}`.trim(),
|
agentName: `${updatedProfile.firstName || ''} ${updatedProfile.lastName || ''}`.trim(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const auditAction =
|
||||||
|
data.status === VerificationStatus.APPROVED
|
||||||
|
? AuditAction.AGENT_VERIFICATION_APPROVE
|
||||||
|
: data.status === VerificationStatus.REJECTED
|
||||||
|
? AuditAction.AGENT_VERIFICATION_REJECT
|
||||||
|
: AuditAction.AGENT_VERIFICATION_SUBMIT;
|
||||||
|
this.audit.log({
|
||||||
|
action: auditAction,
|
||||||
|
resourceType: 'AgentProfile',
|
||||||
|
resourceId: user.agentProfile.id,
|
||||||
|
metadata: {
|
||||||
|
previousStatus: user.agentProfile.verificationStatus,
|
||||||
|
newStatus: data.status,
|
||||||
|
note: data.note ?? null,
|
||||||
|
agentUserId: userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
agentProfileId: updatedProfile.id,
|
agentProfileId: updatedProfile.id,
|
||||||
@@ -768,6 +813,13 @@ export class UsersService {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.audit.log({
|
||||||
|
action: AuditAction.USER_DELETE,
|
||||||
|
resourceType: 'User',
|
||||||
|
resourceId: userId,
|
||||||
|
metadata: { selfInitiated: true },
|
||||||
|
});
|
||||||
|
|
||||||
return { message: 'Account deleted successfully' };
|
return { message: 'Account deleted successfully' };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -797,7 +849,7 @@ export class UsersService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const hashedPassword = await argon2.hash(password);
|
const hashedPassword = await argon2.hash(password);
|
||||||
return this.prisma.user.create({
|
const created = await this.prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
email,
|
email,
|
||||||
password: hashedPassword,
|
password: hashedPassword,
|
||||||
@@ -815,6 +867,13 @@ export class UsersService {
|
|||||||
createdAt: true,
|
createdAt: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
this.audit.log({
|
||||||
|
action: AuditAction.USER_ROLE_CHANGE,
|
||||||
|
resourceType: 'User',
|
||||||
|
resourceId: created.id,
|
||||||
|
metadata: { email: created.email, granted: 'ADMIN' },
|
||||||
|
});
|
||||||
|
return created;
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteAdminUser(id: string, requesterId: string) {
|
async deleteAdminUser(id: string, requesterId: string) {
|
||||||
@@ -832,6 +891,12 @@ export class UsersService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await this.prisma.user.delete({ where: { id } });
|
await this.prisma.user.delete({ where: { id } });
|
||||||
|
this.audit.log({
|
||||||
|
action: AuditAction.USER_ROLE_CHANGE,
|
||||||
|
resourceType: 'User',
|
||||||
|
resourceId: id,
|
||||||
|
metadata: { email: user.email, revoked: 'ADMIN' },
|
||||||
|
});
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user