How To Use AdonisJS Model Hooks To Log All User Actions

In this lesson, we’ll be taking a look at how we can log any and every user action performed against our AdonisJS models.

Published
Jul 09, 23
Duration
9m 59s

Developer, dog lover, and burrito eater. Currently teaching AdonisJS, a fully featured NodeJS framework, and running Adocasts where I post new lessons weekly. Professionally, I work with JavaScript, .Net C#, and SQL Server.

Adocasts

Burlington, KY

AdonisJS Hooks

AdonisJS 5 uses hooks as its event system to notify us when a particular action is run against our models. Each of the hooks has both a before and after variant.

So, if you wanted to know when an action was attempted, you would want to utilize the before variant. If you wanted to know when an action was successfully performed, you’d want to use the after variant.

All of the available hooks can be found in the AdonisJS documentation, under ORM inside the Hooks page.

Before we get too far into this, let me also plug Melchyore's package Adonis Lucid Observer, which expands the observability of what we'll be covering here today.

Hook Event Handlers

There are a couple of ways we can add an event handler to a hook. Let’s quickly take a look at our options.

Decorators

The preferred approach to add an event handler to a hook is by using the decorators made available for each of the hooks. The one you may be most familiar with is the beforeSave hook added to the default auth User model.

@beforeSave()
public static async hashPassword (user: User) {
  if (user.$dirty.password) {
    user.password = await Hash.make(user.password)
  }
}
Copied!
  • app
  • Models
  • User.ts

By using before save this hook will run before the model is both created and updated.

Boot Method

We can also define an event handler for each of these events within our model’s boot method. The boot method, as the name implies, is in charge of ensuring everything is prepped and ready to go for our model to be used.

public static boot () {
  if (this.booted) {
    return
  }

  super.boot()

  // the same hook as before defined within our boot method
  this.before('save', (user) => {
    if (user.$dirty.password) {
      user.password = await Hash.make(user.password)
    }
  })
}
Copied!
  • app
  • Models
  • User.ts

Defining Global Hooks

Every model extends a BaseModel and just like every other model, the BaseModel also has a boot method. We can utilize the BaseModel boot method, just like our resource models to bind event handlers.

However, our BaseModel boot hooks will receive every model type we have, so type safety isn’t super strict here unless you manually check your types.

Adding An Event Handler Via The BaseModel

First, we’ll want to work within a provider’s boot method, I’ll be using the AppProvider here today.

Let’s begin by grabbing our BaseModel.

public async boot() {
  const { BaseModel } = this.app.container.resolveBinding('Adonis/Lucid/Orm')
}
Copied!
  • providers
  • AppProvider.ts

Next, let’s grab the boot method of the BaseModel since we’ll need to define a function in its place.

public async boot() {
  const { BaseModel } = this.app.container.resolveBinding('Adonis/Lucid/Orm')
  const boot = BaseModel.boot
}
Copied!
  • providers
  • AppProvider.ts

We’re now clear to define our own boot method, we’ll need to call the default boot method so all the internal functionality of the BaseModel still executes.

public async boot() {
  const { BaseModel } = this.app.container.resolveBinding('Adonis/Lucid/Orm')
  const boot = BaseModel.boot

  BaseModel.boot = function() {
    if (this.booted) return

    boot.call(this)
  }
}
Copied!
  • providers
  • AppProvider.ts

We’re now clear to add any of the model’s hooks to our BaseModel boot method.

public async boot() {
  const { BaseModel } = this.app.container.resolveBinding('Adonis/Lucid/Orm')
  const boot = BaseModel.boot

  BaseModel.boot = function() {
    if (this.booted) return

    boot.call(this)

    this.before('create', (item) => {
      console.log(`[BEFORE] User is creating ${item.constructor.name}`)
    })

    this.after('create', (item) => {
      console.log(`[AFTER] User has created ${item.constructor.name}`)
    })
  }
}
Copied!
  • providers
  • AppProvider.ts

Now, boot up your server, create any record using a Lucid model and you should see this show up in your console:

[BEFORE] User is creating Taxonomy
[AFTER] User has created Taxonomy 19
Copied!

This is great, but we can take it a step further and include who performed the action as well.

Getting Your Authenticated User Via HttpContext

First, in order to get access to our HttpContext outside the realm of our controllers, we’ll need to enable the useAsyncLocalStorage flag within our app’s HTTP config.

export const http: ServerConfig = {
  useAsyncLocalStorage: true,

  // ...
}
Copied!
  • config
  • app.ts

Once we have that in place, we can get our HttpContext anywhere inside the flow of an HTTP request. Remember, no HTTP request means no HttpContext.

import { HttpContext } from '@adonisjs/core/build/standalone'

// ...

public async boot() {
  const { BaseModel } = this.app.container.resolveBinding('Adonis/Lucid/Orm')
  const boot = BaseModel.boot

  BaseModel.boot = function() {
    if (this.booted) return

    boot.call(this)

    this.before('create', (item) => {
      const ctx = HttpContext.getOrFail()
      console.log(`[BEFORE] User ${ctx.auth.user?.id} is creating ${item.constructor.name}`)
    })

    this.after('create', (item) => {
      const ctx = HttpContext.getOrFail()
      console.log(`[AFTER] User ${ctx.auth.user?.id} has created ${item.constructor.name}`)
    })
  }
}
Copied!
  • providers
  • AppProvider.ts

Now our console logs will look like the below, with the expectation that we do have an authenticated user.

[BEFORE] User 1 is creating Taxonomy
[AFTER] User 1 has created Taxonomy 19
Copied!

Note that you can also use the request’s logger via the HttpContext as well.

public async boot() {
  const { BaseModel } = this.app.container.resolveBinding('Adonis/Lucid/Orm')
  const boot = BaseModel.boot

  BaseModel.boot = function() {
    if (this.booted) return

    boot.call(this)

    this.before('create', (item) => {
      const ctx = HttpContext.getOrFail()
      ctx.logger.debug(`[BEFORE] User ${ctx.auth.user?.id} is creating ${item.constructor.name}`)
    })

    this.after('create', (item) => {
      const ctx = HttpContext.getOrFail()
      ctx.logger.debug(`[AFTER] User ${ctx.auth.user?.id} has created ${item.constructor.name}`)
    })
  }
}
Copied!
  • providers
  • AppProvider.ts

Expanded Example

import { HttpContext } from '@adonisjs/core/build/standalone'
import { ApplicationContract } from '@ioc:Adonis/Core/Application'

export default class AppProvider {
  constructor (protected app: ApplicationContract) {}

  public register () {
    // Register your own bindings
  }

  public async boot () {
    // IoC container is ready
    const { BaseModel } = this.app.container.resolveBinding('Adonis/Lucid/Orm')
    const boot = BaseModel.boot

    BaseModel.boot = function() {
      if (this.booted) return
      
      boot.call(this)

      this.before('create', (item) => {
        const ctx = HttpContext.getOrFail()
        ctx.logger.debug(`[BEFORE] User ${ctx.auth.user?.id} is creating ${item.constructor.name}`)
        console.log(`[BEFORE] User ${ctx.auth.user?.id} is creating ${item.constructor.name}`)
      })

      this.after('create', (item) => {
        const ctx = HttpContext.getOrFail()
        console.log(`[AFTER] User ${ctx.auth.user?.id} has created ${item.constructor.name} ${item.id}`)
      })

      this.before('update', (item) => {
        const ctx = HttpContext.getOrFail()
        console.log(`[BEFORE] User ${ctx.auth.user?.id} is updating ${item.constructor.name} ${item.id}`)
      })

      this.after('update', (item) => {
        const ctx = HttpContext.getOrFail()
        console.log(`[AFTER] User ${ctx.auth.user?.id} has updated ${item.constructor.name} ${item.id}`)
      })

      this.before('delete', (item) => {
        const ctx = HttpContext.getOrFail()
        console.log(`[BEFORE] User ${ctx.auth.user?.id} is deleting ${item.constructor.name} ${item.id}`)
      })

      this.after('delete', (item) => {
        const ctx = HttpContext.getOrFail()
        console.log(`[AFTER] User ${ctx.auth.user?.id} has deleted ${item.constructor.name} ${item.id}`)
      })
    }
  }

  public async ready () {
    // App is ready
  }

  public async shutdown () {
    // Cleanup, since app is going down
  }
}
Copied!
  • providers
  • AppProvider.ts

Join The Discussion! (4 Comments)

Please sign in or sign up for free to join in on the dicussion.

  1. Commented 9 months ago

    Thank you very much for the class! It will help me a lot

    0

    Please sign in or sign up for free to reply

    1. Commented 9 months ago

      That's great to hear, Leandro! Thanks again for the lesson request!

      0

      Please sign in or sign up for free to reply

  2. Commented 5 months ago

    Congratulations on the excellent example. In my case I'm using @ioc:Adonis/Addons/LucidSoftDeletes to preserve deleted records, but when I try to run some query in the models that use softdelete (export default class KanbanPhase extends compose(BaseModel, SoftDeletes)) I get an error in my queries, where the table name is changed to select * from model_with_soft_deletes. Any ideas?

    1

    Please sign in or sign up for free to reply

    1. Commented 5 months ago

      Thank you, Walison! Unfortunately, I haven't used the LucidSoftDeletes package, so I'm not familiar with it's inner workings at all.

      My best guess would be to go back through the package's documentation and ensure everything is configured correctly.

      0

      Please sign in or sign up for free to reply