I'm developing a NestJS API that requires an audit log to record all database changes, along with the user responsible for them.
The issue is that, in TypeORM hooks (before, after, etc.), I don't have access to the current request or the user making the change/request.
What would be the best approach to implement this feature in a generic way that works for all entities?
I tried adding the user ID as a temporary field in the entity class, which somewhat worked for inserts/updates. However, for delete operations, I can't call an update function every time I delete something, and that doesn't seem like a good practice.
Currently my TypeORM EventSubscriber looks like this:
import GenericEntity from "@generic/repository/generic.entity";
import { Injectable } from "@nestjs/common";
import { InjectDataSource } from "@nestjs/typeorm";
import { DataSource, EntitySubscriberInterface, EventSubscriber, InsertEvent, RemoveEvent, UpdateEvent } from "typeorm";
import { LogService } from "./log.service";
import { eLogType } from "./repository/log.entity";
import { AppLogger } from "@utils/logger";
interface Events {
log: {
type: eLogType;
userId: string | "system";
tabela: string;
values: {
id: string;
old: Record<string, any>;
new: Record<string, any>;
};
};
}
@EventSubscriber()
@Injectable()
export class LogEventService implements EntitySubscriberInterface {
private tabelasSemLog: string[] = [];
constructor(
@InjectDataSource() readonly dataSource: DataSource,
private readonly logService: LogService,
) {
dataSource.subscribers.push(this);
}
private async saveLog(data: Events["log"]) {
if (await this.logService.saveLog(data.type, data.userId, data.tabela, data.values))
AppLogger.log(`Log salvo com sucesso para a tabela ${data.tabela}`);
else throw new Error("Erro ao salvar log -> " + JSON.stringify(data));
}
private async generateLogData(tabela: string, type: eLogType, entity: GenericEntity) {
const copy = { ...entity };
const oldValue = copy.previousState;
delete copy.previousState;
await this.saveLog({
tabela,
type,
userId: "", // Need to obtain the user id from the request
values: {
id: entity.id,
new: copy,
old: oldValue,
},
});
}
async afterInsert(event: InsertEvent<GenericEntity>): Promise<any> {
const tabela = event.metadata.tableName;
if (this.tabelasSemLog.includes(tabela)) return;
event.entity.id = event.entity.id ?? event.entityId?.toString();
await this.generateLogData(tabela, eLogType.INCLUSAO, event.entity);
}
async afterUpdate(event: UpdateEvent<GenericEntity>): Promise<any> {
const tabela = event.metadata.tableName;
if (this.tabelasSemLog.includes(tabela)) return;
event.entity.id = event.entity.id;
await this.generateLogData(tabela, eLogType.ALTERACAO, event.entity);
}
async afterRemove(event: RemoveEvent<GenericEntity>): Promise<any> {
const tabela = event.metadata.tableName;
if (this.tabelasSemLog.includes(tabela)) return;
event.entity.id = event.entity.id ?? event.entityId?.toString();
await this.generateLogData(tabela, eLogType.EXCLUSAO, event.entity);
}
}
Solved my problem by using nestjs-cls, with this I could pass my user to my typeorm event listener service.
My app now looks something like this:
app.module.ts
@Module({
imports: [
ConfigModule.forRoot({...}),
TypeOrmModule.forRootAsync({...}),
ClsModule.forRoot({
global: true,
middleware: { mount: true },
}),
AuthModule,
LogModule,
],
controllers: [AppController],
providers: [
AppService,
{
provide: APP_GUARD,
useClass: AuthGuard,
},
],
})
export class AppModule {}
auth.guard.ts
@Injectable()
export class AuthGuard implements CanActivate {
constructor(
private jwtService: JwtService,
private reflector: Reflector,
private readonly cls: ClsService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest() as FastifyRequest;
const token = this.extractTokenFromHeader(request);
try {
const payload = await this.jwtService.verifyAsync(token, { secret: process.env.SECRET_KEY });
request.session.data = payload;
this.cls.set("userId", payload.usuario?.id); // saves the user id in the cls context
} catch {
throw new UnauthorizedException("err");
}
return true;
}
}
log.event.ts
@EventSubscriber()
@Injectable()
export class LogEventService implements EntitySubscriberInterface {
constructor(
@InjectDataSource() readonly dataSource: DataSource,
private readonly cls: ClsService,
) {
dataSource.subscribers.push(this);
}
private getUserId() {
const userId: string | undefined = this.cls.get("userId");
return userId;
}
private async generateLogData(userId: string, tabela: string, type: eLogType, entity: GenericEntity) {
...
}
async afterInsert(event: InsertEvent<GenericEntity>): Promise<any> {
const userId = this.getUserId() ?? "sistema";
const tabela = event.metadata.tableName;
event.entity.id = event.entity.id ?? event.entityId?.toString();
await this.generateLogData(userId, tabela, eLogType.INCLUSAO, event.entity);
}
async afterUpdate(event: UpdateEvent<GenericEntity>): Promise<any> {
const userId = this.getUserId();
const tabela = event.metadata.tableName;
event.entity.id = event.entity.id;
await this.generateLogData(userId, tabela, eLogType.ALTERACAO, event.entity);
}
async afterRemove(event: RemoveEvent<GenericEntity>): Promise<any> {
const userId = this.getUserId();
const tabela = event.metadata.tableName;
event.entity.id = event.entity.id ?? event.entityId?.toString();
await this.generateLogData(userId, tabela, eLogType.EXCLUSAO, event.entity);
}
}