mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 07:53:09 +00:00
refactor(audit): audit log writes through AuditLogRepository
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user