# Engagements

> Calls, emails, meetings, notes and tasks logged on one or more records, with an owner, a due time and an outcome, a record's timeline, its next step and a member's open tasks.

Source: https://bettersupabase.com/docs/blocks/activities

`engagements` logs the work people do around a record: a call with its outcome and duration, an email, a meeting, a note, or a task with a due time. One engagement can sit on several records at once, such as a call on a deal, its company and the person on the line, so each record's panel shows it. Any record registered in [`config.records`](/docs/blocks/sql#subject-registry) can carry engagements.

This block is the logged work, not the system history. The [activity feed](/docs/blocks/timeline) records what the system did (a stage change, a comment), and every engagement event carries its main record, so the feed shows both on one timeline.

Reading needs `engagements.read`, and logging needs `engagements.write`. The author and the owner edit an engagement, and `engagements.manage` edits anyone's. Admins get every permission, and members get `engagements.read` and `engagements.write`.

```bash
better-supabase add activities
```

The block's entity is `engagement` (the `activities` table). Installing it registers `engagement` as a subject, searchable by title, body and outcome when the search module is installed. Rename it with `blocks.activities.names`, for example `{ engagement: "touchpoint" }`. `engagementInputSchema` and `engagementChangeSchema` validate what `log` and `update` take.

The module needs `tenant`, `permissions` and `subjects`.

```ts
import { createActivities } from "better-supabase/blocks/activities";

const engagements = createActivities({ transport });
const deal = { type: "deal", id: dealId };

await engagements
  .log(
    organizationId,
    {
      type: "call",
      title: "Discovery call",
      outcome: "connected",
      durationSeconds: 900,
      doneAt: Temporal.Now.instant(),
    },
    [deal, { type: "person", id: personId }],
  )
  .orThrow();

await engagements
  .log(
    organizationId,
    { type: "task", title: "Send proposal", dueAt: friday },
    [deal],
  )
  .orThrow();
```

## Log and change [#log-and-change]

`log(organizationId, input, subjects)` needs a `type` and a `title`. The first record in `subjects` is the engagement's main record, which its events carry. Every record must exist in the organization and be readable by the caller (`ENGAGEMENT_SUBJECT_FORBIDDEN`). The owner defaults to the caller, and assigning it to someone else notifies them when the [notifications](/docs/blocks/notifications) module is installed.

`options.types` lists the types (default `call`, `email`, `meeting`, `note`, `task`; `ENGAGEMENT_TYPE_INVALID` for another). An `externalRef`, such as a message id from a mail provider or an event id from a calendar, is unique per organization: logging it again updates the engagement, so a sync can replay.

`update(id, input, subjects?)` changes the keys you pass (`null` clears one) and replaces the records when you pass `subjects`. `complete(id, { outcome })` marks it done now, and `{ done: false }` opens it again. `remove(id)` deletes it.

Installing the block registers engagements as the `engagement` subject, so the [attachments](/docs/blocks/attachments) block keeps files on a task, a call or an email: upload to `{ type: "engagement", id: task.id }` and list them with the same subject. Only people who can read the engagement can add or see its files.

## Your own fields [#your-own-fields]

`blocks.activities.options.extraColumns` adds columns to the `activities` table,
keyed by column name with their SQL type. `log` and `update` write them from `fields`, and every engagement comes back with them under `fields`. Pass a
[Standard Schema](https://standardschema.dev) as `fields` to type them,
parse them on reads and validate them on writes; a bad value is a
`validation` error before anything reaches the database (see
[extending blocks](/docs/extending/blocks#your-own-fields)):

```ts
import { z } from "zod";

const engagements = createActivities({
  transport,
  fields: z.object({ region: z.enum(["emea", "amer", "apac"]) }),
});

await engagements.log(
  organizationId,
  { type: "call", title: "Intro", fields: { region: "emea" } },
  subjects,
);
```

## Bookings [#bookings]

With the [bookings](/docs/blocks/bookings) block installed, set `blocks.activities.options.bookings: true` and every confirmed booking becomes a `meeting` engagement. A trigger on the bookings table keeps it in step, so bookings made through the public link or by a direct write are logged too.

```ts
export default defineConfig({
  blocks: {
    bookings: {},
    contacts: {},
    activities: { options: { bookings: true } },
  },
});
```

| Booking                     | Engagement                                                                                                                                    |
| --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| Confirmed                   | A `meeting` titled with the service and the customer's name, due at the start, with the notes as its body and the length in `durationSeconds` |
| Rescheduled while confirmed | The same meeting moves to the new time and opens again                                                                                        |
| Cancelled after confirming  | The meeting is done at the cancel time, with the outcome `cancelled: <reason>`                                                                |

The meeting's `externalRef` is `booking:<id>`, so it is one engagement per booking, and code that logs the same booking with that ref updates it instead of adding a second one. With [parties](/docs/blocks/contacts) installed, the meeting sits on the person whose user or email (in any case) matches the booker's, and on that person's company (the primary link first). A booker with no contact gets a meeting on no record, which every reader of the organization sees. The owner is the member who made the booking, else the contact's owner. It needs `meeting` in `options.types`, and installing it without the bookings block fails at render time. The events are the usual `engagement.logged`, `engagement.updated` and `engagement.completed`.

## Read [#read]

| Method                                         | Returns                                                                                               |
| ---------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
| `of(subject, { types, before, limit })`        | A record's engagements, newest first: done ones by when they happened, open ones by when they are due |
| `next(subject)`                                | The open engagement with the earliest due time, or `undefined`: a record's next step                  |
| `open(organizationId, { ownerId, dueBefore })` | A member's open engagements, earliest due first; the caller's by default                              |

Each engagement has `overdue`: open and past its due time. Reads run as the caller: an engagement is visible to members who can read one of its records, or to every reader of the organization when it is on none.

## Recordings and notes [#recordings-and-notes]

`blocks.activities.options.recordings` (off by default) stores call and meeting recordings, their transcripts and notes about an engagement. It needs the [ai-files](/docs/blocks/ai-files) block, which holds the audio or video, and adds the `engagement_recordings`, `engagement_transcript_segments` and `engagement_notes` tables. The block imports no speech or AI SDK (ADR 0010): you pass a `Transcriber` and a `NoteTaker` that wrap the provider you use.

```ts
import { createActivities } from "better-supabase/blocks/activities";

const engagements = createActivities({
  transport,
  service: serviceTransport,
  files: aiFiles,
  transcriber: {
    apiVersion: 1,
    model: "my-stt",
    transcribe: async ({ url, language }, { signal } = {}) => {
      const result = await speechToText(url, { language, signal });
      return {
        language: result.language,
        segments: result.turns.map((turn) => ({
          speaker: turn.speaker,
          startMs: turn.start,
          endMs: turn.end,
          text: turn.text,
        })),
      };
    },
  },
  noteTaker: {
    apiVersion: 1,
    model: "my-model",
    summarize: async ({ engagement, transcript }) =>
      summarizeCall(engagement, transcript),
  },
});

const recording = await engagements
  .attachRecording(engagementId, fileId, { language: "en" })
  .orThrow();
```

`attachRecording(engagementId, fileId)` links a ready ai-files file of the same organization: the author, the owner and `engagements.manage` may attach one, and the file must be the caller's unless they hold `engagements.manage`. The recording starts `pending`. With the [jobs](/docs/blocks/queue) block installed it queues a transcription on `engagements.transcribe`; register `transcribeJob()` as that queue's handler, and its last attempt marks the recording `failed` with the error. Without jobs, call `transcribe(recordingId)` yourself or run `drainRecordings()` on a schedule as the service role.

`transcribe` signs the file's URL and reads its bytes through `files`, calls the transcriber and saves its speaker turns. `saveTranscript(recordingId, transcript)` stores a transcript you already have, such as one a meeting tool exports; saving again replaces it. Each segment has a `speaker` label, an optional `speakerUserId`, `startMs` and `endMs` from the start of the recording, its `text` and an optional `confidence`.

`summarize(engagementId)` gives the note taker the engagement and every transcript turn and saves its summary and action items as the engagement's notes (`source: "ai"`, with the model). `saveNotes(engagementId, { summary, actionItems })` saves notes a member wrote (`source: "member"`), replacing earlier ones.

| Method                                      | Returns                                                      |
| ------------------------------------------- | ------------------------------------------------------------ |
| `recordings(engagementId)`                  | The engagement's recordings, oldest first, with their status |
| `transcript(recordingId, { after, limit })` | Speaker turns in order, 500 by default and 5000 at most      |
| `notes(engagementId)`                       | The engagement's notes, or `undefined`                       |

Recordings, transcripts and notes are readable by whoever can read the engagement. Teammates see the filename and media type without access to the file itself. `testTranscriber` and `testNoteTaker` from `better-supabase/testing` check that your wrappers keep the contract.

## Events [#events]

| Event                                                                                   | Data                                                                             |
| --------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
| `engagement.logged`, `engagement.updated`, `engagement.completed`, `engagement.deleted` | `organizationId`, `engagementId`, `type`, `subjectType`, `subjectId`, `subjects` |
| `engagement.recorded`, `engagement.transcribed`                                         | The same, and `recordingId`                                                      |
| `engagement.summarized`                                                                 | The same as `engagement.logged`                                                  |

## Hooks [#hooks]

The module calls `before_engagement_log(tenant, attrs jsonb, subjects jsonb)` and `after_engagement_log(engagement uuid)`. A `before_` hook raises to refuse the log. The TypeScript client takes `hooks` for its methods (see [extending blocks](/docs/extending/blocks)).

## Wire it to ReUI [#wire-it-to-reui]

The ReUI CRM Activity Timeline reads `of(subject)` and groups the rows into day buckets by `doneAt` or `dueAt`; its type tabs pass `types`. The Deal Desk composer calls `log` with the deal and its contact as subjects, and the Forecast Call's "No next step" and "Overdue activity" chips read `next(deal)` and its `overdue`.

## Errors [#errors]

| Hint                                                                           | Cause                                                      |
| ------------------------------------------------------------------------------ | ---------------------------------------------------------- |
| `ENGAGEMENT_FORBIDDEN`                                                         | the caller lacks `engagements.write`                       |
| `ENGAGEMENT_TYPE_INVALID`                                                      | not a configured type                                      |
| `ENGAGEMENT_TITLE_REQUIRED`                                                    | the title is empty                                         |
| `ENGAGEMENT_SUBJECTS_INVALID`                                                  | `subjects` isn't a list of `{ type, id }`                  |
| `ENGAGEMENT_SUBJECT_FORBIDDEN`                                                 | a record is unknown, in another organization or unreadable |
| `ENGAGEMENT_NOT_FOUND`                                                         | no engagement the caller can edit                          |
| `AI_FILE_NOT_FOUND`                                                            | no ready file of the organization the caller may attach    |
| `ENGAGEMENT_RECORDING_EXISTS`                                                  | the file is already a recording of the engagement          |
| `ENGAGEMENT_RECORDING_NOT_FOUND`                                               | no such recording                                          |
| `ENGAGEMENT_TRANSCRIPT_INVALID`                                                | a segment lacks its text or ends before it starts          |
| `ENGAGEMENT_NOTES_INVALID`                                                     | an empty summary or an action item without text            |
| `ENGAGEMENT_NO_TRANSCRIBER`, `ENGAGEMENT_NO_FILES`, `ENGAGEMENT_NO_NOTE_TAKER` | `createActivities` lacks the option the method needs       |
| `ENGAGEMENT_TRANSCRIBE_FAILED`, `ENGAGEMENT_SUMMARIZE_FAILED`                  | the transcriber or note taker threw                        |