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

View File

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

View File

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

View File

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

View File

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

View File

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

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';
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<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 {
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;

View File

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

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 './audit-context.interceptor';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<string>('stripe.secretKey');
if (secretKey) {
@@ -180,13 +183,20 @@ export class StripeService {
stripeSubscriptionId: string,
atPeriodEnd = true,
): Promise<Stripe.Subscription> {
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(

View File

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

View File

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

View File

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