Some checks failed
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 29s
CI / E2E Tests (push) Has been skipped
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 2m42s
Deploy / Build Web Image (push) Failing after 27s
Deploy / Build AI Services Image (push) Failing after 29s
E2E Tests / Playwright E2E (push) Failing after 43s
Deploy / Build API Image (push) Failing after 1m31s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 6s
Security Scanning / Trivy Scan — API Image (push) Failing after 5m35s
Security Scanning / Trivy Scan — AI Services Image (push) Failing after 3m45s
Deploy / Deploy to Staging (push) Has been skipped
Deploy / Smoke Test Staging (push) Has been skipped
Deploy / Deploy to Production (push) Has been skipped
Deploy / Smoke Test Production (push) Has been skipped
Deploy / Rollback Staging (push) Has been skipped
Deploy / Rollback Production (push) Has been skipped
Security Scanning / Trivy Scan — Web Image (push) Failing after 13m51s
Security Scanning / Trivy Filesystem Scan (push) Failing after 14m46s
Security Scanning / Security Gate (push) Has been cancelled
676 lines
28 KiB
Markdown
676 lines
28 KiB
Markdown
# Kiến Trúc Ghi Nhật Ký Kiểm Tra cho Module Admin của GoodGo
|
|
|
|
## Tổng Quan Thiết Kế Hệ Thống
|
|
|
|
```
|
|
┌─────────────────────────────────────────────────────────────────────┐
|
|
│ 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 │
|
|
└──────────────────────────┘
|
|
```
|
|
|
|
---
|
|
|
|
## Trình Tự Luồng Dữ Liệu
|
|
|
|
```
|
|
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: '...'
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Bổ Sung Schema Prisma
|
|
|
|
```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
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Tầng Repository
|
|
|
|
### Giao Diện Domain
|
|
```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;
|
|
}
|
|
```
|
|
|
|
### Triển Khai Infrastructure
|
|
```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
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Triển Khai Event Listener
|
|
|
|
```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 để Truy Xuất Dữ Liệu
|
|
|
|
```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,
|
|
});
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Endpoint Controller
|
|
|
|
```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,
|
|
),
|
|
);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Đăng Ký DI
|
|
|
|
```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 {}
|
|
```
|
|
|
|
---
|
|
|
|
## Chiến Lược Kiểm Thử
|
|
|
|
### Kiểm Thử Đơn Vị (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();
|
|
});
|
|
});
|
|
```
|
|
|
|
### Kiểm Thử Tích Hợp
|
|
```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');
|
|
});
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## Tóm Tắt
|
|
|
|
Kiến trúc này đảm bảo:
|
|
|
|
✅ **Tách Biệt Mối Quan Tâm** - Ghi nhật ký kiểm tra là mối quan tâm riêng biệt thông qua event listener
|
|
✅ **Không Chặn Luồng Chính** - Ghi nhật ký kiểm tra xảy ra bất đồng bộ, không chặn thao tác chính
|
|
✅ **Khả Năng Tái Sử Dụng** - Một listener duy nhất xử lý tất cả các hành động admin
|
|
✅ **Nhất Quán** - Tuân theo các mẫu DDD/CQRS hiện có
|
|
✅ **Khả Năng Truy Vấn** - Lịch sử kiểm tra đầy đủ với khả năng lọc
|
|
✅ **Tuân Thủ** - Hồ sơ đầy đủ về ai đã làm gì và khi nào
|