chore(docs): consolidate 22 audit files from root into docs/audits/
Root directory had accumulated audit/exploration markdown files cluttering the project root. Moved all audit-related files to docs/audits/ with a README.md index, and updated cross-references in K6_LOAD_TESTING_GUIDE.md and README_FRONTEND_DOCS.md. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
676
docs/audits/ADMIN_AUDIT_ARCHITECTURE.md
Normal file
676
docs/audits/ADMIN_AUDIT_ARCHITECTURE.md
Normal file
@@ -0,0 +1,676 @@
|
||||
# Audit Logging Architecture for GoodGo Admin Module
|
||||
|
||||
## System Design Overview
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ HTTP Request (Admin Action) │
|
||||
│ POST /admin/users/ban │ POST /admin/moderation/approve │ etc. │
|
||||
└────────────────────────────┬──────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────┐
|
||||
│ Authentication & Validation
|
||||
│ - JwtAuthGuard (@UseGuards)
|
||||
│ - RolesGuard (@Roles('ADMIN'))
|
||||
│ - ValidationPipe (DTO)
|
||||
│ - @CurrentUser() extracts JWT
|
||||
└──────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────┐
|
||||
│ Controller (Presentation Layer) │
|
||||
│ │
|
||||
│ @Post('users/ban') │
|
||||
│ async banUser( │
|
||||
│ @Body() dto: BanUserDto, │ ◄─── DTO validation
|
||||
│ @CurrentUser() user: JwtPayload │ ◄─── Admin ID here!
|
||||
│ ) { │
|
||||
│ return this.commandBus.execute( │
|
||||
│ new BanUserCommand( │
|
||||
│ dto.userId, │
|
||||
│ user.sub, ◄─────────────┐ │
|
||||
│ dto.reason │ │
|
||||
│ ) │ │
|
||||
│ ); │ │
|
||||
│ } │ │
|
||||
└─────────────────┬───────────────┘ │
|
||||
│ │
|
||||
▼ (Command) │
|
||||
┌──────────────────────────────────────┐
|
||||
│ CQRS Bus - Routing │
|
||||
│ (CommandBus.execute) │
|
||||
└────────────────┬─────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Command Handler (Application Layer) │
|
||||
│ │
|
||||
│ @CommandHandler(BanUserCommand) │
|
||||
│ export class BanUserHandler { │
|
||||
│ async execute( │
|
||||
│ command: BanUserCommand │
|
||||
│ ): Promise<BanUserResult> { │
|
||||
│ │
|
||||
│ // 1. Business Logic │
|
||||
│ const user = await │
|
||||
│ this.userRepo.findById(...) │
|
||||
│ user.deactivate() │
|
||||
│ await this.userRepo.update(...) │
|
||||
│ │
|
||||
│ // 2. Publish Domain Event │
|
||||
│ this.eventBus.publish( │
|
||||
│ new UserBannedEvent( │
|
||||
│ user.id, ◄─── Resource ID
|
||||
│ command.adminId, ◄─── Admin ID
|
||||
│ command.reason ◄─── Action context
|
||||
│ ) │
|
||||
│ ); │
|
||||
│ │
|
||||
│ return { ... }; │
|
||||
│ } │
|
||||
│ } │
|
||||
└──────────────────┬────────────────────┘
|
||||
│ (Event Published)
|
||||
▼ ┌──────────────────────────────────┐
|
||||
│ │ Event Emitted: 'user.banned' │
|
||||
│ │ { │
|
||||
│ │ eventName: 'user.banned', │
|
||||
│ │ aggregateId: 'usr_xyz', │
|
||||
│ │ adminId: 'adm_abc', ◄────┐ │
|
||||
│ │ reason: '...', │ │
|
||||
│ │ occurredAt: now() │ │
|
||||
│ │ } │ │
|
||||
└──────────────────────────────────┘ │
|
||||
│ │
|
||||
┌──────────────────┴──────────────────┐ │
|
||||
│ │ │
|
||||
▼ (Event Subscribe) ▼ │
|
||||
┌──────────────────────────┐ ┌──────────────────┐ │
|
||||
│ Existing Listeners │ │ Audit Listener │ │
|
||||
│ │ │ │ │
|
||||
│ UserBannedListener │ │ AuditLogging │ │
|
||||
│ @OnEvent('user.banned') │ │ Listener │ │
|
||||
│ - Deactivate listings │ │ @OnEvent(...) │ │
|
||||
│ - Send notification │ │ - Extract info │ │
|
||||
│ │ │ - Create record │◄─┘
|
||||
└──────────────────────────┘ │ - Persist to DB │
|
||||
└────────┬─────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────┐
|
||||
│ AuditLog Repository │
|
||||
│ (Infrastructure Layer) │
|
||||
│ │
|
||||
│ @Injectable() │
|
||||
│ export class Prisma │
|
||||
│ AuditLogRepository {} │
|
||||
│ │
|
||||
│ Methods: │
|
||||
│ - create(auditLog) │
|
||||
│ - findMany(filters) │
|
||||
│ - findById(id) │
|
||||
└──────────────┬───────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────┐
|
||||
│ Prisma Client │
|
||||
│ (Database Driver) │
|
||||
└──────────────┬───────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────┐
|
||||
│ PostgreSQL Database │
|
||||
│ │
|
||||
│ AuditLog Table │
|
||||
│ ├─ id │
|
||||
│ ├─ adminId │
|
||||
│ ├─ action │
|
||||
│ ├─ resourceType │
|
||||
│ ├─ resourceId │
|
||||
│ ├─ reason │
|
||||
│ ├─ status │
|
||||
│ └─ createdAt │
|
||||
└──────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Data Flow Sequence
|
||||
|
||||
```
|
||||
Admin Action → Controller → Command → Event → AuditListener → Repository → Database
|
||||
|
||||
┌───┐ POST /admin/users/ban ┌──────────┐
|
||||
│ ├──────────────────────────────────▶│ Controller
|
||||
└───┘ {userId, reason, jwt} └──────┬───┘
|
||||
│
|
||||
(validate & extract admin ID)
|
||||
│
|
||||
┌──────▼────────┐
|
||||
│ CommandBus │
|
||||
└──────┬────────┘
|
||||
│
|
||||
┌──────────▼──────────┐
|
||||
│ BanUserCommand │
|
||||
│ {userId, adminId, │
|
||||
│ reason, unban} │
|
||||
└──────────┬──────────┘
|
||||
│
|
||||
┌──────────▼──────────────┐
|
||||
│ BanUserHandler │
|
||||
│ (execute business logic)│
|
||||
└──────────┬──────────────┘
|
||||
│
|
||||
(publish event)
|
||||
│
|
||||
┌──────────▼────────────────┐
|
||||
│ UserBannedEvent │
|
||||
│ {aggregateId, adminId, │
|
||||
│ reason, occurredAt} │
|
||||
└──────────┬────────────────┘
|
||||
│
|
||||
┌──────────────────────────┼──────────────────────────┐
|
||||
│ │ │
|
||||
▼ (existing listener) ▼ (NEW - audit listener) │
|
||||
┌──────────────────────┐ ┌──────────────────────────────┐ │
|
||||
│ UserBannedListener │ │ AuditLoggingListener │ │
|
||||
│ - Deactivate listings│ │ - Extract event data │ │
|
||||
│ - Send notification │ │ - Map to AuditLog record │ │
|
||||
└──────────────────────┘ │ - Call repository.create() │ │
|
||||
└──────────┬──────────────────┘ │
|
||||
│ │
|
||||
┌──────────▼─────────┐ │
|
||||
│ PrismaAuditLog │ │
|
||||
│ Repository │ │
|
||||
│ .create({...}) │ │
|
||||
└──────────┬─────────┘ │
|
||||
│ │
|
||||
┌──────────▼──────────┐ │
|
||||
│ prisma.auditLog │ │
|
||||
│ .create({ │ │
|
||||
│ adminId, │ │
|
||||
│ action, │ │
|
||||
│ resourceType, │ │
|
||||
│ resourceId, │ │
|
||||
│ reason, │ │
|
||||
│ status, │ │
|
||||
│ createdAt │ │
|
||||
│ }) │ │
|
||||
└──────────┬──────────┘ │
|
||||
│ │
|
||||
┌──────────▼──────────┐ │
|
||||
│ PostgreSQL │ │
|
||||
│ INSERT INTO auditLog│ │
|
||||
│ values(...) │ │
|
||||
└─────────────────────┘ │
|
||||
▼ (HTTP Response)
|
||||
200 OK {
|
||||
userId: 'usr_xyz',
|
||||
isActive: false,
|
||||
message: '...'
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Prisma Schema Addition
|
||||
|
||||
```typescript
|
||||
// Add to prisma/schema.prisma
|
||||
|
||||
// ============================================================================
|
||||
// ADMIN AUDIT LOG
|
||||
// ============================================================================
|
||||
|
||||
enum AdminAction {
|
||||
USER_BANNED
|
||||
USER_UNBANNED
|
||||
USER_DEACTIVATED
|
||||
USER_STATUS_UPDATED
|
||||
LISTING_APPROVED
|
||||
LISTING_REJECTED
|
||||
LISTING_BULK_MODERATED
|
||||
KYC_APPROVED
|
||||
KYC_REJECTED
|
||||
SUBSCRIPTION_ADJUSTED
|
||||
}
|
||||
|
||||
enum AuditResourceType {
|
||||
USER
|
||||
LISTING
|
||||
KYC
|
||||
SUBSCRIPTION
|
||||
}
|
||||
|
||||
enum AuditStatus {
|
||||
SUCCESS
|
||||
FAILED
|
||||
}
|
||||
|
||||
model AuditLog {
|
||||
id String @id @default(cuid())
|
||||
adminId String
|
||||
admin User? @relation(fields: [adminId], references: [id])
|
||||
action AdminAction
|
||||
resourceType AuditResourceType
|
||||
resourceId String
|
||||
changes Json? // {before: {...}, after: {...}}
|
||||
reason String? @db.Text
|
||||
ipAddress String?
|
||||
userAgent String?
|
||||
status AuditStatus @default(SUCCESS)
|
||||
statusCode Int?
|
||||
errorMessage String?
|
||||
duration Int? // milliseconds
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([adminId])
|
||||
@@index([action])
|
||||
@@index([resourceType])
|
||||
@@index([resourceId])
|
||||
@@index([createdAt])
|
||||
@@index([adminId, createdAt(sort: Desc)])
|
||||
@@index([action, createdAt(sort: Desc)])
|
||||
@@index([resourceType, resourceId, createdAt(sort: Desc)])
|
||||
}
|
||||
|
||||
// Add relationship to User model:
|
||||
model User {
|
||||
// ... existing fields ...
|
||||
|
||||
auditLogs AuditLog[] // New relation
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Repository Layer
|
||||
|
||||
### Domain Interface
|
||||
```typescript
|
||||
// domain/repositories/audit-log.repository.ts
|
||||
|
||||
export interface IAuditLogRepository {
|
||||
create(auditLog: CreateAuditLogDto): Promise<AuditLog>;
|
||||
findMany(params: FindAuditLogsParams): Promise<PaginatedResult<AuditLog>>;
|
||||
findById(id: string): Promise<AuditLog | null>;
|
||||
findByAdminId(adminId: string, pagination: Pagination): Promise<PaginatedResult<AuditLog>>;
|
||||
}
|
||||
|
||||
export interface CreateAuditLogDto {
|
||||
adminId: string;
|
||||
action: AdminAction;
|
||||
resourceType: AuditResourceType;
|
||||
resourceId: string;
|
||||
changes?: Record<string, any>;
|
||||
reason?: string;
|
||||
ipAddress?: string;
|
||||
userAgent?: string;
|
||||
status: AuditStatus;
|
||||
statusCode?: number;
|
||||
errorMessage?: string;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
export interface FindAuditLogsParams {
|
||||
page: number;
|
||||
limit: number;
|
||||
adminId?: string;
|
||||
action?: AdminAction;
|
||||
resourceType?: AuditResourceType;
|
||||
resourceId?: string;
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
}
|
||||
```
|
||||
|
||||
### Infrastructure Implementation
|
||||
```typescript
|
||||
// infrastructure/repositories/prisma-audit-log.repository.ts
|
||||
|
||||
@Injectable()
|
||||
export class PrismaAuditLogRepository implements IAuditLogRepository {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async create(dto: CreateAuditLogDto): Promise<AuditLog> {
|
||||
return this.prisma.auditLog.create({
|
||||
data: {
|
||||
adminId: dto.adminId,
|
||||
action: dto.action,
|
||||
resourceType: dto.resourceType,
|
||||
resourceId: dto.resourceId,
|
||||
changes: dto.changes,
|
||||
reason: dto.reason,
|
||||
ipAddress: dto.ipAddress,
|
||||
userAgent: dto.userAgent,
|
||||
status: dto.status,
|
||||
statusCode: dto.statusCode,
|
||||
errorMessage: dto.errorMessage,
|
||||
duration: dto.duration,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findMany(params: FindAuditLogsParams): Promise<PaginatedResult<AuditLog>> {
|
||||
const skip = (params.page - 1) * params.limit;
|
||||
|
||||
const where: Prisma.AuditLogWhereInput = {
|
||||
...(params.adminId && { adminId: params.adminId }),
|
||||
...(params.action && { action: params.action }),
|
||||
...(params.resourceType && { resourceType: params.resourceType }),
|
||||
...(params.resourceId && { resourceId: params.resourceId }),
|
||||
...(params.startDate || params.endDate) && {
|
||||
createdAt: {
|
||||
...(params.startDate && { gte: params.startDate }),
|
||||
...(params.endDate && { lte: params.endDate }),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const [data, total] = await Promise.all([
|
||||
this.prisma.auditLog.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: params.limit,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}),
|
||||
this.prisma.auditLog.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
data,
|
||||
total,
|
||||
page: params.page,
|
||||
limit: params.limit,
|
||||
totalPages: Math.ceil(total / params.limit),
|
||||
};
|
||||
}
|
||||
|
||||
// ... other methods
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Event Listener Implementation
|
||||
|
||||
```typescript
|
||||
// application/listeners/audit-logging.listener.ts
|
||||
|
||||
@Injectable()
|
||||
export class AuditLoggingListener {
|
||||
constructor(
|
||||
@Inject(AUDIT_LOG_REPOSITORY) private readonly auditRepo: IAuditLogRepository,
|
||||
private readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
@OnEvent('user.banned', { async: true })
|
||||
async handleUserBanned(event: UserBannedEvent): Promise<void> {
|
||||
await this.createAuditLog({
|
||||
adminId: event.adminId,
|
||||
action: AdminAction.USER_BANNED,
|
||||
resourceType: AuditResourceType.USER,
|
||||
resourceId: event.aggregateId,
|
||||
reason: event.reason,
|
||||
status: AuditStatus.SUCCESS,
|
||||
});
|
||||
}
|
||||
|
||||
@OnEvent('user.unbanned', { async: true })
|
||||
async handleUserUnbanned(event: UserUnbannedEvent): Promise<void> {
|
||||
await this.createAuditLog({
|
||||
adminId: event.adminId,
|
||||
action: AdminAction.USER_UNBANNED,
|
||||
resourceType: AuditResourceType.USER,
|
||||
resourceId: event.aggregateId,
|
||||
status: AuditStatus.SUCCESS,
|
||||
});
|
||||
}
|
||||
|
||||
@OnEvent('listing.approved', { async: true })
|
||||
async handleListingApproved(event: ListingApprovedEvent): Promise<void> {
|
||||
await this.createAuditLog({
|
||||
adminId: event.adminId,
|
||||
action: AdminAction.LISTING_APPROVED,
|
||||
resourceType: AuditResourceType.LISTING,
|
||||
resourceId: event.aggregateId,
|
||||
reason: event.moderationNotes,
|
||||
status: AuditStatus.SUCCESS,
|
||||
});
|
||||
}
|
||||
|
||||
@OnEvent('kyc.approved', { async: true })
|
||||
async handleKycApproved(event: KycApprovedEvent): Promise<void> {
|
||||
await this.createAuditLog({
|
||||
adminId: event.adminId,
|
||||
action: AdminAction.KYC_APPROVED,
|
||||
resourceType: AuditResourceType.KYC,
|
||||
resourceId: event.aggregateId,
|
||||
reason: event.comments,
|
||||
status: AuditStatus.SUCCESS,
|
||||
});
|
||||
}
|
||||
|
||||
// ... more handlers ...
|
||||
|
||||
private async createAuditLog(dto: CreateAuditLogDto): Promise<void> {
|
||||
try {
|
||||
await this.auditRepo.create(dto);
|
||||
this.logger.log(
|
||||
`Audit logged: ${dto.action} on ${dto.resourceType}/${dto.resourceId}`,
|
||||
'AuditLoggingListener',
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to create audit log: ${String(error)}`,
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
'AuditLoggingListener',
|
||||
);
|
||||
// Don't re-throw to prevent interrupting the main operation
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Query Handler for Retrieval
|
||||
|
||||
```typescript
|
||||
// application/queries/get-audit-logs/get-audit-logs.handler.ts
|
||||
|
||||
export class GetAuditLogsQuery {
|
||||
constructor(
|
||||
public readonly page: number,
|
||||
public readonly limit: number,
|
||||
public readonly adminId?: string,
|
||||
public readonly action?: AdminAction,
|
||||
public readonly resourceType?: AuditResourceType,
|
||||
public readonly startDate?: Date,
|
||||
public readonly endDate?: Date,
|
||||
) {}
|
||||
}
|
||||
|
||||
@QueryHandler(GetAuditLogsQuery)
|
||||
export class GetAuditLogsHandler implements IQueryHandler<GetAuditLogsQuery> {
|
||||
constructor(
|
||||
@Inject(AUDIT_LOG_REPOSITORY) private readonly auditRepo: IAuditLogRepository,
|
||||
) {}
|
||||
|
||||
async execute(query: GetAuditLogsQuery): Promise<PaginatedResult<AuditLog>> {
|
||||
return this.auditRepo.findMany({
|
||||
page: query.page,
|
||||
limit: query.limit,
|
||||
adminId: query.adminId,
|
||||
action: query.action,
|
||||
resourceType: query.resourceType,
|
||||
startDate: query.startDate,
|
||||
endDate: query.endDate,
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Controller Endpoint
|
||||
|
||||
```typescript
|
||||
// presentation/controllers/admin.controller.ts (add to existing file)
|
||||
|
||||
@Get('audit-logs')
|
||||
@ApiOperation({ summary: 'Get audit logs with filters' })
|
||||
@ApiQuery({ name: 'page', required: false, type: Number })
|
||||
@ApiQuery({ name: 'limit', required: false, type: Number })
|
||||
@ApiQuery({ name: 'adminId', required: false, type: String })
|
||||
@ApiQuery({ name: 'action', required: false, enum: AdminAction })
|
||||
@ApiQuery({ name: 'resourceType', required: false, enum: AuditResourceType })
|
||||
@ApiQuery({ name: 'startDate', required: false, type: String })
|
||||
@ApiQuery({ name: 'endDate', required: false, type: String })
|
||||
@ApiResponse({ status: 200, description: 'Audit logs retrieved' })
|
||||
async getAuditLogs(
|
||||
@Query() query: GetAuditLogsQueryDto,
|
||||
): Promise<PaginatedResult<AuditLog>> {
|
||||
return this.queryBus.execute(
|
||||
new GetAuditLogsQuery(
|
||||
query.page ?? 1,
|
||||
query.limit ?? 20,
|
||||
query.adminId,
|
||||
query.action,
|
||||
query.resourceType,
|
||||
query.startDate ? new Date(query.startDate) : undefined,
|
||||
query.endDate ? new Date(query.endDate) : undefined,
|
||||
),
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## DI Registration
|
||||
|
||||
```typescript
|
||||
// admin.module.ts (update existing)
|
||||
|
||||
import { AUDIT_LOG_REPOSITORY, IAuditLogRepository } from '...';
|
||||
import { PrismaAuditLogRepository } from '...';
|
||||
import { AuditLoggingListener } from '...';
|
||||
|
||||
const QueryHandlers = [
|
||||
// ... existing handlers ...
|
||||
GetAuditLogsHandler, // NEW
|
||||
];
|
||||
|
||||
const EventListeners = [
|
||||
UserBannedListener,
|
||||
UserDeactivatedListener,
|
||||
AuditLoggingListener, // NEW
|
||||
];
|
||||
|
||||
@Module({
|
||||
imports: [CqrsModule, AuthModule, ListingsModule, SubscriptionsModule],
|
||||
controllers: [AdminController, AdminModerationController],
|
||||
providers: [
|
||||
// Repositories
|
||||
{ provide: ADMIN_QUERY_REPOSITORY, useClass: PrismaAdminQueryRepository },
|
||||
{ provide: AUDIT_LOG_REPOSITORY, useClass: PrismaAuditLogRepository }, // NEW
|
||||
|
||||
// CQRS
|
||||
...CommandHandlers,
|
||||
...QueryHandlers,
|
||||
|
||||
// Event Listeners
|
||||
...EventListeners,
|
||||
],
|
||||
})
|
||||
export class AdminModule {}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Test (AuditLoggingListener)
|
||||
```typescript
|
||||
describe('AuditLoggingListener', () => {
|
||||
let listener: AuditLoggingListener;
|
||||
let auditRepo: MockRepository<IAuditLogRepository>;
|
||||
|
||||
beforeEach(() => {
|
||||
auditRepo = mockRepository();
|
||||
listener = new AuditLoggingListener(auditRepo, mockLogger());
|
||||
});
|
||||
|
||||
it('should create audit log on user.banned event', async () => {
|
||||
const event = new UserBannedEvent('usr_123', 'admin_456', 'Violation');
|
||||
|
||||
await listener.handleUserBanned(event);
|
||||
|
||||
expect(auditRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
adminId: 'admin_456',
|
||||
action: AdminAction.USER_BANNED,
|
||||
resourceId: 'usr_123',
|
||||
reason: 'Violation',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle repository errors gracefully', async () => {
|
||||
auditRepo.create.mockRejectedValueOnce(new Error('DB error'));
|
||||
const event = new UserBannedEvent('usr_123', 'admin_456', 'Violation');
|
||||
|
||||
// Should not throw
|
||||
await expect(listener.handleUserBanned(event)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Integration Test
|
||||
```typescript
|
||||
describe('Audit Logging Integration', () => {
|
||||
let app: INestApplication;
|
||||
let prisma: PrismaService;
|
||||
|
||||
beforeAll(async () => {
|
||||
const module = await Test.createTestingModule({
|
||||
imports: [AdminModule, SharedModule],
|
||||
}).compile();
|
||||
app = module.createNestApplication();
|
||||
prisma = module.get(PrismaService);
|
||||
});
|
||||
|
||||
it('should log admin action to database', async () => {
|
||||
const admin = await createTestAdmin(prisma);
|
||||
const user = await createTestUser(prisma);
|
||||
|
||||
await app.get(CommandBus).execute(
|
||||
new BanUserCommand(user.id, admin.id, 'Test ban')
|
||||
);
|
||||
|
||||
const auditLog = await prisma.auditLog.findFirst({
|
||||
where: { adminId: admin.id, resourceId: user.id },
|
||||
});
|
||||
|
||||
expect(auditLog).toBeDefined();
|
||||
expect(auditLog?.action).toBe(AdminAction.USER_BANNED);
|
||||
expect(auditLog?.reason).toBe('Test ban');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
This architecture ensures:
|
||||
|
||||
✅ **Separation of Concerns** - Audit logging as separate concern via event listener
|
||||
✅ **Non-Blocking** - Audit logging happens async, doesn't block main operation
|
||||
✅ **Reusability** - Single listener handles all admin actions
|
||||
✅ **Consistency** - Follows existing DDD/CQRS patterns
|
||||
✅ **Queryability** - Full audit trail with filtering capabilities
|
||||
✅ **Compliance** - Complete record of who did what and when
|
||||
|
||||
Reference in New Issue
Block a user