# Audit (/docs/admin/audit)

---
title: Audit
description: The audit module writes one row for every change to a collection or a global, carrying the actor, the field-level diff and a readable title. It records nothing until you add it.
kind: guide
package: "@questpie/admin"
---

Someone changed a price last week and nobody remembers who. That is the
question this page answers. Add one module and every write starts landing in a
collection you can read like any other.

## Turn it on

```ts title="src/questpie/server/modules.ts"
import { adminModule } from "@questpie/admin/modules/admin";
import { auditModule } from "@questpie/admin/modules/audit";

export default [adminModule, auditModule] as const;
```

```bash
questpie generate   # registers admin_audit_log and a cleanup job
questpie push       # creates the table in your dev database
```

That is the whole setup. Nothing has to opt in. The module registers app-level
hooks, so they fire on every collection and every global at once.

`adminModule` is a separate module. Add it too and the log gets a sidebar item
under Administration, showing a read-only list of title, user and date.

## What lands in the log

| `action`     | Written when                                          |
| ------------ | ----------------------------------------------------- |
| `create`     | A row is created.                                     |
| `update`     | A row changes. No changed field means no entry.       |
| `delete`     | A row is deleted, soft or hard.                       |
| `purge`      | A soft-deleted row is purged for good.                |
| `transition` | A record or a global moves to another workflow stage. |

Globals record `update` and `transition` only. A global has no create and no
delete.

Bulk writes are not one entry. `updateMany` and `deleteMany` run the write
hooks once per row, so a hundred rows write a hundred entries.

### Purge is deliberately thin

A purge entry keeps the action, the id and the actor. Its `resourceLabel` and
`changes` are both null. The point of a purge is that the row is gone. The
audit trail must not end up holding the last copy of it.

### A failed entry does not fail your write

Each hook catches its own error and calls `logger.error`. Your mutation still
commits. Purge is the exception. It logs and rethrows, so a purge that cannot
be recorded fails.

## The row

`admin_audit_log` is an ordinary collection with `timestamps: true`, so
`createdAt` is when it happened.

| Field                | Holds                                                                        |
| -------------------- | ---------------------------------------------------------------------------- |
| `action`             | One of the five above, or whatever a custom entry passed.                    |
| `resourceType`       | `collection` or `global`. A custom entry may say anything.                   |
| `resource`           | The key the resource has on `app.collections` or `app.globals`.              |
| `resourceId`         | The row id. Null for a global.                                               |
| `resourceLabel`      | The row's own label, cut to 200 characters.                                  |
| `userId`, `userName` | The actor.                                                                   |
| `locale`             | The locale the write ran under.                                              |
| `changes`            | `{ field: { from, to } }`, or null.                                          |
| `metadata`           | Always `actorType` and `accessMode`, plus what the hook or the caller added. |
| `title`              | One sentence, such as `Alice updated Posts 'Hello'`.                         |

`resourceLabel` is the first of `_title`, `title`, `name`, `label`, `slug` and
`id` that holds a non-empty string.

`changes` and `metadata` are `f.json()` columns. Values are coerced before they
are stored, so a `Date` arrives as an ISO string and a `bigint` as a string.
The diff leaves out `id`, `createdAt`, `updatedAt` and every key starting with
an underscore.

A global update carries no diff at all. `changes` is null on every one of them.
You get who and when, not what.

### Who the actor is

| `userName`                           | `userId`            | When                                          |
| ------------------------------------ | ------------------- | --------------------------------------------- |
| The user's name, else email, else id | The session user id | A session is present.                         |
| `System`                             | `system`            | No session, and the call runs in system mode. |
| `Anonymous`                          | null                | No session, and the call runs in user mode.   |

`metadata.actorType` carries the same three cases as `user`, `system` and
`anonymous`.

### The title is rebuilt on read

The stored `title` is English. An `afterRead` hook rewrites it in the reader's
locale when the request carries one. The stored row never changes, so a locale
with no messages falls back to what was written.

## Skipping a resource

```ts title="src/questpie/server/collections/import-runs.ts"
import { collection } from "#questpie/factories";

export const importRuns = collection("importRuns")
	.fields(({ f }) => ({
		name: f.text(120).label("Name").required(),
		payload: f.json().label("Payload"),
	}))
	.admin({ label: "Import runs", audit: false });
```

`audit: false` drops that collection out of the log. Leave the key unset and it
is audited. Globals take the same key on their own `.admin()`. Reach for it on
machine-written tables, heartbeats and caches, the ones that would bury the
entries a person needs to find.

## Reading it back

The log is a collection, so query it the way you query your own.

```ts
const { docs } = await app.collections.admin_audit_log.find({
	where: { resource: "posts", resourceId: id },
	orderBy: { createdAt: "desc" },
	limit: 20,
});
```

Two REST routes answer the same question for one record.
`GET /:collection/:id/audit` and `GET /globals/:name/audit`, both under your
handler's base path. Each takes `limit`, which defaults to 50, and `offset`.
Each checks the record exists first, and runs under the caller's own access
rules.

<Callout type="warn" title="The log answers an anonymous caller">
	The collection ships `access: { read: true }`, so `GET /api/admin_audit_log`
	needs no session. Every title, user name and diff is in there. Narrow it
	before you ship.
</Callout>

To narrow it, redeclare the collection in your own `collections/` directory and
merge the module's builder in. Your file wins, and the merge keeps the fields,
indexes, hooks and admin config. `.access()` replaces the whole object, so pass
every rule, not just `read`.

```ts title="src/questpie/server/collections/audit-log.ts"
import { auditLogCollection } from "@questpie/admin/modules/audit";

import { collection } from "#questpie/factories";

export const auditLog = collection("admin_audit_log")
	.merge(auditLogCollection)
	.access({
		read: ({ session }) => !!session?.user,
		create: false,
		update: false,
		delete: false,
	});
```

## Where each topic lives

| Topic                                         | Page                                          |
| --------------------------------------------- | --------------------------------------------- |
| One handler across every collection           | [Configuration](/docs/ship/configuration)     |
| The stages these hooks sit on                 | [Hooks](/docs/schema/hooks)                   |
| Why `read: true` means anyone                 | [Access control](/docs/schema/access-control) |
| Extending a collection a module ships         | [Modules](/docs/code/modules)                 |
| The `.admin()` call and the rest of its keys  | [Collections](/docs/admin/collections)        |
| Purge, and what it is allowed to leave behind | [Soft delete](/docs/schema/soft-delete)       |

## Next

**[Collections and globals](/docs/admin/collections)** covers the `.admin()`
call this module extends, and the four methods beside it.
