Adding a simple Audit Trail to an AdonisJS app

Tue Dec 19 2023

An audit trail is a record of all activities and transactions that occur within a system. It provides a chronological and detailed history of events, including who performed them, when they were performed, and what changes were made. Audit trails are commonly used in industries such as finance and government to ensure compliance and detect fraudulent activity. However, they’re also common in apps and services that support Organisations or Teams as it keeps a track of what every team member is up to.

In this article, I’ll explain how I added an audit trail to keep a track of User’s actions in my recent project; an e-commerce boilerplate. As it’s a small application, with potentially a handful of users interacting with it at one time, I implemented a simple logging system that records the following;

  • User ID
  • Target Type
  • Target ID
  • Action

By default, MySQL will add a createdAt and updatedAt to every record too.

Audit Migration and Model

Firstly, we’ll start with database changes. Here we’ll want to create a migration and model both called audit using node ace make:migration audit and node ace make:model Audit.

Inside the audit migration, we want string columns of the following; user_id, action, target and target_id.

import BaseSchema from "@ioc:Adonis/Lucid/Schema";

export default class extends BaseSchema {
    protected tableName = 'audits'

  public async up() {
    this.schema.createTable(this.tableName, (table) => {
      table.increments("id").primary();

      table.string("user_id")
      table.string("action");
      table.string("target");
      table.string("target_id");

      /**
       * Uses timestamptz for PostgreSQL and DATETIME2 for MSSQL
       */
      table.timestamp("created_at", { useTz: true });
      table.timestamp("updated_at", { useTz: true });
    });
  }

  public async down() {
    this.schema.dropTable(this.tableName);
  }
}

We’ll also add the same 4 string columns to our Audit model too. In addition, we’ll want to add a relationship to the Audit model so that we can fetch the related User’s information.

import { DateTime } from "luxon";
import { BaseModel, column } from "@ioc:Adonis/Lucid/Orm";

export default class Audit extends BaseModel {
  /** Columns */
  @column({ isPrimary: true })
  public id: number;

  @column()
  public user_id: string;

  @column()
  public action: string;

  @column()
  public target: string;

  @column()
  public target_id: string;

  @column.dateTime({ autoCreate: true })
  public createdAt: DateTime;

  @column.dateTime({ autoCreate: true, autoUpdate: true })
  public updatedAt: DateTime;

    /** Relationships */
  @belongsTo(() => User, {
    foreignKey: "user_id",
    localKey: "id",
  })
  public user: BelongsTo<typeof User>;
}

We’ll then update our User model with this new relation. This way, when we query for a User, we can also return their actions from the Audits table.

...
/** relationships */
@hasMany(() => Audit, {
   foreignKey: "user_id",
   localKey: "id",
 })
 public actions: HasMany<typeof Audit>;
...

Listening for audit events

To record actions to our database, we’ll use Adonis’ event emitter module. For this, we’ll create a new events file using node ace make:prldfile events. In this new file, located in the /start folder, we’ll add the following Event listener that will trigger when “audit:new” is triggered within our app.

import Event from "@ioc:Adonis/Core/Event";
import Audit from "App/Models/Audit";

Event.on("audit:new", async ({ user_id, action, target, target_id }) => {
  await Audit.create({
    user_id: user_id,
    action,
    target,
    target_id: String(target_id),
  });
});

To make this typesafe, go to /contracts/events and add the following interfaces

interface NewAudit {
    user_id: string;
    action: "CREATE" | "DELETE" | "UPDATE" | "SIGNED IN";
    target?: "PRODUCT" | "ORDER" | "USER" | "COLLECTION ";
    target_id?: string | number;
  }

interface EventsList {
    "audit:new": NewAudit;
}

By limiting action and target type to only accept a handful of strings, we can ensure that audits follow one pattern throughout our app. i.e

“user string CREATE PRODUCT 1”

or

“user string DELETE COLLECTION 3”

Recording actions

Finally, we want to emit audit:new events when user’s do important actions such as creating and deleting products. To emit a new event, we import Event from "@ioc:Adonis/Core/Event" and then use Event.emit() where necessary. In the example below, when a new product is created, an event is emitted and a new audit log is stored in the DB with the relevant information on the newly created product.

Event.emit("audit:new", {
    user_id: auth.user.id,
  action: "CREATE",
  target: "PRODUCT",
  target_id: product.id,
});

And now we test; below I’m creating a new product, A simple Rocket Booster, in Merchant.

Description

Once created, the “audit:new” event should trigger and we should see a new record in our Audits table.

Description

Voila! and with that we have a working simple audit log to track actions users are taking around our application. We could expand this further in the future by adding a column for storing data changes i.e “before and after” changes, but that’s for a future article!

Thanks for reading and happy coding 😎

Table of Contents