refactor(audit): audit log writes through AuditLogRepository

This commit is contained in:
jubnl
2026-09-22 12:34:49 +02:00
parent b8e7dbccc6
commit c2da9d62f7
29 changed files with 308 additions and 80 deletions
@@ -1,4 +1,65 @@
import type { AuditLog } from '../entities/AuditLog.entity';
import { type AssertRowKeys } from './_shared/rows';
import { EntityRepository } from '@mikro-orm/sql';
export class AuditLogRepository extends EntityRepository<AuditLog> {}
/**
* An `audit_log` row as the API emits it (Plan 3i's `admin.service.ts`
* projection reads it via a `LEFT JOIN users`, not this shape — this is the
* bare table row).
*/
export interface AuditLogRow {
id: number;
created_at: string | null;
user_id: number | null;
action: string;
resource: string | null;
details: string | null;
ip: string | null;
}
const _auditLogRowKeys: AssertRowKeys<AuditLogRow, AuditLog> = true;
/** The column set `insert` writes; `id`/`created_at` are generated. */
export interface NewAuditLogRow {
user_id: number | null;
action: string;
resource: string | null;
details: string | null;
ip: string | null;
}
// Reserved for Plan 3i (`admin.service.ts`'s paginated read of the same
// table): `listPage(limit, offset): Promise<AuditLogPageRow[]>` (a `qb()`
// join to `Users` for `username`/`email`) and `count(): Promise<number>`.
// Not implemented here — see plan3a-sql-inventory.md §1.
export class AuditLogRepository extends EntityRepository<AuditLog> {
/**
* The column set of the legacy INSERT:
* `INSERT INTO audit_log (user_id, action, resource, details, ip)
* VALUES (?, ?, ?, ?, ?)`.
*
* Best-effort, fire-and-forget, exactly like the caller
* (`AuditService.writeAudit`, wrapped in an outer try/catch that never
* throws): no `refresh`, nothing read back — the generated `id` and
* `created_at` (left to the column's `DEFAULT CURRENT_TIMESTAMP`) are
* never used afterward.
*
* Named `insertEntry`, not `insert`: `EntityRepository` (`@mikro-orm/core`)
* already declares a native `insert(data, options?): Promise<Primary<Entity>>`
* (bypassing `create`/`persist`/`flush`), which is a `tsc`-verified
* incompatible override for a `Promise<void>` method of the same name — the
* same reason `Days.repository.ts`/`DayNotes.repository.ts` name their
* inserts `createDay`/`createNote` rather than `create`.
*/
async insertEntry(entry: NewAuditLogRow): Promise<void> {
const row = this.create({
user: entry.user_id,
action: entry.action,
resource: entry.resource,
details: entry.details,
ip: entry.ip,
});
await this.getEntityManager().persist(row).flush();
}
}
+4
View File
@@ -1,4 +1,7 @@
import { Module } from '@nestjs/common';
import { MikroOrmModule } from '@mikro-orm/nestjs';
import { AuditLog } from '../../db/entities/AuditLog.entity';
import { Users } from '../../db/entities/Users.entity';
import { AuditService } from './audit.service';
/** Cross-cutting audit domain (Wave 2). No controller/MCP surface of its own —
@@ -9,6 +12,7 @@ import { AuditService } from './audit.service';
* transitively through each consumer's explicit import. Registered in
* AppModule. */
@Module({
imports: [MikroOrmModule.forFeature([AuditLog, Users])],
providers: [AuditService],
exports: [AuditService],
})
+18 -8
View File
@@ -1,5 +1,9 @@
import { Injectable } from '@nestjs/common';
import { DatabaseService } from '../database/database.service';
import { InjectRepository } from '@mikro-orm/nestjs';
import { AuditLog } from '../../db/entities/AuditLog.entity';
import type { AuditLogRepository } from '../../db/repositories/AuditLog.repository';
import { Users } from '../../db/entities/Users.entity';
import type { UsersRepository } from '../../db/repositories/Users.repository';
import { logInfo, logDebug, logError } from './audit-log.logger';
const ACTION_LABELS: Record<string, string> = {
@@ -66,13 +70,16 @@ function buildInfoSummary(action: string, details?: Record<string, unknown>): st
@Injectable()
export class AuditService {
constructor(private readonly dbs: DatabaseService) {}
constructor(
@InjectRepository(AuditLog) private readonly auditLog: AuditLogRepository,
@InjectRepository(Users) private readonly users: UsersRepository,
) {}
private async resolveUserEmail(userId: number | null): Promise<string> {
if (userId == null) return 'anonymous';
try {
const row = this.dbs.get<{ email: string }>('SELECT email FROM users WHERE id = ?', userId);
return row?.email || `uid:${userId}`;
const email = await this.users.getEmail(userId);
return email || `uid:${userId}`;
} catch { return `uid:${userId}`; }
}
@@ -87,10 +94,13 @@ export class AuditService {
}): Promise<void> {
try {
const detailsJson = entry.details && Object.keys(entry.details).length > 0 ? JSON.stringify(entry.details) : null;
this.dbs.run(
`INSERT INTO audit_log (user_id, action, resource, details, ip) VALUES (?, ?, ?, ?, ?)`,
entry.userId, entry.action, entry.resource ?? null, detailsJson, entry.ip ?? null
);
await this.auditLog.insertEntry({
user_id: entry.userId,
action: entry.action,
resource: entry.resource ?? null,
details: detailsJson,
ip: entry.ip ?? null,
});
const email = await this.resolveUserEmail(entry.userId);
const label = ACTION_LABELS[entry.action] || entry.action;