NestboltNestbolt

@nestbolt/audit-log

Manual Logging

Use AuditLogService.log() to create audit log entries for operations that bypass the TypeORM subscriber.

The automatic TypeORM subscriber covers .save() and .remove() operations, but many real-world scenarios require logging changes that bypass the ORM entirely. Use AuditLogService.log() for these cases.

When to Use Manual Logging

  • Bulk updates -- repository.update(), QueryBuilder.update(), and raw SQL do not trigger TypeORM entity lifecycle events.
  • External API calls -- logging changes made to third-party systems (payment providers, email services, etc.).
  • Business events -- recording logical actions like "user banned", "subscription cancelled", or "report generated" that may not map to a single entity update.
  • Multi-step operations -- logging the outcome of a complex workflow that spans multiple entities or services.
  • Soft deletes -- if your soft delete implementation uses repository.update() to set a deletedAt timestamp, it will not trigger the subscriber.

Basic Usage

Inject AuditLogService and call log():

import { Injectable } from "@nestjs/common";
import { AuditLogService } from "@nestbolt/audit-log";

@Injectable()
export class UserService {
  constructor(private readonly auditLogService: AuditLogService) {}

  async banUser(userId: string, adminId: string, reason: string): Promise<void> {
    // Perform the ban using a bulk update (does not trigger subscriber)
    await this.userRepo.update(userId, { status: "banned", bannedAt: new Date() });

    // Manually log the change
    await this.auditLogService.log({
      action: "updated",
      entityType: "User",
      entityId: userId,
      oldValues: { status: "active" },
      newValues: { status: "banned" },
      actor: { type: "Admin", id: adminId },
      metadata: { reason },
    });
  }
}

Parameter Reference

The log() method accepts a ManualAuditLogParams object:

interface ManualAuditLogParams {
  action: AuditAction;
  entityType: string;
  entityId: string;
  oldValues?: Record<string, any> | null;
  newValues?: Record<string, any> | null;
  actor?: AuditActor;
  metadata?: Record<string, any>;
  ipAddress?: string;
  userAgent?: string;
}

Required Parameters

ParameterTypeDescription
action"created" | "updated" | "deleted"The type of action being logged
entityTypestringThe logical entity type name (e.g., "User", "Order")
entityIdstringThe unique identifier of the entity

Optional Parameters

ParameterTypeDefaultDescription
oldValuesRecord<string, any> | nullnullPrevious field values (typically set for "updated" and "deleted" actions)
newValuesRecord<string, any> | nullnullNew field values (typically set for "created" and "updated" actions)
actor{ type: string; id: string }Resolved via ActorResolver or defaultActorThe user or system that performed the action
metadataRecord<string, any>Module-level metadataExtra context stored with the audit log entry
ipAddressstringnullThe IP address of the request
userAgentstringnullThe user agent string of the request

Actor Resolution in Manual Logging

When actor is not provided to log(), the service resolves the actor automatically:

  1. If an actorResolver is configured, it calls resolve() on the resolver.
  2. If no resolver is configured (or it returns null), the defaultActor from module options is used.
  3. If neither is available, actorType and actorId are set to null.

When actor is explicitly provided, it takes precedence over all automatic resolution.

Metadata Precedence

When metadata is provided to log(), it replaces the module-level metadata entirely (no merging). If metadata is not provided, the module-level metadata from module options is used. If neither is set, metadata is null.

Return Value

The log() method returns the saved AuditLogEntity:

const auditLog = await this.auditLogService.log({
  action: "updated",
  entityType: "User",
  entityId: userId,
  oldValues: { status: "active" },
  newValues: { status: "banned" },
});

console.log(auditLog.id);        // UUID of the audit log entry
console.log(auditLog.createdAt);  // Timestamp of when the entry was created

Examples

Logging a Bulk Update

@Injectable()
export class ProductService {
  constructor(
    @InjectRepository(Product)
    private readonly productRepo: Repository<Product>,
    private readonly auditLogService: AuditLogService,
  ) {}

  async deactivateAllByCategory(category: string, adminId: string): Promise<void> {
    const products = await this.productRepo.find({
      where: { category, active: true },
    });

    // Bulk update -- does not trigger the subscriber
    await this.productRepo.update(
      { category, active: true },
      { active: false },
    );

    // Log each affected entity
    for (const product of products) {
      await this.auditLogService.log({
        action: "updated",
        entityType: "Product",
        entityId: product.id,
        oldValues: { active: true },
        newValues: { active: false },
        actor: { type: "Admin", id: adminId },
        metadata: { bulkOperation: "deactivateByCategory", category },
      });
    }
  }
}

Logging an External API Operation

@Injectable()
export class PaymentService {
  constructor(private readonly auditLogService: AuditLogService) {}

  async refundPayment(
    orderId: string,
    paymentId: string,
    amount: number,
    adminId: string,
  ): Promise<void> {
    // Call external payment provider
    const result = await this.stripeClient.refunds.create({
      payment_intent: paymentId,
      amount: Math.round(amount * 100),
    });

    // Log the refund as an audit event
    await this.auditLogService.log({
      action: "updated",
      entityType: "Order",
      entityId: orderId,
      oldValues: { paymentStatus: "paid" },
      newValues: { paymentStatus: "refunded" },
      actor: { type: "Admin", id: adminId },
      metadata: {
        refundId: result.id,
        refundAmount: amount,
        provider: "stripe",
      },
    });
  }
}

Logging a Business Event

@Injectable()
export class SubscriptionService {
  constructor(private readonly auditLogService: AuditLogService) {}

  async cancelSubscription(
    subscriptionId: string,
    userId: string,
    reason: string,
  ): Promise<void> {
    // ... cancellation logic ...

    await this.auditLogService.log({
      action: "updated",
      entityType: "Subscription",
      entityId: subscriptionId,
      oldValues: { status: "active" },
      newValues: { status: "cancelled" },
      actor: { type: "User", id: userId },
      metadata: {
        cancellationReason: reason,
        effectiveDate: new Date().toISOString(),
      },
    });
  }
}

Logging with Request Context

@Controller("users")
export class UserController {
  constructor(private readonly auditLogService: AuditLogService) {}

  @Post(":id/role")
  async changeRole(
    @Param("id") userId: string,
    @Body() body: ChangeRoleDto,
    @Req() req: Request,
  ): Promise<void> {
    const oldRole = await this.getRole(userId);

    await this.userRepo.update(userId, { role: body.role });

    await this.auditLogService.log({
      action: "updated",
      entityType: "User",
      entityId: userId,
      oldValues: { role: oldRole },
      newValues: { role: body.role },
      actor: { type: "Admin", id: req.user.id },
      ipAddress: req.ip,
      userAgent: req.headers["user-agent"],
      metadata: { endpoint: "POST /users/:id/role" },
    });
  }
}

Logging a Soft Delete

@Injectable()
export class ArticleService {
  constructor(
    @InjectRepository(Article)
    private readonly articleRepo: Repository<Article>,
    private readonly auditLogService: AuditLogService,
  ) {}

  async softDelete(articleId: string, userId: string): Promise<void> {
    const article = await this.articleRepo.findOneByOrFail({ id: articleId });

    // Soft delete via update -- does not trigger the subscriber
    await this.articleRepo.update(articleId, { deletedAt: new Date() });

    await this.auditLogService.log({
      action: "deleted",
      entityType: "Article",
      entityId: articleId,
      oldValues: { title: article.title, status: article.status },
      newValues: null,
      actor: { type: "User", id: userId },
    });
  }
}

Manual Logging vs. Automatic Logging

AspectAutomatic (Subscriber)Manual (log())
Trigger.save() and .remove()Explicit call to log()
Diff computationAutomatic (old vs. new values)You provide oldValues and newValues
Field filteringApplied (via only, except, globalExcludedFields)Not applied -- you control what is logged
disabledActionsRespectedNot enforced
Actor resolutionAutomatic via resolverAutomatic unless actor is explicitly provided
MetadataUses module-level metadataUses provided metadata, falls back to module-level
ipAddress / userAgentNot capturedMust be provided explicitly

Next Steps

  • Querying -- retrieve and filter audit log entries.
  • Actor Resolution -- learn how actor resolution works for both automatic and manual logging.