Audit Module Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Build a real, persisted audit trail (AuditEvent entity + service + GET /api/v1/audit-events API), wire it into the Super Admin Dashboard, and emit events from login, logout, RBAC role updates, and organization creation — replacing the hardcoded placeholder warning and empty auditSecurityFeed.

Architecture: New backend/src/audit/ NestJS module (entity, service, controller) following the existing tickets/ module shape. AuditService.record(...) is injected directly into AuthService, RoleService (rbac), OrganizationsService, and DashboardQueryService — no event bus. Read access reuses RBAC permissions (audit:read:system, audit:read:organization) already seeded in the database.

Tech Stack: NestJS 11, TypeORM 0.3, PostgreSQL, class-validator, Jest.

Spec: docs/superpowers/specs/2026-07-04-audit-module-design.md


Task 1: Audit severity enum and event-type constants

Files:

  • Create: backend/src/audit/enums/audit-severity.enum.ts

  • Create: backend/src/audit/audit-event-types.ts

  • Step 1: Create the severity enum

// backend/src/audit/enums/audit-severity.enum.ts
export enum AuditSeverity {
  INFO = 'info',
  WARNING = 'warning',
  HIGH = 'high',
  CRITICAL = 'critical',
}
  • Step 2: Create the shared event-type constants
// backend/src/audit/audit-event-types.ts
export const AUDIT_EVENT_TYPES = {
  AUTH_LOGIN_SUCCESS: 'auth.login_success',
  AUTH_LOGIN_FAILED: 'auth.login_failed',
  AUTH_LOGOUT: 'auth.logout',
  RBAC_ROLE_PERMISSIONS_UPDATED: 'rbac.role.permissions_updated',
  ORGANIZATION_CREATED: 'organization.created',
} as const;

export type AuditEventType = (typeof AUDIT_EVENT_TYPES)[keyof typeof AUDIT_EVENT_TYPES];
  • Step 3: Commit
git add backend/src/audit/enums/audit-severity.enum.ts backend/src/audit/audit-event-types.ts
git commit -m "feat(audit): add severity enum and event-type constants"

Task 2: AuditEvent entity

Files:

  • Create: backend/src/audit/entities/audit-event.entity.ts

  • Step 1: Write the entity

// backend/src/audit/entities/audit-event.entity.ts
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn } from 'typeorm';
import { AuditSeverity } from '@/audit/enums/audit-severity.enum';

@Entity('audit_events')
export class AuditEvent {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ type: 'varchar', length: 100 })
  type: string;

  @Column({ name: 'actor_id', type: 'uuid', nullable: true })
  actorId: string | null;

  @Column({ name: 'actor_email', type: 'varchar', length: 255, nullable: true })
  actorEmail: string | null;

  @Column({ name: 'organization_id', type: 'uuid', nullable: true })
  organizationId: string | null;

  @Column({ type: 'text' })
  message: string;

  @Column({ type: 'enum', enum: AuditSeverity, default: AuditSeverity.INFO })
  severity: AuditSeverity;

  @Column({ type: 'jsonb', nullable: true })
  metadata: Record<string, unknown> | null;

  @CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
  createdAt: Date;
}
  • Step 2: Verify it compiles

Run: cd backend && bun run build
Expected: build succeeds (no TypeScript errors).

  • Step 3: Commit
git add backend/src/audit/entities/audit-event.entity.ts
git commit -m "feat(audit): add AuditEvent entity"

Task 3: Database migration for audit_events

Files:

  • Create: backend/src/migrations/1778600000000-CreateAuditEventsTable.ts

  • Step 1: Write the migration

// backend/src/migrations/1778600000000-CreateAuditEventsTable.ts
import type { MigrationInterface, QueryRunner } from 'typeorm';

export class CreateAuditEventsTable1778600000000 implements MigrationInterface {
  name = 'CreateAuditEventsTable1778600000000';

  public async up(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query(`
      CREATE TYPE audit_severity AS ENUM ('info', 'warning', 'high', 'critical');
    `);

    await queryRunner.query(`
      CREATE TABLE "audit_events" (
        "id" UUID PRIMARY KEY DEFAULT gen_random_uuid(),
        "type" VARCHAR(100) NOT NULL,
        "actor_id" UUID,
        "actor_email" VARCHAR(255),
        "organization_id" UUID REFERENCES organizations(id),
        "message" TEXT NOT NULL,
        "severity" audit_severity NOT NULL DEFAULT 'info',
        "metadata" JSONB,
        "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW()
      );
    `);

    await queryRunner.query(`
      CREATE INDEX "idx_audit_events_organization" ON "audit_events" (organization_id);
      CREATE INDEX "idx_audit_events_type" ON "audit_events" (type);
      CREATE INDEX "idx_audit_events_severity" ON "audit_events" (severity);
      CREATE INDEX "idx_audit_events_created_at" ON "audit_events" (created_at DESC);
    `);
  }

  public async down(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query(`DROP TABLE IF EXISTS "audit_events" CASCADE;`);
    await queryRunner.query(`DROP TYPE IF EXISTS audit_severity;`);
  }
}
  • Step 2: Run the migration against the local dev database (if available)

Run: cd backend && bun run migration:run
Expected: CreateAuditEventsTable1778600000000 reported as executed. If no local database is running, skip this step and note it as unverified rather than claiming it passed.

  • Step 3: Commit
git add backend/src/migrations/1778600000000-CreateAuditEventsTable.ts
git commit -m "feat(audit): add migration for audit_events table"

Task 4: AuditService — record()

Files:

  • Create: backend/src/audit/audit.service.ts

  • Test: backend/src/audit/audit.service.spec.ts

  • Step 1: Write the failing tests

// backend/src/audit/audit.service.spec.ts
import { Test, type TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import type { Repository, SelectQueryBuilder } from 'typeorm';
import { AUDIT_EVENT_TYPES } from '@/audit/audit-event-types';
import { AuditService } from '@/audit/audit.service';
import { AuditEvent } from '@/audit/entities/audit-event.entity';
import { AuditSeverity } from '@/audit/enums/audit-severity.enum';
import type { CurrentUserData } from '@/auth/decorators/current-user.decorator';
import { RoleName } from '@/common/enums';
import { AuthorizationPolicyService } from '@/rbac/authorization-policy.service';

describe('AuditService', () => {
  let service: AuditService;
  let auditEventRepository: jest.Mocked<Repository<AuditEvent>>;
  let authorizationPolicyService: jest.Mocked<AuthorizationPolicyService>;
  let queryBuilder: jest.Mocked<
    Pick<
      SelectQueryBuilder<AuditEvent>,
      'andWhere' | 'orderBy' | 'skip' | 'take' | 'getManyAndCount' | 'getMany'
    >
  >;

  const superAdmin: CurrentUserData = {
    userId: 'super-admin-1',
    email: 'super-admin@example.com',
    organizationId: null,
    role: RoleName.SUPER_ADMIN,
    roles: [RoleName.SUPER_ADMIN],
    permissions: ['audit:read:system'],
    assignedResources: [],
    assignedAreaIds: [],
  };

  const orgAdmin: CurrentUserData = {
    userId: 'admin-1',
    email: 'admin@example.com',
    organizationId: 'org-1',
    role: RoleName.ADMIN,
    roles: [RoleName.ADMIN],
    permissions: ['audit:read:organization'],
    assignedResources: [],
    assignedAreaIds: [],
  };

  beforeEach(async () => {
    queryBuilder = {
      andWhere: jest.fn().mockReturnThis(),
      orderBy: jest.fn().mockReturnThis(),
      skip: jest.fn().mockReturnThis(),
      take: jest.fn().mockReturnThis(),
      getManyAndCount: jest.fn().mockResolvedValue([[], 0]),
      getMany: jest.fn().mockResolvedValue([]),
    } as unknown as jest.Mocked<SelectQueryBuilder<AuditEvent>>;

    const module: TestingModule = await Test.createTestingModule({
      providers: [
        AuditService,
        {
          provide: getRepositoryToken(AuditEvent),
          useValue: {
            create: jest.fn(),
            save: jest.fn(),
            createQueryBuilder: jest.fn().mockReturnValue(queryBuilder),
          },
        },
        {
          provide: AuthorizationPolicyService,
          useValue: {
            hasPermission: jest.fn(),
          },
        },
      ],
    }).compile();

    service = module.get(AuditService);
    auditEventRepository = module.get(getRepositoryToken(AuditEvent));
    authorizationPolicyService = module.get(AuthorizationPolicyService);
  });

  describe('record', () => {
    it('persists an audit event, defaulting optional fields to null', async () => {
      const created = { id: 'event-1' } as AuditEvent;
      auditEventRepository.create.mockReturnValue(created);
      auditEventRepository.save.mockResolvedValue(created);

      await service.record({
        type: AUDIT_EVENT_TYPES.AUTH_LOGIN_SUCCESS,
        message: 'User logged in',
        severity: AuditSeverity.INFO,
        actorId: 'user-1',
        actorEmail: 'user@example.com',
        organizationId: 'org-1',
      });

      expect(auditEventRepository.create).toHaveBeenCalledWith({
        type: AUDIT_EVENT_TYPES.AUTH_LOGIN_SUCCESS,
        message: 'User logged in',
        severity: AuditSeverity.INFO,
        actorId: 'user-1',
        actorEmail: 'user@example.com',
        organizationId: 'org-1',
        metadata: null,
      });
      expect(auditEventRepository.save).toHaveBeenCalledWith(created);
    });

    it('swallows repository failures so callers are never broken by audit writes', async () => {
      auditEventRepository.create.mockReturnValue({} as AuditEvent);
      auditEventRepository.save.mockRejectedValue(new Error('db down'));

      await expect(
        service.record({
          type: AUDIT_EVENT_TYPES.AUTH_LOGIN_FAILED,
          message: 'Login failed',
          severity: AuditSeverity.WARNING,
        }),
      ).resolves.toBeUndefined();
    });
  });

  describe('findAll', () => {
    it('does not filter by organization for system-permission callers when none is requested', async () => {
      authorizationPolicyService.hasPermission.mockReturnValue(true);

      await service.findAll({ page: 1, limit: 20 }, superAdmin);

      expect(queryBuilder.andWhere).not.toHaveBeenCalledWith(
        'auditEvent.organizationId = :organizationId',
        expect.anything(),
      );
    });

    it('applies the requested organization filter for system-permission callers', async () => {
      authorizationPolicyService.hasPermission.mockReturnValue(true);

      await service.findAll({ page: 1, limit: 20, organizationId: 'org-9' }, superAdmin);

      expect(queryBuilder.andWhere).toHaveBeenCalledWith(
        'auditEvent.organizationId = :organizationId',
        { organizationId: 'org-9' },
      );
    });

    it('forces organization-scoped callers to their own organization regardless of query param', async () => {
      authorizationPolicyService.hasPermission.mockReturnValue(false);

      await service.findAll({ page: 1, limit: 20, organizationId: 'org-9' }, orgAdmin);

      expect(queryBuilder.andWhere).toHaveBeenCalledWith(
        'auditEvent.organizationId = :organizationId',
        { organizationId: 'org-1' },
      );
    });

    it('returns paginated results using page and limit', async () => {
      const events = [{ id: 'event-1' } as AuditEvent];
      queryBuilder.getManyAndCount.mockResolvedValue([events, 1]);
      authorizationPolicyService.hasPermission.mockReturnValue(true);

      const result = await service.findAll({ page: 2, limit: 10 }, superAdmin);

      expect(result).toEqual({ data: events, total: 1, page: 2, limit: 10 });
      expect(queryBuilder.skip).toHaveBeenCalledWith(10);
      expect(queryBuilder.take).toHaveBeenCalledWith(10);
    });
  });

  describe('getRecentForDashboard', () => {
    it('returns the most recent events ordered by createdAt desc, limited', async () => {
      const events = [{ id: 'event-1' } as AuditEvent];
      queryBuilder.getMany.mockResolvedValue(events);

      const result = await service.getRecentForDashboard(5);

      expect(result).toEqual(events);
      expect(queryBuilder.orderBy).toHaveBeenCalledWith('auditEvent.createdAt', 'DESC');
      expect(queryBuilder.take).toHaveBeenCalledWith(5);
    });

    it('filters by organization when one is provided', async () => {
      await service.getRecentForDashboard(5, 'org-1');

      expect(queryBuilder.andWhere).toHaveBeenCalledWith(
        'auditEvent.organizationId = :organizationId',
        { organizationId: 'org-1' },
      );
    });
  });
});
  • Step 2: Run the test to verify it fails

Run: cd /Users/hieunguyen/Documents/PROJECTS/HMPIOT/iot_platform/.treehouse/iot_platform-a57176/1/iot_platform && make backend-test FILE=src/audit/audit.service.spec.ts
Expected: FAIL — Cannot find module '@/audit/audit.service'.

  • Step 3: Write the DTO used by findAll
// backend/src/audit/dto/list-audit-events-query.dto.ts
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
  IsDateString,
  IsEnum,
  IsInt,
  IsOptional,
  IsString,
  IsUUID,
  Max,
  Min,
} from 'class-validator';
import { AuditSeverity } from '@/audit/enums/audit-severity.enum';

export class ListAuditEventsQueryDto {
  @ApiProperty({ required: false, description: 'Filter by event type, e.g. auth.login_failed' })
  @IsOptional()
  @IsString()
  type?: string;

  @ApiProperty({ required: false, enum: AuditSeverity })
  @IsOptional()
  @IsEnum(AuditSeverity)
  severity?: AuditSeverity;

  @ApiProperty({
    required: false,
    description: 'Filter by organization. Ignored (forced to own org) for organization-scoped callers.',
  })
  @IsOptional()
  @IsUUID()
  organizationId?: string;

  @ApiProperty({ required: false, example: '2026-01-01' })
  @IsOptional()
  @IsString()
  @IsDateString()
  dateFrom?: string;

  @ApiProperty({ required: false, example: '2026-01-31' })
  @IsOptional()
  @IsString()
  @IsDateString()
  dateTo?: string;

  @ApiProperty({ required: false, default: 1 })
  @IsOptional()
  @Type(() => Number)
  @IsInt()
  @Min(1)
  page?: number = 1;

  @ApiProperty({ required: false, default: 20 })
  @IsOptional()
  @Type(() => Number)
  @IsInt()
  @Min(1)
  @Max(100)
  limit?: number = 20;
}
  • Step 4: Implement AuditService
// backend/src/audit/audit.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import type { Repository } from 'typeorm';
import type { AuditEventType } from '@/audit/audit-event-types';
import type { ListAuditEventsQueryDto } from '@/audit/dto/list-audit-events-query.dto';
import { AuditEvent } from '@/audit/entities/audit-event.entity';
import type { AuditSeverity } from '@/audit/enums/audit-severity.enum';
import type { CurrentUserData } from '@/auth/decorators/current-user.decorator';
import { AuthorizationPolicyService } from '@/rbac/authorization-policy.service';

export interface RecordAuditEventInput {
  type: AuditEventType;
  message: string;
  severity: AuditSeverity;
  actorId?: string | null;
  actorEmail?: string | null;
  organizationId?: string | null;
  metadata?: Record<string, unknown> | null;
}

export interface PaginatedAuditEvents {
  data: AuditEvent[];
  total: number;
  page: number;
  limit: number;
}

@Injectable()
export class AuditService {
  private readonly logger = new Logger(AuditService.name);

  constructor(
    @InjectRepository(AuditEvent)
    private auditEventRepository: Repository<AuditEvent>,
    private authorizationPolicyService: AuthorizationPolicyService,
  ) {}

  async record(input: RecordAuditEventInput): Promise<void> {
    try {
      const event = this.auditEventRepository.create({
        type: input.type,
        message: input.message,
        severity: input.severity,
        actorId: input.actorId ?? null,
        actorEmail: input.actorEmail ?? null,
        organizationId: input.organizationId ?? null,
        metadata: input.metadata ?? null,
      });
      await this.auditEventRepository.save(event);
    } catch (error) {
      this.logger.error(
        `Failed to record audit event of type "${input.type}"`,
        error instanceof Error ? error.stack : undefined,
      );
    }
  }

  async findAll(
    query: ListAuditEventsQueryDto,
    currentUser: CurrentUserData,
  ): Promise<PaginatedAuditEvents> {
    const page = query.page ?? 1;
    const limit = query.limit ?? 20;
    const hasSystemScope = this.authorizationPolicyService.hasPermission(
      currentUser,
      'audit:read:system',
    );

    const qb = this.auditEventRepository.createQueryBuilder('auditEvent');

    if (hasSystemScope) {
      if (query.organizationId) {
        qb.andWhere('auditEvent.organizationId = :organizationId', {
          organizationId: query.organizationId,
        });
      }
    } else {
      qb.andWhere('auditEvent.organizationId = :organizationId', {
        organizationId: currentUser.organizationId,
      });
    }

    if (query.type) {
      qb.andWhere('auditEvent.type = :type', { type: query.type });
    }

    if (query.severity) {
      qb.andWhere('auditEvent.severity = :severity', { severity: query.severity });
    }

    if (query.dateFrom) {
      qb.andWhere('auditEvent.createdAt >= :dateFrom', { dateFrom: query.dateFrom });
    }

    if (query.dateTo) {
      qb.andWhere('auditEvent.createdAt <= :dateTo', { dateTo: query.dateTo });
    }

    qb.orderBy('auditEvent.createdAt', 'DESC')
      .skip((page - 1) * limit)
      .take(limit);

    const [data, total] = await qb.getManyAndCount();

    return { data, total, page, limit };
  }

  async getRecentForDashboard(limit: number, organizationId?: string): Promise<AuditEvent[]> {
    const qb = this.auditEventRepository
      .createQueryBuilder('auditEvent')
      .orderBy('auditEvent.createdAt', 'DESC')
      .take(limit);

    if (organizationId) {
      qb.andWhere('auditEvent.organizationId = :organizationId', { organizationId });
    }

    return qb.getMany();
  }
}
  • Step 5: Run the test to verify it passes

Run: make backend-test FILE=src/audit/audit.service.spec.ts
Expected: PASS — all AuditService tests green.

  • Step 6: Commit
git add backend/src/audit/audit.service.ts backend/src/audit/audit.service.spec.ts backend/src/audit/dto/list-audit-events-query.dto.ts
git commit -m "feat(audit): add AuditService with record, findAll, and dashboard query"

Task 5: AuditController and DTO wiring

Files:

  • Create: backend/src/audit/audit.controller.ts

  • Test: backend/src/audit/audit.controller.spec.ts

  • Step 1: Write the failing test

// backend/src/audit/audit.controller.spec.ts
import { Test, type TestingModule } from '@nestjs/testing';
import { AuditController } from '@/audit/audit.controller';
import { AuditService } from '@/audit/audit.service';
import type { CurrentUserData } from '@/auth/decorators/current-user.decorator';
import { RoleName } from '@/common/enums';

describe('AuditController', () => {
  let controller: AuditController;
  let auditService: jest.Mocked<AuditService>;

  const superAdmin: CurrentUserData = {
    userId: 'super-admin-1',
    email: 'super-admin@example.com',
    organizationId: null,
    role: RoleName.SUPER_ADMIN,
    roles: [RoleName.SUPER_ADMIN],
    permissions: ['audit:read:system'],
    assignedResources: [],
    assignedAreaIds: [],
  };

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      controllers: [AuditController],
      providers: [
        {
          provide: AuditService,
          useValue: {
            findAll: jest.fn(),
          },
        },
      ],
    }).compile();

    controller = module.get(AuditController);
    auditService = module.get(AuditService);
  });

  it('delegates listing to AuditService with the query and current user', async () => {
    const expected = { data: [], total: 0, page: 1, limit: 20 };
    auditService.findAll.mockResolvedValue(expected);

    const result = await controller.findAll({ page: 1, limit: 20 }, superAdmin);

    expect(result).toBe(expected);
    expect(auditService.findAll).toHaveBeenCalledWith({ page: 1, limit: 20 }, superAdmin);
  });
});
  • Step 2: Run the test to verify it fails

Run: make backend-test FILE=src/audit/audit.controller.spec.ts
Expected: FAIL — Cannot find module '@/audit/audit.controller'.

  • Step 3: Implement AuditController
// backend/src/audit/audit.controller.ts
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { AuditService } from '@/audit/audit.service';
import { ListAuditEventsQueryDto } from '@/audit/dto/list-audit-events-query.dto';
import { CurrentUser, type CurrentUserData } from '@/auth/decorators/current-user.decorator';
import { JwtAuthGuard } from '@/auth/guards/jwt-auth.guard';
import { RequirePermissions } from '@/common/decorators/permissions.decorator';
import { PermissionsGuard } from '@/common/guards/permissions.guard';

@ApiTags('Audit')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, PermissionsGuard)
@Controller('audit-events')
export class AuditController {
  constructor(private readonly auditService: AuditService) {}

  @Get()
  @ApiOperation({ summary: 'List audit events (paginated, filterable)' })
  @RequirePermissions('audit:read:system', 'audit:read:organization')
  findAll(@Query() query: ListAuditEventsQueryDto, @CurrentUser() currentUser: CurrentUserData) {
    return this.auditService.findAll(query, currentUser);
  }
}
  • Step 4: Run the test to verify it passes

Run: make backend-test FILE=src/audit/audit.controller.spec.ts
Expected: PASS.

  • Step 5: Commit
git add backend/src/audit/audit.controller.ts backend/src/audit/audit.controller.spec.ts
git commit -m "feat(audit): add AuditController for GET /api/v1/audit-events"

Task 6: AuditModule and app-wide registration

Files:

  • Create: backend/src/audit/audit.module.ts

  • Modify: backend/src/app.module.ts

  • Step 1: Write the module

// backend/src/audit/audit.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuditController } from '@/audit/audit.controller';
import { AuditService } from '@/audit/audit.service';
import { AuditEvent } from '@/audit/entities/audit-event.entity';

@Module({
  imports: [TypeOrmModule.forFeature([AuditEvent])],
  controllers: [AuditController],
  providers: [AuditService],
  exports: [AuditService],
})
export class AuditModule {}
  • Step 2: Register AuditEvent and AuditModule in app.module.ts

In backend/src/app.module.ts, add the imports (alphabetically, right before the AuthModule-related imports):

import { AuditModule } from './audit/audit.module';
import { AuditEvent } from './audit/entities/audit-event.entity';

Add AuditEvent to the entities array inside TypeOrmModule.forRootAsync, immediately after PasswordResetToken,:

            User,
            RefreshToken,
            PasswordResetToken,
            AuditEvent,
            Organization,

Add AuditModule as the first entry of the top-level imports array (it has no dependency on any other feature module, and four other modules depend on it):

    AuditModule,
    AuthModule,
    DashboardModule,
  • Step 3: Verify the app still compiles and boots

Run: cd backend && bun run build
Expected: build succeeds.

  • Step 4: Commit
git add backend/src/audit/audit.module.ts backend/src/app.module.ts
git commit -m "feat(audit): register AuditModule and AuditEvent entity in AppModule"

Task 7: Emit audit events from login/logout (AuthService)

Files:

  • Modify: backend/src/auth/auth.module.ts

  • Modify: backend/src/auth/auth.service.ts

  • Modify: backend/src/auth/auth.service.spec.ts

  • Step 1: Add AuditModule to AuthModule imports

In backend/src/auth/auth.module.ts, add the import and wire it in:

import { AuditModule } from '@/audit/audit.module';
  imports: [
    AuditModule,
    TypeOrmModule.forFeature([User, RefreshToken, PasswordResetToken, UserRole, Area, Pond]),
    PassportModule.register({ defaultStrategy: 'jwt' }),
  • Step 2: Update the failing test expectations first

In backend/src/auth/auth.service.spec.ts:

Add these imports near the top, alongside the existing ones:

import { AUDIT_EVENT_TYPES } from '@/audit/audit-event-types';
import { AuditService } from '@/audit/audit.service';

Add a mocked AuditService provider to the providers array (after the ConfigService provider, before the closing ],):

        {
          provide: AuditService,
          useValue: {
            record: jest.fn(),
          },
        },

Add let auditService: jest.Mocked<AuditService>; alongside the other let declarations at the top of the describe block:

  let service: AuthService;
  let userRepository: jest.Mocked<Repository<User>>;
  let refreshTokenRepository: jest.Mocked<Repository<RefreshToken>>;
  let passwordResetTokenRepository: jest.Mocked<Repository<PasswordResetToken>>;
  let userRoleRepository: jest.Mocked<Repository<UserRole>>;
  let jwtService: jest.Mocked<JwtService>;
  let dataSource: jest.Mocked<DataSource>;
  let auditService: jest.Mocked<AuditService>;

Add its retrieval in beforeEach, alongside the other module.get(...) calls:

    dataSource = module.get(DataSource);
    auditService = module.get(AuditService);

Update the 'should login with valid credentials' test to assert the success event, appending after the existing expect(mockUser.lastLogin).toBeDefined();:

      expect(auditService.record).toHaveBeenCalledWith(
        expect.objectContaining({
          type: AUDIT_EVENT_TYPES.AUTH_LOGIN_SUCCESS,
          actorId: 'user-1',
          actorEmail: 'test@example.com',
          organizationId: 'org-1',
        }),
      );

Update the 'should throw UnauthorizedException for invalid email' test, appending after the existing assertions:

      expect(auditService.record).toHaveBeenCalledWith(
        expect.objectContaining({
          type: AUDIT_EVENT_TYPES.AUTH_LOGIN_FAILED,
          actorId: null,
          actorEmail: 'wrong@example.com',
        }),
      );

Update the 'should throw UnauthorizedException for invalid password' test, appending after the existing assertions:

      expect(auditService.record).toHaveBeenCalledWith(
        expect.objectContaining({
          type: AUDIT_EVENT_TYPES.AUTH_LOGIN_FAILED,
          actorId: 'user-1',
          actorEmail: 'test@example.com',
        }),
      );

Update the 'should throw UnauthorizedException for suspended account' test, appending after the existing assertions:

      expect(auditService.record).toHaveBeenCalledWith(
        expect.objectContaining({
          type: AUDIT_EVENT_TYPES.AUTH_LOGIN_FAILED,
          actorId: 'user-1',
          metadata: { reason: 'account_suspended' },
        }),
      );

Update the describe('logout', ...) test, appending after the existing expect(refreshTokenRepository.update)... assertion:

      expect(auditService.record).toHaveBeenCalledWith(
        expect.objectContaining({
          type: AUDIT_EVENT_TYPES.AUTH_LOGOUT,
          actorId: 'user-1',
        }),
      );
  • Step 3: Run the tests to verify they fail

Run: make backend-test FILE=src/auth/auth.service.spec.ts
Expected: FAIL — auditService.record was not called (login/logout don’t emit yet), or a DI resolution error for the missing AuditService provider expectation mismatch.

  • Step 4: Wire AuditService into AuthService and emit events

In backend/src/auth/auth.service.ts, add the imports:

import { AUDIT_EVENT_TYPES } from '@/audit/audit-event-types';
import { AuditService } from '@/audit/audit.service';
import { AuditSeverity } from '@/audit/enums/audit-severity.enum';

Add AuditService to the constructor:

  constructor(
    @InjectRepository(User)
    private userRepository: Repository<User>,
    @InjectRepository(RefreshToken)
    private refreshTokenRepository: Repository<RefreshToken>,
    @InjectRepository(PasswordResetToken)
    private passwordResetTokenRepository: Repository<PasswordResetToken>,
    @InjectRepository(UserRole)
    private userRoleRepository: Repository<UserRole>,
    @InjectRepository(Area)
    private areaRepository: Repository<Area>,
    @InjectRepository(Pond)
    private pondRepository: Repository<Pond>,
    private jwtService: JwtService,
    private configService: ConfigService,
    private dataSource: DataSource,
    private auditService: AuditService,
  ) {}

Replace the login method body with:

  async login(loginDto: LoginDto) {
    const user = await this.userRepository.findOne({
      where: { email: loginDto.email },
      relations: ['userRoles', 'userRoles.role'],
    });

    if (!user) {
      await this.auditService.record({
        type: AUDIT_EVENT_TYPES.AUTH_LOGIN_FAILED,
        message: `Login failed: no account found for ${loginDto.email}`,
        severity: AuditSeverity.WARNING,
        actorEmail: loginDto.email,
        metadata: { reason: 'invalid_credentials' },
      });
      throw new UnauthorizedException('Invalid credentials');
    }

    const isPasswordValid = await bcrypt.compare(loginDto.password, user.password);

    if (!isPasswordValid) {
      await this.auditService.record({
        type: AUDIT_EVENT_TYPES.AUTH_LOGIN_FAILED,
        message: `Login failed: invalid password for ${user.email}`,
        severity: AuditSeverity.WARNING,
        actorId: user.id,
        actorEmail: user.email,
        organizationId: user.organizationId,
        metadata: { reason: 'invalid_credentials' },
      });
      throw new UnauthorizedException('Invalid credentials');
    }

    if (user.status === 'suspended') {
      await this.auditService.record({
        type: AUDIT_EVENT_TYPES.AUTH_LOGIN_FAILED,
        message: `Login failed: account suspended for ${user.email}`,
        severity: AuditSeverity.WARNING,
        actorId: user.id,
        actorEmail: user.email,
        organizationId: user.organizationId,
        metadata: { reason: 'account_suspended' },
      });
      throw new UnauthorizedException('Account suspended');
    }

    if (user.status === 'inactive') {
      await this.auditService.record({
        type: AUDIT_EVENT_TYPES.AUTH_LOGIN_FAILED,
        message: `Login failed: account inactive for ${user.email}`,
        severity: AuditSeverity.WARNING,
        actorId: user.id,
        actorEmail: user.email,
        organizationId: user.organizationId,
        metadata: { reason: 'account_inactive' },
      });
      throw new UnauthorizedException('Account inactive');
    }

    user.lastLogin = new Date();
    await this.userRepository.save(user);

    const { accessToken, refreshToken } = await this.generateTokens(user);

    // Split fullName into firstName/lastName
    const nameParts = user.fullName.split(' ');
    const firstName = nameParts[0] || '';
    const lastName = nameParts.slice(1).join(' ') || '';

    const activeRole = this.resolveActiveRole(
      user.userRoles?.map((userRole) => userRole.role.name),
    );

    await this.auditService.record({
      type: AUDIT_EVENT_TYPES.AUTH_LOGIN_SUCCESS,
      message: `${user.email} logged in successfully`,
      severity: AuditSeverity.INFO,
      actorId: user.id,
      actorEmail: user.email,
      organizationId: user.organizationId,
    });

    return {
      user: {
        id: user.id,
        email: user.email,
        firstName,
        lastName,
        role: activeRole,
        organizationId: user.organizationId,
        isActive: user.status === UserStatus.ACTIVE,
      },
      accessToken,
      refreshToken,
    };
  }

Replace the logout method body with:

  async logout(userId: string, refreshToken: string) {
    await this.refreshTokenRepository.update(
      { token: refreshToken, userId },
      { revokedAt: new Date() },
    );

    await this.auditService.record({
      type: AUDIT_EVENT_TYPES.AUTH_LOGOUT,
      message: `User ${userId} logged out`,
      severity: AuditSeverity.INFO,
      actorId: userId,
    });
  }
  • Step 5: Run the tests to verify they pass

Run: make backend-test FILE=src/auth/auth.service.spec.ts
Expected: PASS.

  • Step 6: Commit
git add backend/src/auth/auth.module.ts backend/src/auth/auth.service.ts backend/src/auth/auth.service.spec.ts
git commit -m "feat(audit): emit audit events on login success/failure and logout"

Task 8: Emit audit events on RBAC role updates (RoleService)

Files:

  • Modify: backend/src/rbac/rbac.module.ts

  • Modify: backend/src/rbac/role.service.ts

  • Modify: backend/src/rbac/role.service.spec.ts

  • Modify: backend/src/roles/role.controller.ts

  • Step 1: Add AuditModule to RbacModule imports

In backend/src/rbac/rbac.module.ts, add the import and wire it in:

import { AuditModule } from '@/audit/audit.module';
@Global()
@Module({
  imports: [AuditModule, TypeOrmModule.forFeature([Role, UserRole, Area, Pond])],
  • Step 2: Update the failing test expectations first

In backend/src/rbac/role.service.spec.ts, add these imports:

import { AUDIT_EVENT_TYPES } from '@/audit/audit-event-types';
import { AuditService } from '@/audit/audit.service';
import type { CurrentUserData } from '@/auth/decorators/current-user.decorator';
import { RoleName } from '@/common/enums';

Add a currentUser fixture and a mocked AuditService, alongside the existing mockRole/systemRole fixtures:

  const currentUser: CurrentUserData = {
    userId: 'admin-1',
    email: 'admin@example.com',
    organizationId: 'org-1',
    role: RoleName.ADMIN,
    roles: [RoleName.ADMIN],
    permissions: [],
    assignedResources: [],
    assignedAreaIds: [],
  };

Add the mocked provider to the providers array in beforeEach, and retrieve it:

        {
          provide: AuditService,
          useValue: {
            record: jest.fn(),
          },
        },
    service = module.get<RoleService>(RoleService);
    repository = module.get(getRepositoryToken(Role));
    auditService = module.get(AuditService);

(declare let auditService: jest.Mocked<AuditService>; alongside the existing let service/let repository declarations.)

Update all three calls to service.update(...) in the describe('update', ...) block to pass currentUser as the third argument:

      const result = await service.update('role-1', updateDto, currentUser);
      await expect(service.update('system-role-1', updateDto, currentUser)).rejects.toThrow(ConflictException);
      await expect(service.update('system-role-1', updateDto, currentUser)).rejects.toThrow(
        'Cannot modify system roles',
      );
      const updatePromise = service.update('role-1', updateDto, currentUser);

Add a new test at the end of the describe('update', ...) block, right before its closing });:

    it('records an audit event when a role is updated', async () => {
      const updateDto = { name: 'Updated Role', description: 'Updated', permissions: [] };
      const existingRole = { ...mockRole, isSystem: false };
      repository.findOne.mockResolvedValue(existingRole);
      repository.save.mockResolvedValue({ ...existingRole, ...updateDto });

      await service.update('role-1', updateDto, currentUser);

      expect(auditService.record).toHaveBeenCalledWith(
        expect.objectContaining({
          type: AUDIT_EVENT_TYPES.RBAC_ROLE_PERMISSIONS_UPDATED,
          actorId: 'admin-1',
          actorEmail: 'admin@example.com',
          organizationId: 'org-1',
        }),
      );
    });
  • Step 3: Run the tests to verify they fail

Run: make backend-test FILE=src/rbac/role.service.spec.ts
Expected: FAIL — TypeScript error (wrong number of arguments to update) or auditService undefined.

  • Step 4: Wire AuditService into RoleService

In backend/src/rbac/role.service.ts, replace the full file contents with:

import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import type { Repository } from 'typeorm';
import { AUDIT_EVENT_TYPES } from '@/audit/audit-event-types';
import { AuditService } from '@/audit/audit.service';
import { AuditSeverity } from '@/audit/enums/audit-severity.enum';
import type { CurrentUserData } from '@/auth/decorators/current-user.decorator';
import type { CreateRoleDto } from '@/rbac/dto/create-role.dto';
import type { UpdateRoleDto } from '@/rbac/dto/update-role.dto';
import { Role } from '@/roles/entities/role.entity';

@Injectable()
export class RoleService {
  constructor(
    @InjectRepository(Role)
    private roleRepository: Repository<Role>,
    private auditService: AuditService,
  ) {}

  async findAll(): Promise<Role[]> {
    return this.roleRepository.find({
      order: { createdAt: 'ASC' },
    });
  }

  async findOne(id: string): Promise<Role> {
    const role = await this.roleRepository.findOne({
      where: { id },
    });

    if (!role) {
      throw new NotFoundException('Role not found');
    }

    return role;
  }

  async create(createRoleDto: CreateRoleDto): Promise<Role> {
    const existingRole = await this.roleRepository.findOne({
      where: { name: createRoleDto.name },
    });

    if (existingRole) {
      throw new ConflictException('Role with this name already exists');
    }

    const role = this.roleRepository.create(createRoleDto);
    return this.roleRepository.save(role);
  }

  async update(
    id: string,
    updateRoleDto: UpdateRoleDto,
    currentUser: CurrentUserData,
  ): Promise<Role> {
    const role = await this.findOne(id);

    if (role.isSystem) {
      throw new ConflictException('Cannot modify system roles');
    }

    if (updateRoleDto.name) {
      const existingRole = await this.roleRepository.findOne({
        where: { name: updateRoleDto.name },
      });

      if (existingRole && existingRole.id !== id) {
        throw new ConflictException('Role with this name already exists');
      }
    }

    Object.assign(role, updateRoleDto);
    const savedRole = await this.roleRepository.save(role);

    await this.auditService.record({
      type: AUDIT_EVENT_TYPES.RBAC_ROLE_PERMISSIONS_UPDATED,
      message: `Role "${savedRole.name}" was updated by ${currentUser.email}`,
      severity: AuditSeverity.HIGH,
      actorId: currentUser.userId,
      actorEmail: currentUser.email,
      organizationId: currentUser.organizationId,
      metadata: {
        roleId: savedRole.id,
        roleName: savedRole.name,
        permissions: savedRole.permissions,
      },
    });

    return savedRole;
  }

  async remove(id: string): Promise<void> {
    const role = await this.findOne(id);

    if (role.isSystem) {
      throw new ConflictException('Cannot delete system roles');
    }

    await this.roleRepository.delete(id);
  }
}
  • Step 5: Thread currentUser from the controller

In backend/src/roles/role.controller.ts, add the import:

import { CurrentUser, type CurrentUserData } from '@/auth/decorators/current-user.decorator';

Replace the update method:

  @Put(':id')
  @RequirePermissions('role:update:all')
  @ApiOperation({ summary: 'Update role' })
  @ApiResponse({ status: 200, description: 'Role updated successfully' })
  async update(
    @Param('id') id: string,
    @Body() updateRoleDto: UpdateRoleDto,
    @CurrentUser() currentUser: CurrentUserData,
  ) {
    return this.roleService.update(id, updateRoleDto, currentUser);
  }
  • Step 6: Run the tests to verify they pass

Run: make backend-test FILE=src/rbac/role.service.spec.ts
Expected: PASS.

Then verify the wider build still compiles (the controller signature changed):

Run: cd backend && bun run build
Expected: build succeeds.

  • Step 7: Commit
git add backend/src/rbac/rbac.module.ts backend/src/rbac/role.service.ts backend/src/rbac/role.service.spec.ts backend/src/roles/role.controller.ts
git commit -m "feat(audit): emit audit event when a role's permissions are updated"

Task 9: Emit audit events on organization creation (OrganizationsService)

Files:

  • Modify: backend/src/organizations/organizations.module.ts

  • Modify: backend/src/organizations/organizations.service.ts

  • Modify: backend/src/organizations/organizations.service.spec.ts

  • Modify: backend/src/organizations/organizations.controller.ts

  • Modify: backend/src/organizations/organizations.controller.spec.ts

  • Step 1: Add AuditModule to OrganizationsModule imports

In backend/src/organizations/organizations.module.ts, add the import and wire it in:

import { AuditModule } from '@/audit/audit.module';
@Module({
  imports: [AuditModule, TypeOrmModule.forFeature([Organization])],
  • Step 2: Update the failing test expectations first

In backend/src/organizations/organizations.service.spec.ts, add these imports, right after the existing import { AuthorizationPolicyService } from '@/rbac/authorization-policy.service'; line:

import { AUDIT_EVENT_TYPES } from '@/audit/audit-event-types';
import { AuditService } from '@/audit/audit.service';

Add an auditService declaration alongside the other let declarations at the top of the describe block:

  let service: OrganizationsService;
  let organizationRepository: jest.Mocked<Repository<Organization>>;
  let dataSource: jest.Mocked<DataSource>;
  let auditService: { record: jest.Mock };
  let authorizationPolicyService: {
    resolveEffectiveScope: jest.Mock;
    isTenantResourceAccessible: jest.Mock;
    applyReadScopeToQueryBuilder: jest.Mock;
    hasPermission: jest.Mock;
  };

Add a currentUser fixture right after the existing mockOrganization fixture:

  const currentUser: CurrentUserData = {
    userId: 'super-admin-1',
    email: 'super-admin@example.com',
    organizationId: null,
    roles: ['SUPER_ADMIN'],
    permissions: [],
    assignedResources: [],
    assignedAreaIds: [],
  };

In beforeEach, initialize auditService alongside authorizationPolicyService and register it as a provider:

    authorizationPolicyService = {
      resolveEffectiveScope: jest.fn().mockReturnValue('system'),
      isTenantResourceAccessible: jest.fn().mockReturnValue(true),
      applyReadScopeToQueryBuilder: jest.fn(),
      hasPermission: jest.fn().mockReturnValue(true),
    };

    auditService = {
      record: jest.fn(),
    };

    const module: TestingModule = await Test.createTestingModule({
      providers: [
        OrganizationsService,
        {
          provide: getRepositoryToken(Organization),
          useValue: organizationRepository,
        },
        {
          provide: DataSource,
          useValue: dataSource,
        },
        {
          provide: AuthorizationPolicyService,
          useValue: authorizationPolicyService,
        },
        {
          provide: AuditService,
          useValue: auditService,
        },
      ],
    }).compile();

    service = module.get<OrganizationsService>(OrganizationsService);

Update the 'should create organization' test: change const result = await service.create(createOrgDto); to const result = await service.create(createOrgDto, currentUser);, and append this assertion after the existing expect(result)... block:

      expect(auditService.record).toHaveBeenCalledWith(
        expect.objectContaining({
          type: AUDIT_EVENT_TYPES.ORGANIZATION_CREATED,
          actorId: 'super-admin-1',
        }),
      );

Update the 'should append suffix when generated code already exists' test’s call site the same way: await service.create(createOrgDto, currentUser) instead of await service.create(createOrgDto).

  • Step 3: Run the tests to verify they fail

Run: make backend-test FILE=src/organizations/organizations.service.spec.ts
Expected: FAIL — TypeScript error or missing audit call assertion failure.

  • Step 4: Wire AuditService into OrganizationsService

In backend/src/organizations/organizations.service.ts, add the imports:

import { AUDIT_EVENT_TYPES } from '@/audit/audit-event-types';
import { AuditService } from '@/audit/audit.service';
import { AuditSeverity } from '@/audit/enums/audit-severity.enum';

Add AuditService to the constructor:

  constructor(
    @InjectRepository(Organization)
    private organizationRepository: Repository<Organization>,
    private dataSource: DataSource,
    private authorizationPolicyService: AuthorizationPolicyService,
    private auditService: AuditService,
  ) {}

Replace the create method:

  async create(createOrgDto: CreateOrgDto, currentUser: CurrentUserData): Promise<Organization> {
    const organizationCode = await this.generateUniqueCode(createOrgDto.name);

    const organization = await this.dataSource.transaction(async (manager) => {
      const newOrganization = manager.create(Organization, {
        name: createOrgDto.name,
        code: organizationCode,
        settings: createOrgDto.settings || {},
        isActive: true,
      });

      return manager.save(newOrganization);
    });

    await this.auditService.record({
      type: AUDIT_EVENT_TYPES.ORGANIZATION_CREATED,
      message: `Organization "${organization.name}" was created by ${currentUser.email}`,
      severity: AuditSeverity.INFO,
      actorId: currentUser.userId,
      actorEmail: currentUser.email,
      organizationId: organization.id,
      metadata: { organizationCode: organization.code },
    });

    return organization;
  }
  • Step 5: Thread currentUser from the controller

In backend/src/organizations/organizations.controller.ts, replace the create method:

  @Post()
  @ApiOperation({ summary: 'Create new organization (Super Admin only)' })
  @ApiResponse({ status: 201, description: 'Organization created' })
  @ApiResponse({ status: 401, description: 'Unauthorized' })
  @ApiResponse({ status: 403, description: 'Forbidden - Requires Super Admin role' })
  @ApiResponse({ status: 400, description: 'Validation error or duplicate code' })
  @HttpCode(HttpStatus.CREATED)
  @RequirePermissions('organization:create:all')
  async create(
    @CurrentUser() currentUser: CurrentUserData,
    @Body() createOrgDto: CreateOrgDto,
  ) {
    return this.organizationsService.create(createOrgDto, currentUser);
  }
  • Step 6: Update organizations.controller.spec.ts

In backend/src/organizations/organizations.controller.spec.ts, update the describe('create', ...) test:

  describe('create', () => {
    it('should create an organization', async () => {
      const dto = {
        name: 'New Organization',
        code: 'NEW001',
        settings: { timezone: 'Asia/Ho_Chi_Minh' },
      };

      const result = await controller.create(mockSuperAdmin, dto);

      expect(result).toEqual(mockOrganization);
      expect(organizationsService.create).toHaveBeenCalledWith(dto, mockSuperAdmin);
    });
  });
  • Step 7: Run the tests to verify they pass

Run: make backend-test FILE=src/organizations/organizations.service.spec.ts then make backend-test FILE=src/organizations/organizations.controller.spec.ts
Expected: both PASS.

  • Step 8: Commit
git add backend/src/organizations/organizations.module.ts backend/src/organizations/organizations.service.ts backend/src/organizations/organizations.service.spec.ts backend/src/organizations/organizations.controller.ts backend/src/organizations/organizations.controller.spec.ts
git commit -m "feat(audit): emit audit event when an organization is created"

Task 10: Dashboard integration — real audit feed, remove placeholder warning

Files:

  • Modify: backend/src/dashboard/dashboard.module.ts

  • Modify: backend/src/dashboard/dashboard-query.service.ts

  • Modify: backend/src/dashboard/services/super-admin-dashboard.service.ts

  • Modify: backend/src/dashboard/dashboard-query.technical.spec.ts

  • Modify: backend/src/dashboard/services/super-admin-dashboard.service.spec.ts

  • Step 1: Add AuditModule to DashboardModule imports

In backend/src/dashboard/dashboard.module.ts, add the import and wire it in:

import { AuditModule } from '@/audit/audit.module';
@Module({
  imports: [
    AuditModule,
    TypeOrmModule.forFeature([
  • Step 2: Fix DI in the existing DashboardQueryService unit test

DashboardQueryService is about to gain a new constructor dependency. backend/src/dashboard/dashboard-query.technical.spec.ts builds a real DashboardQueryService via Test.createTestingModule, so it needs a mock for the new dependency or the module compile will fail. Add the import:

import { AuditService } from '@/audit/audit.service';

Add the provider to the providers array, after the Ticket repository provider:

        { provide: AuditService, useValue: { getRecentForDashboard: jest.fn().mockResolvedValue([]) } },
  • Step 3: Wire AuditService into DashboardQueryService and populate auditSecurityFeed

In backend/src/dashboard/dashboard-query.service.ts, add the import:

import { AuditService } from '@/audit/audit.service';

Add AuditService to the constructor (after ticketRepository):

    @InjectRepository(Ticket)
    private ticketRepository: Repository<Ticket>,
    private auditService: AuditService,
  ) {}

In buildSuperAdminSections, add a call to fetch recent audit events. Add this.auditService.getRecentForDashboard(SUPER_ADMIN_AUDIT_FEED_LIMIT) to the initial Promise.all array (append it as a 13th element) and destructure it as auditEvents:

    const [
      totalOrganizations,
      activeOrganizations,
      totalFarms,
      totalPonds,
      totalUsers,
      deviceStatusRows,
      deviceOwnershipRows,
      activeDeviceCountRow,
      inactiveDeviceCountRow,
      deviceIssues,
      ticketCounts,
      technicalWorkloadRows,
      auditEvents,
    ] = await Promise.all([

(the last element added to the array being awaited, right after this.getTechnicalWorkloadRows(),, is:)

      this.auditService.getRecentForDashboard(SUPER_ADMIN_AUDIT_FEED_LIMIT),

Add the constant near the top of the file, alongside the existing interface declarations:

const SUPER_ADMIN_AUDIT_FEED_LIMIT = 20;

Replace the final auditSecurityFeed: [] inside the returned object of buildSuperAdminSections (not the one in buildEmptySuperAdminSections, which stays []) with:

      auditSecurityFeed: auditEvents.map((event) => ({
        id: event.id,
        type: event.type,
        message: event.message,
        createdAt: event.createdAt.toISOString(),
      })),
  • Step 4: Remove the hardcoded warning

In backend/src/dashboard/services/super-admin-dashboard.service.ts, replace:

    const warnings = [
      'Audit/security feed đang dùng trạng thái tạm thời cho đến khi audit module hoàn tất.',
      ...(systemHealthSnapshot.warning ? [systemHealthSnapshot.warning] : []),
    ];

with:

    const warnings = systemHealthSnapshot.warning ? [systemHealthSnapshot.warning] : [];
  • Step 5: Update the now-outdated assertion in super-admin-dashboard.service.spec.ts

In the test 'returns super admin aggregate with system scope and partial warnings', replace:

    expect(result.warnings).toEqual(
      expect.arrayContaining([expect.stringContaining('audit module')]),
    );
    expect(result.warnings).not.toEqual(
      expect.arrayContaining([expect.stringContaining('placeholder')]),
    );
    expect(result.warnings).not.toEqual(
      expect.arrayContaining([expect.stringContaining('System health không khả dụng tạm thời')]),
    );

with:

    expect(result.warnings).toEqual([]);
  • Step 6: Run the affected tests

Run: make backend-test FILE=src/dashboard/dashboard-query.technical.spec.ts
Expected: PASS.

Run: make backend-test FILE=src/dashboard/services/super-admin-dashboard.service.spec.ts
Expected: PASS.

  • Step 7: Commit
git add backend/src/dashboard/dashboard.module.ts backend/src/dashboard/dashboard-query.service.ts backend/src/dashboard/services/super-admin-dashboard.service.ts backend/src/dashboard/dashboard-query.technical.spec.ts backend/src/dashboard/services/super-admin-dashboard.service.spec.ts
git commit -m "feat(audit): wire real audit feed into Super Admin Dashboard, remove placeholder warning"

Task 11: Full verification

Files: none (verification only)

  • Step 1: Run the full backend test suite

Run: cd backend && bun run test
Expected: all suites pass, no failures introduced in unrelated modules.

  • Step 2: Run Biome across the backend workspace

Run: bun run check (from repo root)
Expected: no lint/format errors. If Biome reports fixable issues in touched files, run cd backend && bun run lint:fix and re-check.

  • Step 3: Run the backend build

Run: cd backend && bun run build
Expected: build succeeds with no TypeScript errors.

  • Step 4: Manually confirm the frontend banner is gone (no frontend code change expected)

Start the backend (bun run backend:dev from repo root) and the frontend (bun run frontend:dev), log in as a Super Admin demo user, open /dashboard, and confirm the “Audit/security feed đang dùng trạng thái tạm thời…” banner no longer appears and the Audit/Security section renders (empty state is fine if no audit events exist yet — trigger one by logging out and back in).

  • Step 5: Report results

Summarize what was actually run and its outcome (tests passed/failed, lint clean or not, build succeeded or not, manual UI check done or skipped) — do not claim success without having run these commands.


Self-Review Notes

  • Spec coverage: entity/API (Tasks 2–6), auth events (Task 7), RBAC event (Task 8), organization event (Task 9), dashboard integration + warning removal (Task 10), tests throughout, migration (Task 3). The spec’s “no new RBAC migration needed” claim is honored — no task adds a permissions migration.
  • Scope note carried from the spec: device provisioning, ticket lifecycle, and other event types listed in the GitHub issue are explicitly out of scope for this plan (per the approved design) and are left as one-line auditService.record(...) additions for future issues once a module needs them.
  • Out of scope, intentionally: a dedicated integration test exercising DashboardQueryService.buildSuperAdminSections’s full query fan-out was not added — that method already has no direct unit test coverage in this codebase (it’s only exercised indirectly through the mocked-interface super-admin-dashboard.service.spec.ts), and fully mocking its ~15 chained query builders would be disproportionate to the one-line audit-feed addition. AuditService.getRecentForDashboard itself is fully unit tested in Task 4.