feat: implement audit logging system with service, controller, and interceptor to track user actions across core services

This commit is contained in:
pradeepkumar
2026-04-28 11:12:25 +05:30
parent 4eda556059
commit 80c6fd1d71
27 changed files with 895 additions and 29 deletions

16
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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")
}

View File

@@ -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;
} }
} }

View File

@@ -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;
} }

View File

@@ -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 {}

View 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;
}

View 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
View 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 {}

View 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
View 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 };
}

View 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
View File

@@ -0,0 +1 @@
export * from './audit-query.dto';

4
src/audit/index.ts Normal file
View File

@@ -0,0 +1,4 @@
export * from './audit.module';
export * from './audit.service';
export * from './audit.constants';
export * from './diff.util';

View File

@@ -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;

View File

@@ -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;
} }
} }

View 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();
}
}

View File

@@ -1 +1,2 @@
export * from './transform.interceptor'; export * from './transform.interceptor';
export * from './audit-context.interceptor';

View File

@@ -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;
} }
/** /**

View File

@@ -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() {

View File

@@ -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;
} }
/** /**

View File

@@ -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;
} }
/** /**

View File

@@ -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`);
} }
} }

View File

@@ -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(

View File

@@ -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;
} }
} }

View File

@@ -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() {

View File

@@ -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;
} }
} }