# Audit — developer guide

This page explains how to trigger an audit from your code, recommended practices, how subjects are converted and displayed, and how the whole pipeline persists an `AuditTrail`.

## Triggering an audit

Audits are triggered through the `Chill\MainBundle\Audit\TriggerAuditInterface` service. It exposes a single method and is also invokable:

```php
use Chill\MainBundle\Audit\TriggerAuditInterface;
use Chill\MainBundle\Entity\AuditTrail; // for action constants
use Symfony\Component\Translation\TranslatableMessage;

final class MyController
{
    public function __construct(private readonly TriggerAuditInterface $triggerAudit) {}

    public function someAction(MyEntity $entity): Response
    {
        // ... persist/flush $entity so that its id is available

        $this->triggerAudit->triggerAudit(
            AuditTrail::AUDIT_UPDATE,        // $action
            $entity,                         // $mainSubject (object)
            subjects: [$entity->getOwner()], // optional related subjects (array of objects)
            description: new TranslatableMessage('audit.entity.updated'), // optional description
            metadata: ['field' => 'value']   // optional machine-readable extras
        );

        // equivalently (because the service is invokable):
        ($this->triggerAudit)(AuditTrail::AUDIT_UPDATE, $entity);

        // ...
    }
}
```

- `$action`: an action label such as `AuditTrail::AUDIT_CREATE`, `AUDIT_UPDATE`, `AUDIT_DELETE`, `AUDIT_VIEW`, `AUDIT_LIST`, ...
- `$mainSubject`: the primary domain object the action is about.
- `$subjects`: additional related domain objects to attach to the audit (optional). The always-related subject should be added through the converter.
- `$description`: a short human-readable text, already translated. You may pass a plain string or any `TranslatableInterface` (e.g. `TranslatableMessage`).
- `$metadata`: free-form associative array to add machine-readable context (ids, filters, counters, etc.).

!!! warning

    The id of every subject must be set when you trigger the audit. In practice this means:
    - trigger audits after persist operations (once Doctrine assigned an id, i.e. after `flush()` when needed), and
    - trigger audits before delete operations (while the id is still available).

See concrete usages in controllers such as:
- `Chill\PersonBundle\Controller\AccompanyingCourseApiController` (multiple calls to `$this->triggerAudit->triggerAudit(...)`).

Example extracted and simplified from `AccompanyingCourseApiController`:

```php
$this->managerRegistry->getManager()->flush(); // ensure ids are set

$this->triggerAudit->triggerAudit(
    AuditTrail::AUDIT_UPDATE,
    $accompanyingPeriod,
    description: new TranslatableMessage('audit.accompanying_period.set_requestor'),
    metadata: ['t' => $accompanyingPeriod->getRequestorKind(), 'requestor' => $accompanyingPeriod->getRequestor()->getId()],
);
```

### Best practices

- Audit every create, update, delete, view and list action on domain entities.
- Use only the `$action` when it is obvious and self-explanatory.
- Use the `$description` when the action alone is not enough. For example for a list, the main subject is the person (or the accompanying period) the list is attached to, and the description should summarize the list content (filters, ranges, counts, …).
- The description should be a `TranslatableMessage`, and the first key of the message should be `audit`
- Add `$metadata` to provide extra, machine-friendly details (ids, filters, pagination, counts, etc.).
- Reuse the existing patterns found in current controllers and services.

Examples:

```php
// CREATE a person
($this->triggerAudit)(AuditTrail::AUDIT_CREATE, $person);

// VIEW a person details
($this->triggerAudit)(AuditTrail::AUDIT_VIEW, $person);

// LIST resources attached to an accompanying period with filters in metadata
($this->triggerAudit)(
    AuditTrail::AUDIT_LIST,
    $accompanyingPeriod,
    description: new TranslatableMessage('audit.Listing resources for the current period'),
    metadata: ['scope' => 'resources', 'filters' => ['category' => 'housing', 'page' => 1]]
);
```

### Audits in ApiController

When using `Chill\MainBundle\CRUD\Controller\ApiController`, the `onAfterFlush` method can be overridden to trigger audits. This method is called after the database operations (flush) are completed.

Example (based on `AccompanyingCourseWorkApiController`):

```php
protected function onAfterFlush(
    string $action,
    Request $request,
    string $_format,
    $entity,
    ConstraintViolationListInterface $errors,
    array $more = []
): ?Response {
    if (Request::METHOD_GET === $request->getMethod()) {
        $this->triggerAudit->triggerAudit(AuditTrail::AUDIT_VIEW, $entity);
    } elseif (Request::METHOD_PUT === $request->getMethod() || Request::METHOD_PATCH === $request->getMethod()) {
        $this->triggerAudit->triggerAudit(AuditTrail::AUDIT_UPDATE, $entity);
    } elseif (Request::METHOD_POST === $request->getMethod()) {
        $this->triggerAudit->triggerAudit(AuditTrail::AUDIT_CREATE, $entity);
    } elseif (Request::METHOD_DELETE === $request->getMethod()) {
        $this->triggerAudit->triggerAudit(AuditTrail::AUDIT_DELETE, $entity);
    } else {
        throw new \RuntimeException(sprintf('Unsupported HTTP method "%s" for audit trail.', $request->getMethod()));
    }

    return parent::onAfterFlush($action, $request, $_format, $entity, $errors, $more);
}
```

## Use new entities in Audits

### Creating a new SubjectConverter

Subjects are normalized into a serializable `Subject` model using `Chill\MainBundle\Audit\SubjectConverterInterface`.
Implement a converter when you introduce a new domain type you want to appear in audits.

Interface:

```php
interface SubjectConverterInterface
{
    /**
     * @param object $subject
     */
    public function convert(mixed $subject, bool $includeAssociated = false): SubjectBag;

    /** @phpstan-assert-if-true T $subject */
    public function supportsConvert(mixed $subject): bool;

    public static function getDefaultPriority(): int;
}
```

Typical implementation skeleton:

```php
use Chill\MainBundle\Audit\{Subject, SubjectBag, SubjectConverterInterface, SubjectConverterManagerAwareInterface, SubjectConverterManagerAwareTrait};

final class MyEntitySubjectConverter implements SubjectConverterInterface, SubjectConverterManagerAwareInterface
{
    use SubjectConverterManagerAwareTrait;

    public function supportsConvert(mixed $subject): bool
    {
        return $subject instanceof MyEntity;
    }

    public function convert(mixed $subject, bool $includeAssociated = false): SubjectBag
    {
        \assert($subject instanceof MyEntity);

        $main = new SubjectBag(
            new Subject(
                type: 'my_entity',
                identifiers: ['id' => $subject->getId()],
            )
        );

        $associated = [];
        if ($includeAssociated && null !== $subject->getOwner()) {
            // we delegate the conversion of the owner to the manager
            $main->append($this->subjectConverterManager->getSubjectsForEntity($subject->getOwner(), false));
        }

        return new SubjectBag($main, $associated);
    }

    public static function getDefaultPriority(): int
    {
        return 0; // higher value = earlier pick when multiple converters support the same type
    }
}
```

- `SubjectBag` groups the primary `Subject` and optional associated `Subject`s.
- Converters are discovered and composed by `SubjectConverterManagerInterface`.
- Implement `SubjectConverterManagerAwareInterface` (and use `SubjectConverterManagerAwareTrait`) if your converter needs to delegate the conversion of associated entities to the manager (useful if there is already a converter for that entity).

### Creating a new SubjectDisplayer

To control how a `Subject` is rendered in the UI (HTML or plain string), implement `Chill\MainBundle\Audit\SubjectDisplayerInterface`:

```php
interface SubjectDisplayerInterface
{
    public function supportsDisplay(Subject $subject, array $options = []): bool;

    /** @param 'html'|'string' $format */
    public function display(Subject $subject, string $format = 'html', array $options = []): string;
}
```

Example: see `Chill\PersonBundle\Audit\Displayer\HouseholdCompositionDisplayer`. A simplified version:

```php
use Chill\MainBundle\Audit\{Subject, SubjectDisplayerInterface};

final class MyEntityDisplayer implements SubjectDisplayerInterface
{
    public function supportsDisplay(Subject $subject, array $options = []): bool
    {
        return 'my_entity' === $subject->type;
    }

    public function display(Subject $subject, string $format = 'html', array $options = []): string
    {
        $label = sprintf('#%d %s', $subject->identifiers['id'], $subject->labels['name'] ?? '');
        return 'html' === $format ? '<span>'.$label.'</span>' : $label; // <-- here, be aware that there is no xss risk. If any, use twig.
    }
}
```

!!! warning

    Use twig to render html format if there is a risk for xss injection.

## How it works internally

1. Storage: every audit is persisted as an `AuditTrail` entity.
2. Subject conversion: the main subject and all additional subjects are converted to `Subject` models using `SubjectConverterInterface` implementations, orchestrated by `SubjectConverterManagerInterface`.
3. Display: UI rendering uses `SubjectDisplayerInterface` implementations to render `Subject`s as HTML or strings.
4. Persistence: `TriggerAuditInterface` builds an `AuditEvent`, converts it to an `AuditTrail` via `AuditEvent2TrailInterface`, and persists it through the audit persistence layer (`AuditTrailPersisterInterface`, i.e. the persistence interface behind the service). There is no need to call `EntityManager::flush()` for the audit itself, so it is fast; however you must trigger the audit when the subject ids are available (see warning above).

See also:
- `Chill\MainBundle\Audit\TriggerAuditService`
- `Chill\MainBundle\Audit\AuditEvent2Trail`
- `Chill\MainBundle\Audit\AuditTrailPersister`
- `Chill\MainBundle\Audit\SubjectConverterManagerInterface`
- `Chill\PersonBundle\Audit\Displayer\HouseholdCompositionDisplayer`
