Files
goodgo-platform/docs/audits/ADMIN_AUDIT_ARCHITECTURE.md
Ho Ngoc Hai 11f2bf26e6
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
chore: update project documentation, audit reports, and initialize IDE configuration files
2026-04-19 03:12:54 +07:00

28 KiB

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

// 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

// 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

// 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

// 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

// 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

// 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

// 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)

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

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