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.tsCreate:
backend/src/audit/audit-event-types.tsStep 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.tsStep 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.tsStep 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.tsTest:
backend/src/audit/audit.service.spec.tsStep 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.tsTest:
backend/src/audit/audit.controller.spec.tsStep 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.tsModify:
backend/src/app.module.tsStep 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
AuditEventandAuditModuleinapp.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.tsModify:
backend/src/auth/auth.service.tsModify:
backend/src/auth/auth.service.spec.tsStep 1: Add
AuditModuletoAuthModuleimports
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
AuditServiceintoAuthServiceand 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.tsModify:
backend/src/rbac/role.service.tsModify:
backend/src/rbac/role.service.spec.tsModify:
backend/src/roles/role.controller.tsStep 1: Add
AuditModuletoRbacModuleimports
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
AuditServiceintoRoleService
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
currentUserfrom 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.tsModify:
backend/src/organizations/organizations.service.tsModify:
backend/src/organizations/organizations.service.spec.tsModify:
backend/src/organizations/organizations.controller.tsModify:
backend/src/organizations/organizations.controller.spec.tsStep 1: Add
AuditModuletoOrganizationsModuleimports
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
AuditServiceintoOrganizationsService
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
currentUserfrom 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.tsModify:
backend/src/dashboard/dashboard-query.service.tsModify:
backend/src/dashboard/services/super-admin-dashboard.service.tsModify:
backend/src/dashboard/dashboard-query.technical.spec.tsModify:
backend/src/dashboard/services/super-admin-dashboard.service.spec.tsStep 1: Add
AuditModuletoDashboardModuleimports
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
DashboardQueryServiceunit 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
AuditServiceintoDashboardQueryServiceand populateauditSecurityFeed
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-interfacesuper-admin-dashboard.service.spec.ts), and fully mocking its ~15 chained query builders would be disproportionate to the one-line audit-feed addition.AuditService.getRecentForDashboarditself is fully unit tested in Task 4.