From 80c6fd1d71373221abe740329dd3554d07ba1bc8 Mon Sep 17 00:00:00 2001 From: pradeepkumar Date: Tue, 28 Apr 2026 11:12:25 +0530 Subject: [PATCH] feat: implement audit logging system with service, controller, and interceptor to track user actions across core services --- package-lock.json | 16 +++ package.json | 1 + prisma/schema.prisma | 27 ++++ src/agent-types/agent-types.service.ts | 29 ++++- src/agents/agents.service.ts | 46 +++++++ src/app.module.ts | 19 ++- src/audit/audit.constants.ts | 67 ++++++++++ src/audit/audit.controller.ts | 59 +++++++++ src/audit/audit.module.ts | 11 ++ src/audit/audit.service.ts | 58 +++++++++ src/audit/diff.util.ts | 39 ++++++ src/audit/dto/audit-query.dto.ts | 50 +++++++ src/audit/dto/index.ts | 1 + src/audit/index.ts | 4 + src/auth/auth.service.ts | 123 ++++++++++++++++++ src/cms/cms.service.ts | 34 ++++- .../interceptors/audit-context.interceptor.ts | 46 +++++++ src/common/interceptors/index.ts | 1 + .../connection-requests.service.ts | 28 +++- src/contact/contact.service.ts | 27 +++- src/profile-fields/profile-fields.service.ts | 29 ++++- .../profile-sections.service.ts | 29 ++++- src/stripe/stripe-webhook.controller.ts | 45 +++++++ src/stripe/stripe.service.ts | 22 +++- src/support-chat/support-chat.service.ts | 18 ++- src/user-reports/user-reports.service.ts | 26 +++- src/users/users.service.ts | 69 +++++++++- 27 files changed, 895 insertions(+), 29 deletions(-) create mode 100644 src/audit/audit.constants.ts create mode 100644 src/audit/audit.controller.ts create mode 100644 src/audit/audit.module.ts create mode 100644 src/audit/audit.service.ts create mode 100644 src/audit/diff.util.ts create mode 100644 src/audit/dto/audit-query.dto.ts create mode 100644 src/audit/dto/index.ts create mode 100644 src/audit/index.ts create mode 100644 src/common/interceptors/audit-context.interceptor.ts diff --git a/package-lock.json b/package-lock.json index f775147..7d07152 100644 --- a/package-lock.json +++ b/package-lock.json @@ -57,6 +57,7 @@ "mime-types": "^3.0.2", "multer": "^2.0.2", "nest-winston": "^1.10.2", + "nestjs-cls": "^6.2.0", "nestjs-pino": "^4.5.0", "nodemailer": "^7.0.11", "passport": "^0.7.0", @@ -14909,6 +14910,21 @@ "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": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/nestjs-pino/-/nestjs-pino-4.5.0.tgz", diff --git a/package.json b/package.json index 457ba09..ce990e7 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,7 @@ "mime-types": "^3.0.2", "multer": "^2.0.2", "nest-winston": "^1.10.2", + "nestjs-cls": "^6.2.0", "nestjs-pino": "^4.5.0", "nodemailer": "^7.0.11", "passport": "^0.7.0", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c91da21..f6b9fe0 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -166,6 +166,9 @@ model User { // Verification actions (admin) verificationActions VerificationHistory[] @relation("VerificationActions") + // Audit Log (actor) + auditLogs AuditLog[] + @@index([email]) @@index([role]) @@index([status]) @@ -797,3 +800,27 @@ model VerificationHistory { @@index([createdAt]) @@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") +} diff --git a/src/agent-types/agent-types.service.ts b/src/agent-types/agent-types.service.ts index 9e3a574..af4c658 100644 --- a/src/agent-types/agent-types.service.ts +++ b/src/agent-types/agent-types.service.ts @@ -1,10 +1,15 @@ import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; import { PrismaService } from '../prisma'; import { CreateAgentTypeDto, UpdateAgentTypeDto } from './dto'; +import { AuditService } from '../audit/audit.service'; +import { AuditAction } from '../audit/audit.constants'; @Injectable() export class AgentTypesService { - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly audit: AuditService, + ) {} /** * 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 }, data: { ...(name && { name }), ...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 }, }); + + this.audit.log({ + action: AuditAction.AGENT_TYPE_UPDATE, + resourceType: 'AgentType', + resourceId: id, + metadata: { action: 'delete', name: agentType.name }, + }); + + return deleted; } } diff --git a/src/agents/agents.service.ts b/src/agents/agents.service.ts index 5c591be..733dd0d 100644 --- a/src/agents/agents.service.ts +++ b/src/agents/agents.service.ts @@ -14,6 +14,9 @@ import { } from './dto'; import { Prisma, Prisma as PrismaTypes, VerificationStatus } from '@prisma/client'; 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 { const base = `${firstName}-${lastName}` @@ -29,6 +32,7 @@ export class AgentsService { constructor( private prisma: PrismaService, private connectionRequestsService: ConnectionRequestsService, + private audit: AuditService, ) {} /** @@ -218,6 +222,29 @@ export class AgentsService { await this.syncServiceAreasToFieldValues(profile.id, serviceAreas); } + const diff = buildDiff( + profile as unknown as Record, + updatedProfile as unknown as Record, + [ + '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; } @@ -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; } diff --git a/src/app.module.ts b/src/app.module.ts index e23a52b..fb39d76 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,14 +1,17 @@ import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; 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 { EventEmitterModule } from '@nestjs/event-emitter'; +import { ClsModule } from 'nestjs-cls'; import { configuration } from './config'; import { RedisModule } from './common/redis/redis.module'; import { PrismaModule } from './prisma'; import { AuthModule, JwtAuthGuard, RolesGuard } from './auth'; +import { AuditModule } from './audit'; +import { AuditContextInterceptor } from './common/interceptors'; import { UsersModule } from './users'; import { EmailModule } from './email'; import { AgentTypesModule } from './agent-types'; @@ -53,9 +56,18 @@ import { AppService } from './app.service'; // Event Emitter EventEmitterModule.forRoot(), + // Async Local Storage (request-scoped audit context) + ClsModule.forRoot({ + global: true, + middleware: { mount: true, generateId: true }, + }), + // Database PrismaModule, + // Audit Log (global) + AuditModule, + // Feature Modules AuthModule, UsersModule, @@ -92,6 +104,11 @@ import { AppService } from './app.service'; provide: APP_GUARD, useClass: RolesGuard, }, + // Global Audit Context Interceptor (populates CLS for AuditService) + { + provide: APP_INTERCEPTOR, + useClass: AuditContextInterceptor, + }, ], }) export class AppModule {} diff --git a/src/audit/audit.constants.ts b/src/audit/audit.constants.ts new file mode 100644 index 0000000..c34dd5a --- /dev/null +++ b/src/audit/audit.constants.ts @@ -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; +} diff --git a/src/audit/audit.controller.ts b/src/audit/audit.controller.ts new file mode 100644 index 0000000..c225637 --- /dev/null +++ b/src/audit/audit.controller.ts @@ -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 }; + } +} diff --git a/src/audit/audit.module.ts b/src/audit/audit.module.ts new file mode 100644 index 0000000..19a4927 --- /dev/null +++ b/src/audit/audit.module.ts @@ -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 {} diff --git a/src/audit/audit.service.ts b/src/audit/audit.service.ts new file mode 100644 index 0000000..ab78294 --- /dev/null +++ b/src/audit/audit.service.ts @@ -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; + /** + * 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(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, + ); + }); + } +} diff --git a/src/audit/diff.util.ts b/src/audit/diff.util.ts new file mode 100644 index 0000000..b2d5d77 --- /dev/null +++ b/src/audit/diff.util.ts @@ -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)[k], (b as Record)[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>( + before: T, + after: T, + fields: (keyof T)[], +): { before: Partial; after: Partial } | null { + const b: Partial = {}; + const a: Partial = {}; + 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 }; +} diff --git a/src/audit/dto/audit-query.dto.ts b/src/audit/dto/audit-query.dto.ts new file mode 100644 index 0000000..3258652 --- /dev/null +++ b/src/audit/dto/audit-query.dto.ts @@ -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; +} diff --git a/src/audit/dto/index.ts b/src/audit/dto/index.ts new file mode 100644 index 0000000..c5b98ac --- /dev/null +++ b/src/audit/dto/index.ts @@ -0,0 +1 @@ +export * from './audit-query.dto'; diff --git a/src/audit/index.ts b/src/audit/index.ts new file mode 100644 index 0000000..ee1125c --- /dev/null +++ b/src/audit/index.ts @@ -0,0 +1,4 @@ +export * from './audit.module'; +export * from './audit.service'; +export * from './audit.constants'; +export * from './diff.util'; diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index c9ed375..70767dc 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -27,6 +27,8 @@ import { } from './dto'; import { UserRole, AuthProvider, UserStatus } from '@prisma/client'; import { TwoFactorService } from './two-factor/two-factor.service'; +import { AuditService } from '../audit/audit.service'; +import { AuditAction } from '../audit/audit.constants'; @Injectable() export class AuthService { @@ -37,6 +39,7 @@ export class AuthService { private readonly eventEmitter: EventEmitter2, @Inject(forwardRef(() => TwoFactorService)) private readonly twoFactorService: TwoFactorService, + private readonly audit: AuditService, ) {} // ========================================== @@ -119,6 +122,14 @@ export class AuthService { 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: 'Registration successful! Please check your email to verify your account before logging in.', @@ -145,21 +156,47 @@ export class AuthService { }); 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'); } 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( 'This account uses social login. Please sign in with Google or Facebook.', ); } 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.'); } // Check if email is verified 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( '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); 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'); } @@ -219,6 +263,14 @@ export class AuthService { 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 const profile = user.role === UserRole.AGENT ? user.agentProfile : user.userProfile; @@ -367,6 +419,14 @@ export class AuthService { 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 = user.role === UserRole.AGENT ? user.agentProfile : user.userProfile; @@ -418,6 +478,14 @@ export class AuthService { resetUrl: `${this.configService.get('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 { message: 'Password reset link has been sent to your email', }; @@ -464,6 +532,14 @@ export class AuthService { 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 { message: 'Password reset successful. Please login with your new password.', }; @@ -529,6 +605,14 @@ export class AuthService { 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 { 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' }; } @@ -622,6 +714,14 @@ export class AuthService { 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 { 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' }; } @@ -908,6 +1015,14 @@ export class AuthService { 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 const profile = user.role === UserRole.AGENT ? user.agentProfile : user.userProfile; @@ -977,6 +1092,14 @@ export class AuthService { 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 const profile = user.role === UserRole.AGENT ? user.agentProfile : user.userProfile; diff --git a/src/cms/cms.service.ts b/src/cms/cms.service.ts index 45bff7c..a6dc131 100644 --- a/src/cms/cms.service.ts +++ b/src/cms/cms.service.ts @@ -1,10 +1,15 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { CreateCmsContentDto, UpdateCmsContentDto } from './dto'; +import { AuditService } from '../audit/audit.service'; +import { AuditAction } from '../audit/audit.constants'; @Injectable() export class CmsService { - constructor(private prisma: PrismaService) {} + constructor( + private prisma: PrismaService, + private audit: AuditService, + ) {} async findByPage(pageSlug: string) { return this.prisma.cmsContent.findMany({ @@ -49,10 +54,26 @@ export class CmsService { if (!existing) { throw new NotFoundException(`CMS content with ID ${id} not found`); } - return this.prisma.cmsContent.update({ + const updated = await this.prisma.cmsContent.update({ where: { id }, 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) { @@ -60,6 +81,13 @@ export class CmsService { if (!existing) { 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; } } diff --git a/src/common/interceptors/audit-context.interceptor.ts b/src/common/interceptors/audit-context.interceptor.ts new file mode 100644 index 0000000..0b8ea6a --- /dev/null +++ b/src/common/interceptors/audit-context.interceptor.ts @@ -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 { + 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(); + } +} diff --git a/src/common/interceptors/index.ts b/src/common/interceptors/index.ts index 0546468..1efca53 100644 --- a/src/common/interceptors/index.ts +++ b/src/common/interceptors/index.ts @@ -1 +1,2 @@ export * from './transform.interceptor'; +export * from './audit-context.interceptor'; diff --git a/src/connection-requests/connection-requests.service.ts b/src/connection-requests/connection-requests.service.ts index 578e48a..550aa5c 100644 --- a/src/connection-requests/connection-requests.service.ts +++ b/src/connection-requests/connection-requests.service.ts @@ -9,12 +9,15 @@ import { EventEmitter2 } from '@nestjs/event-emitter'; import { PrismaService } from '../prisma/prisma.service'; import { ConnectionStatus } from '@prisma/client'; import { CreateConnectionRequestDto, RespondConnectionRequestDto } from './dto'; +import { AuditService } from '../audit/audit.service'; +import { AuditAction } from '../audit/audit.constants'; @Injectable() export class ConnectionRequestsService { constructor( private prisma: PrismaService, private eventEmitter: EventEmitter2, + private audit: AuditService, ) {} /** @@ -320,6 +323,16 @@ export class ConnectionRequestsService { 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; } @@ -385,9 +398,22 @@ export class ConnectionRequestsService { 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 }, }); + + this.audit.log({ + action: AuditAction.CONNECTION_REQUEST_CANCEL, + resourceType: 'ConnectionRequest', + resourceId: requestId, + metadata: { + userId: request.userId, + agentProfileId: request.agentProfileId, + previousStatus: request.status, + }, + }); + + return deleted; } /** diff --git a/src/contact/contact.service.ts b/src/contact/contact.service.ts index a3e9cbc..311b413 100644 --- a/src/contact/contact.service.ts +++ b/src/contact/contact.service.ts @@ -1,9 +1,14 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; +import { AuditService } from '../audit/audit.service'; +import { AuditAction } from '../audit/audit.constants'; @Injectable() 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 }) { return this.prisma.contactMessage.create({ data }); @@ -20,16 +25,32 @@ export class ContactService { async markAsRead(id: string) { const msg = await this.prisma.contactMessage.findUnique({ where: { id } }); if (!msg) throw new NotFoundException('Contact message not found'); - return this.prisma.contactMessage.update({ + const result = await this.prisma.contactMessage.update({ where: { id }, 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) { const msg = await this.prisma.contactMessage.findUnique({ where: { id } }); 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() { diff --git a/src/profile-fields/profile-fields.service.ts b/src/profile-fields/profile-fields.service.ts index 6b49dd0..5978489 100644 --- a/src/profile-fields/profile-fields.service.ts +++ b/src/profile-fields/profile-fields.service.ts @@ -2,10 +2,15 @@ import { Injectable, NotFoundException, ConflictException, BadRequestException } import { Prisma, FieldType } from '@prisma/client'; import { PrismaService } from '../prisma'; import { CreateFieldDto, UpdateFieldDto } from './dto'; +import { AuditService } from '../audit/audit.service'; +import { AuditAction } from '../audit/audit.constants'; @Injectable() 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 @@ -153,7 +158,7 @@ export class ProfileFieldsService { data.slug = newSlug; } - return this.prisma.profileField.update({ + const updated = await this.prisma.profileField.update({ where: { id }, data, 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 }, }); + + this.audit.log({ + action: AuditAction.PROFILE_FIELD_UPDATE, + resourceType: 'ProfileField', + resourceId: id, + metadata: { action: 'delete' }, + }); + + return deleted; } /** diff --git a/src/profile-fields/profile-sections.service.ts b/src/profile-fields/profile-sections.service.ts index c40fa8d..4970d60 100644 --- a/src/profile-fields/profile-sections.service.ts +++ b/src/profile-fields/profile-sections.service.ts @@ -1,10 +1,15 @@ import { Injectable, NotFoundException, ConflictException, ForbiddenException } from '@nestjs/common'; import { PrismaService } from '../prisma'; import { CreateSectionDto, UpdateSectionDto, AssignSectionDto, UpdateAssignmentDto } from './dto'; +import { AuditService } from '../audit/audit.service'; +import { AuditAction } from '../audit/audit.constants'; @Injectable() 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 @@ -127,7 +132,7 @@ export class ProfileSectionsService { data.slug = effectiveSlug; } - return this.prisma.profileSection.update({ + const updated = await this.prisma.profileSection.update({ where: { id }, data, 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 }, }); + + this.audit.log({ + action: AuditAction.PROFILE_SECTION_UPDATE, + resourceType: 'ProfileSection', + resourceId: id, + metadata: { action: 'delete', name: section.name }, + }); + + return deleted; } /** diff --git a/src/stripe/stripe-webhook.controller.ts b/src/stripe/stripe-webhook.controller.ts index cef1040..32acb0e 100644 --- a/src/stripe/stripe-webhook.controller.ts +++ b/src/stripe/stripe-webhook.controller.ts @@ -14,6 +14,8 @@ import { StripeService } from './stripe.service'; import { SubscriptionService } from './subscription.service'; import { Public } from '../auth/decorators/public.decorator'; import { SubscriptionStatus } from '@prisma/client'; +import { AuditService } from '../audit/audit.service'; +import { AuditAction } from '../audit/audit.constants'; @ApiTags('Stripe Webhooks') @Controller('stripe') @@ -23,6 +25,7 @@ export class StripeWebhookController { constructor( private stripeService: StripeService, private subscriptionService: SubscriptionService, + private audit: AuditService, ) {} @Public() @@ -131,6 +134,14 @@ export class StripeWebhookController { 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}`); } @@ -156,6 +167,19 @@ export class StripeWebhookController { 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 const periodEnd = invoice.lines?.data?.[0]?.period?.end; const periodStart = invoice.lines?.data?.[0]?.period?.start; @@ -193,6 +217,19 @@ export class StripeWebhookController { 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}`); } @@ -224,6 +261,14 @@ export class StripeWebhookController { { 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`); } } diff --git a/src/stripe/stripe.service.ts b/src/stripe/stripe.service.ts index d960a0f..491c497 100644 --- a/src/stripe/stripe.service.ts +++ b/src/stripe/stripe.service.ts @@ -2,6 +2,8 @@ import { Injectable, Logger, BadRequestException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import Stripe from 'stripe'; import { PrismaService } from '../prisma/prisma.service'; +import { AuditService } from '../audit/audit.service'; +import { AuditAction } from '../audit/audit.constants'; @Injectable() export class StripeService { @@ -11,6 +13,7 @@ export class StripeService { constructor( private configService: ConfigService, private prisma: PrismaService, + private audit: AuditService, ) { const secretKey = this.configService.get('stripe.secretKey'); if (secretKey) { @@ -180,13 +183,20 @@ export class StripeService { stripeSubscriptionId: string, atPeriodEnd = true, ): Promise { - if (atPeriodEnd) { - return this.getStripe().subscriptions.update(stripeSubscriptionId, { - cancel_at_period_end: true, - }); - } + const result = atPeriodEnd + ? await this.getStripe().subscriptions.update(stripeSubscriptionId, { + 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( diff --git a/src/support-chat/support-chat.service.ts b/src/support-chat/support-chat.service.ts index e243e45..13e5501 100644 --- a/src/support-chat/support-chat.service.ts +++ b/src/support-chat/support-chat.service.ts @@ -5,10 +5,15 @@ import { } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { SupportChatStatus } from '@prisma/client'; +import { AuditService } from '../audit/audit.service'; +import { AuditAction } from '../audit/audit.constants'; @Injectable() export class SupportChatService { - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly audit: AuditService, + ) {} private readonly chatInclude = { user: { @@ -212,9 +217,18 @@ export class SupportChatService { throw new NotFoundException('Support chat not found'); } - return this.prisma.supportChat.update({ + const updated = await this.prisma.supportChat.update({ where: { id: chatId }, 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; } } diff --git a/src/user-reports/user-reports.service.ts b/src/user-reports/user-reports.service.ts index 594020a..38db7cf 100644 --- a/src/user-reports/user-reports.service.ts +++ b/src/user-reports/user-reports.service.ts @@ -2,10 +2,15 @@ import { Injectable, NotFoundException, BadRequestException } from '@nestjs/comm import { PrismaService } from '../prisma/prisma.service'; import { ReportStatus } from '@prisma/client'; import { CreateReportDto } from './dto'; +import { AuditService } from '../audit/audit.service'; +import { AuditAction } from '../audit/audit.constants'; @Injectable() export class UserReportsService { - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly audit: AuditService, + ) {} private readonly reportInclude = { reporter: { @@ -84,11 +89,28 @@ export class UserReportsService { throw new NotFoundException('Report not found'); } - return this.prisma.userReport.update({ + const updated = await this.prisma.userReport.update({ where: { id }, data: { status, adminNotes }, 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() { diff --git a/src/users/users.service.ts b/src/users/users.service.ts index 7150b34..e1b3b16 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -3,6 +3,8 @@ import { UserStatus, UserRole, Prisma, VerificationStatus } from '@prisma/client import { PrismaService } from '../prisma/prisma.service'; import { EventEmitter2 } from '@nestjs/event-emitter'; import * as argon2 from 'argon2'; +import { AuditService } from '../audit/audit.service'; +import { AuditAction } from '../audit/audit.constants'; type UserWithProfiles = Prisma.UserGetPayload<{ include: { userProfile: true; agentProfile: { include: { agentType: true } } }; @@ -13,6 +15,7 @@ export class UsersService { constructor( private readonly prisma: PrismaService, private readonly eventEmitter: EventEmitter2, + private readonly audit: AuditService, ) {} // ============================================= @@ -323,10 +326,34 @@ export class UsersService { } 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 }, 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) { @@ -482,6 +509,24 @@ export class UsersService { 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 { id: user.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' }; } @@ -797,7 +849,7 @@ export class UsersService { } const hashedPassword = await argon2.hash(password); - return this.prisma.user.create({ + const created = await this.prisma.user.create({ data: { email, password: hashedPassword, @@ -815,6 +867,13 @@ export class UsersService { 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) { @@ -832,6 +891,12 @@ export class UsersService { } 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; } }