feat: implement audit logging system with service, controller, and interceptor to track user actions across core services
This commit is contained in:
16
package-lock.json
generated
16
package-lock.json
generated
@@ -57,6 +57,7 @@
|
||||
"mime-types": "^3.0.2",
|
||||
"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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
67
src/audit/audit.constants.ts
Normal file
67
src/audit/audit.constants.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
export const AuditAction = {
|
||||
// Auth
|
||||
AUTH_SIGNUP: 'AUTH_SIGNUP',
|
||||
AUTH_LOGIN_SUCCESS: 'AUTH_LOGIN_SUCCESS',
|
||||
AUTH_LOGIN_FAIL: 'AUTH_LOGIN_FAIL',
|
||||
AUTH_LOGOUT: 'AUTH_LOGOUT',
|
||||
AUTH_PASSWORD_RESET_REQUEST: 'AUTH_PASSWORD_RESET_REQUEST',
|
||||
AUTH_PASSWORD_RESET_CONFIRM: 'AUTH_PASSWORD_RESET_CONFIRM',
|
||||
AUTH_PASSWORD_CHANGE: 'AUTH_PASSWORD_CHANGE',
|
||||
AUTH_EMAIL_CHANGE: 'AUTH_EMAIL_CHANGE',
|
||||
AUTH_EMAIL_VERIFY: 'AUTH_EMAIL_VERIFY',
|
||||
AUTH_2FA_ENABLE: 'AUTH_2FA_ENABLE',
|
||||
AUTH_2FA_DISABLE: 'AUTH_2FA_DISABLE',
|
||||
|
||||
// User / role
|
||||
USER_UPDATE: 'USER_UPDATE',
|
||||
USER_DELETE: 'USER_DELETE',
|
||||
USER_SUSPEND: 'USER_SUSPEND',
|
||||
USER_UNSUSPEND: 'USER_UNSUSPEND',
|
||||
USER_ROLE_CHANGE: 'USER_ROLE_CHANGE',
|
||||
|
||||
// Agent
|
||||
AGENT_PROFILE_UPDATE: 'AGENT_PROFILE_UPDATE',
|
||||
AGENT_VERIFICATION_SUBMIT: 'AGENT_VERIFICATION_SUBMIT',
|
||||
AGENT_VERIFICATION_APPROVE: 'AGENT_VERIFICATION_APPROVE',
|
||||
AGENT_VERIFICATION_REJECT: 'AGENT_VERIFICATION_REJECT',
|
||||
|
||||
// Billing
|
||||
SUBSCRIPTION_CREATE: 'SUBSCRIPTION_CREATE',
|
||||
SUBSCRIPTION_CANCEL: 'SUBSCRIPTION_CANCEL',
|
||||
SUBSCRIPTION_PLAN_CHANGE: 'SUBSCRIPTION_PLAN_CHANGE',
|
||||
PAYMENT_SUCCESS: 'PAYMENT_SUCCESS',
|
||||
PAYMENT_FAILURE: 'PAYMENT_FAILURE',
|
||||
PAYMENT_REFUND: 'PAYMENT_REFUND',
|
||||
|
||||
// Moderation
|
||||
REPORT_STATUS_CHANGE: 'REPORT_STATUS_CHANGE',
|
||||
CONTACT_MESSAGE_RESPOND: 'CONTACT_MESSAGE_RESPOND',
|
||||
SUPPORT_TICKET_CLOSE: 'SUPPORT_TICKET_CLOSE',
|
||||
|
||||
// CMS
|
||||
CMS_BLOG_PUBLISH: 'CMS_BLOG_PUBLISH',
|
||||
CMS_BLOG_UNPUBLISH: 'CMS_BLOG_UNPUBLISH',
|
||||
CMS_BLOG_DELETE: 'CMS_BLOG_DELETE',
|
||||
CMS_TESTIMONIAL_CHANGE: 'CMS_TESTIMONIAL_CHANGE',
|
||||
|
||||
// Schema (high-impact admin config)
|
||||
PROFILE_FIELD_UPDATE: 'PROFILE_FIELD_UPDATE',
|
||||
PROFILE_SECTION_UPDATE: 'PROFILE_SECTION_UPDATE',
|
||||
AGENT_TYPE_UPDATE: 'AGENT_TYPE_UPDATE',
|
||||
|
||||
// Connections
|
||||
CONNECTION_REQUEST_ACCEPT: 'CONNECTION_REQUEST_ACCEPT',
|
||||
CONNECTION_REQUEST_REJECT: 'CONNECTION_REQUEST_REJECT',
|
||||
CONNECTION_REQUEST_CANCEL: 'CONNECTION_REQUEST_CANCEL',
|
||||
} as const;
|
||||
|
||||
export type AuditAction = (typeof AuditAction)[keyof typeof AuditAction];
|
||||
|
||||
export const AUDIT_CTX_KEY = 'auditCtx';
|
||||
|
||||
export interface AuditContext {
|
||||
userId?: string | null;
|
||||
role?: string | null;
|
||||
ip?: string | null;
|
||||
userAgent?: string | null;
|
||||
}
|
||||
59
src/audit/audit.controller.ts
Normal file
59
src/audit/audit.controller.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger';
|
||||
import { Prisma, UserRole } from '@prisma/client';
|
||||
import { JwtAuthGuard, RolesGuard } from '../auth/guards';
|
||||
import { Roles } from '../auth/decorators';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditQueryDto } from './dto';
|
||||
|
||||
@ApiTags('Admin - Audit Log')
|
||||
@Controller('admin/audit-log')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN, UserRole.SUPER_ADMIN)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
export class AuditController {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List audit log entries (admin)' })
|
||||
@ApiResponse({ status: 200, description: 'Paginated audit log' })
|
||||
async list(@Query() query: AuditQueryDto) {
|
||||
const page = query.page ?? 1;
|
||||
const limit = query.limit ?? 25;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const where: Prisma.AuditLogWhereInput = {};
|
||||
if (query.actorId) where.actorId = query.actorId;
|
||||
if (query.action) where.action = query.action;
|
||||
if (query.resourceType) where.resourceType = query.resourceType;
|
||||
if (query.resourceId) where.resourceId = query.resourceId;
|
||||
if (query.from || query.to) {
|
||||
where.createdAt = {};
|
||||
if (query.from) (where.createdAt as Prisma.DateTimeFilter).gte = new Date(query.from);
|
||||
if (query.to) (where.createdAt as Prisma.DateTimeFilter).lte = new Date(query.to);
|
||||
}
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.auditLog.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip,
|
||||
take: limit,
|
||||
include: {
|
||||
actor: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
role: true,
|
||||
userProfile: { select: { firstName: true, lastName: true } },
|
||||
agentProfile: { select: { firstName: true, lastName: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
this.prisma.auditLog.count({ where }),
|
||||
]);
|
||||
|
||||
return { items, total, page, limit };
|
||||
}
|
||||
}
|
||||
11
src/audit/audit.module.ts
Normal file
11
src/audit/audit.module.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { AuditService } from './audit.service';
|
||||
import { AuditController } from './audit.controller';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [AuditService],
|
||||
controllers: [AuditController],
|
||||
exports: [AuditService],
|
||||
})
|
||||
export class AuditModule {}
|
||||
58
src/audit/audit.service.ts
Normal file
58
src/audit/audit.service.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ClsService } from 'nestjs-cls';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditAction, AuditContext, AUDIT_CTX_KEY } from './audit.constants';
|
||||
|
||||
interface AuditLogInput {
|
||||
action: AuditAction;
|
||||
resourceType?: string;
|
||||
resourceId?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
/**
|
||||
* Use when the actor differs from the current request user — e.g. failed
|
||||
* logins (no req.user), system-driven webhook events, or impersonation.
|
||||
*/
|
||||
actorOverride?: { id: string | null; role: string };
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuditService {
|
||||
private readonly logger = new Logger(AuditService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly cls: ClsService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Persist an audit row. Fire-and-forget: never awaited by callers, never
|
||||
* surfaces errors to the request path.
|
||||
*
|
||||
* Future archival cutover point: a scheduled job can move rows older than
|
||||
* the retention window to S3 and delete them here without app changes.
|
||||
*/
|
||||
log(input: AuditLogInput): void {
|
||||
const ctx = this.cls.get<AuditContext>(AUDIT_CTX_KEY) ?? {};
|
||||
const actor = input.actorOverride ?? { id: ctx.userId ?? null, role: ctx.role ?? 'SYSTEM' };
|
||||
|
||||
this.prisma.auditLog
|
||||
.create({
|
||||
data: {
|
||||
actorId: actor.id ?? null,
|
||||
actorRole: actor.role ?? 'SYSTEM',
|
||||
action: input.action,
|
||||
resourceType: input.resourceType ?? null,
|
||||
resourceId: input.resourceId ?? null,
|
||||
metadata: (input.metadata ?? null) as never,
|
||||
ipAddress: ctx.ip ?? null,
|
||||
userAgent: ctx.userAgent ?? null,
|
||||
},
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
this.logger.error(
|
||||
`Audit write failed (action=${input.action}): ${err.message}`,
|
||||
err.stack,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
39
src/audit/diff.util.ts
Normal file
39
src/audit/diff.util.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
function isDeepEqual(a: unknown, b: unknown): boolean {
|
||||
if (a === b) return true;
|
||||
if (a === null || b === null || a === undefined || b === undefined) return false;
|
||||
if (typeof a !== typeof b) return false;
|
||||
if (typeof a !== 'object') return false;
|
||||
if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime();
|
||||
if (Array.isArray(a) && Array.isArray(b)) {
|
||||
if (a.length !== b.length) return false;
|
||||
return a.every((v, i) => isDeepEqual(v, b[i]));
|
||||
}
|
||||
const ka = Object.keys(a as object);
|
||||
const kb = Object.keys(b as object);
|
||||
if (ka.length !== kb.length) return false;
|
||||
return ka.every((k) =>
|
||||
isDeepEqual((a as Record<string, unknown>)[k], (b as Record<string, unknown>)[k]),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a before/after diff limited to the explicit field allowlist.
|
||||
* Returns null when nothing in the allowlist changed.
|
||||
*
|
||||
* NEVER include sensitive fields (password, passwordHash, tokens) in the allowlist.
|
||||
*/
|
||||
export function buildDiff<T extends Record<string, unknown>>(
|
||||
before: T,
|
||||
after: T,
|
||||
fields: (keyof T)[],
|
||||
): { before: Partial<T>; after: Partial<T> } | null {
|
||||
const b: Partial<T> = {};
|
||||
const a: Partial<T> = {};
|
||||
for (const f of fields) {
|
||||
if (!isDeepEqual(before[f], after[f])) {
|
||||
b[f] = before[f];
|
||||
a[f] = after[f];
|
||||
}
|
||||
}
|
||||
return Object.keys(a).length === 0 ? null : { before: b, after: a };
|
||||
}
|
||||
50
src/audit/dto/audit-query.dto.ts
Normal file
50
src/audit/dto/audit-query.dto.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsOptional, IsString, IsInt, Min, Max, IsISO8601 } from 'class-validator';
|
||||
|
||||
export class AuditQueryDto {
|
||||
@ApiPropertyOptional({ description: 'Filter by actor user id' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
actorId?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Filter by AuditAction' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
action?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Filter by resource type (e.g. AgentProfile)' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
resourceType?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Filter by resource id' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
resourceId?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'ISO date — inclusive lower bound' })
|
||||
@IsOptional()
|
||||
@IsISO8601()
|
||||
from?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'ISO date — inclusive upper bound' })
|
||||
@IsOptional()
|
||||
@IsISO8601()
|
||||
to?: string;
|
||||
|
||||
@ApiPropertyOptional({ default: 1, minimum: 1 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number = 1;
|
||||
|
||||
@ApiPropertyOptional({ default: 25, minimum: 1, maximum: 100 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
limit?: number = 25;
|
||||
}
|
||||
1
src/audit/dto/index.ts
Normal file
1
src/audit/dto/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './audit-query.dto';
|
||||
4
src/audit/index.ts
Normal file
4
src/audit/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './audit.module';
|
||||
export * from './audit.service';
|
||||
export * from './audit.constants';
|
||||
export * from './diff.util';
|
||||
@@ -27,6 +27,8 @@ import {
|
||||
} from './dto';
|
||||
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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
46
src/common/interceptors/audit-context.interceptor.ts
Normal file
46
src/common/interceptors/audit-context.interceptor.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import {
|
||||
CallHandler,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
} from '@nestjs/common';
|
||||
import { Observable } from 'rxjs';
|
||||
import { ClsService } from 'nestjs-cls';
|
||||
import { AUDIT_CTX_KEY, AuditContext } from '../../audit/audit.constants';
|
||||
|
||||
/**
|
||||
* Populates the CLS store with the actor + request metadata so AuditService
|
||||
* can read it without callers having to thread context through every call.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AuditContextInterceptor implements NestInterceptor {
|
||||
constructor(private readonly cls: ClsService) {}
|
||||
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||
if (context.getType() !== 'http') {
|
||||
return next.handle();
|
||||
}
|
||||
|
||||
const req = context.switchToHttp().getRequest();
|
||||
const user = req?.user as { id?: string; role?: string } | undefined;
|
||||
const forwardedFor =
|
||||
typeof req?.headers?.['x-forwarded-for'] === 'string'
|
||||
? (req.headers['x-forwarded-for'] as string).split(',')[0].trim()
|
||||
: undefined;
|
||||
const ip = forwardedFor || req?.ip || req?.socket?.remoteAddress || null;
|
||||
const userAgent =
|
||||
typeof req?.headers?.['user-agent'] === 'string'
|
||||
? (req.headers['user-agent'] as string)
|
||||
: null;
|
||||
|
||||
const ctx: AuditContext = {
|
||||
userId: user?.id ?? null,
|
||||
role: user?.role ?? null,
|
||||
ip,
|
||||
userAgent,
|
||||
};
|
||||
|
||||
this.cls.set(AUDIT_CTX_KEY, ctx);
|
||||
return next.handle();
|
||||
}
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
export * from './transform.interceptor';
|
||||
export * from './audit-context.interceptor';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user