All blocks
CRM & sales
Engagements
Calls, emails, meetings, notes and tasks logged on one or more registered records, with an owner, a due time and an outcome, a record's timeline, its next step and a member's open tasks.
npx better-supabase sql add engagementsimport { defineConfig } from "better-supabase/config";
export default defineConfig({
sql: {
modules: {
// Required by engagements; sql add installs them too.
"updated-at": {},
tenant: {},
access: {},
subjects: {},
// Modes: "managed", "custom". The default is "managed".
engagements: { mode: "managed" },
},
},
});npx better-supabase sql add engagements writes these files into your declarative schema with the default layout. See SQL modules for the migration step and the layout options.
supabase/schemas/900_better_supabase_01_updated_at.sqlschemaupdated-at100 linessupabase/better-supabase-data/900_better_supabase_01_updated_at.sqldataupdated-at12 linessupabase/schemas/900_better_supabase_04_tenant.sqlschematenant156 linessupabase/better-supabase-data/900_better_supabase_04_tenant.sqldatatenant12 linessupabase/schemas/900_better_supabase_19_access.sqlschemaaccess376 linessupabase/better-supabase-data/900_better_supabase_19_access.sqldataaccess12 linessupabase/schemas/900_better_supabase_65_subjects.sqlschemasubjects120 linessupabase/better-supabase-data/900_better_supabase_65_subjects.sqldatasubjects12 linessupabase/schemas/900_better_supabase_94_engagements.sqlschema386 linessupabase/better-supabase-data/900_better_supabase_94_engagements.sqldata12 lines
supabase/schemas/900_better_supabase_94_engagements.sql
-- better-supabase module: engagements
-- @bs-module engagements@1 managed
-- Calls, emails, meetings, notes and tasks logged on one or more registered records, with an owner, a due time and an outcome, a record's timeline, its next step and a member's open tasks.
-- Managed by `better-supabase sql add`; re-running it overwrites this file.
-- Change it through `sql.modules` in better-supabase.config.ts and the module's SQL hooks.
create or replace function better_supabase.actor_may_write()
returns boolean
language sql
stable
security invoker
set search_path = ''
as $$
select case
when auth.jwt() is null then true
when coalesce(auth.jwt() -> 'act' ->> 'read_only', '') = 'false' then true
when auth.jwt() -> 'act' ->> 'kind' = 'agent'
then coalesce(auth.jwt() -> 'act' ->> 'read_only', '') <> 'true'
when auth.jwt() -> 'act' ->> 'kind' in ('support', 'impersonation') then false
when auth.jwt() -> 'act' ->> 'session_id' is not null
and auth.jwt() -> 'act' ->> 'kind' is null then false
else true
end
$$;
revoke execute on function better_supabase.actor_may_write() from public;
grant execute on function better_supabase.actor_may_write() to anon, authenticated, service_role;
create or replace function better_supabase.raise_actor_readonly()
returns void
language plpgsql
volatile
set search_path = ''
as $$
begin
raise exception 'This session is read-only' using errcode = '42501', hint = 'ACTOR_READ_ONLY';
end;
$$;
revoke execute on function better_supabase.raise_actor_readonly() from public;
grant execute on function better_supabase.raise_actor_readonly() to anon, authenticated, service_role;
create schema if not exists better_supabase;
grant usage on schema better_supabase to anon, authenticated, service_role;
-- Calls, emails, meetings, notes and tasks logged on one or more records.
create table if not exists "better_supabase"."engagements" (
"id" uuid primary key default gen_random_uuid(),
"tenant_id" uuid not null,
"type" text not null,
"title" text not null check (length("title") between 1 and 500),
"body" text,
"outcome" text,
"owner_user_id" uuid,
"due_at" timestamptz,
"done_at" timestamptz,
"duration_seconds" integer check ("duration_seconds" >= 0),
"external_ref" text,
"created_by" uuid,
"created_at" timestamptz not null default now(),
"updated_at" timestamptz not null default now()
);
create index if not exists engagements_owner_open_idx on "better_supabase"."engagements" ("tenant_id", "owner_user_id", "due_at") where "done_at" is null;
create index if not exists engagements_tenant_created_idx on "better_supabase"."engagements" ("tenant_id", "created_at" desc);
create unique index if not exists engagements_external_ref_idx on "better_supabase"."engagements" ("tenant_id", "external_ref") where "external_ref" is not null;
alter table "better_supabase"."engagements" enable row level security;
revoke all on "better_supabase"."engagements" from anon, authenticated;
grant select on "better_supabase"."engagements" to authenticated;
grant all on "better_supabase"."engagements" to service_role;
create table if not exists "better_supabase"."engagement_subjects" (
"tenant_id" uuid not null,
"engagement_id" uuid not null references "better_supabase"."engagements" ("id") on delete cascade,
"subject_type" text not null,
"subject_id" text not null,
"position" integer not null default 1,
primary key ("engagement_id", "subject_type", "subject_id")
);
create index if not exists engagement_subjects_subject_idx on "better_supabase"."engagement_subjects" ("subject_type", "subject_id");
alter table "better_supabase"."engagement_subjects" enable row level security;
revoke all on "better_supabase"."engagement_subjects" from anon, authenticated;
grant select on "better_supabase"."engagement_subjects" to authenticated;
grant all on "better_supabase"."engagement_subjects" to service_role;
-- A link is visible when its record is; an engagement when one of its
-- records is, or it has none.
drop policy if exists engagement_subjects_read on "better_supabase"."engagement_subjects";
create policy engagement_subjects_read on "better_supabase"."engagement_subjects" for select to authenticated
using ("tenant_id" in (select better_supabase.tenant_ids_with('engagements.read')) and "better_supabase"."can_subject"("subject_type", "subject_id", 'read'));
drop policy if exists engagements_read on "better_supabase"."engagements";
create policy engagements_read on "better_supabase"."engagements" for select to authenticated
using ("tenant_id" in (select better_supabase.tenant_ids_with('engagements.read')) and (
not exists (select 1 from "better_supabase"."engagement_subjects" x where x."engagement_id" = "id")
or exists (select 1 from "better_supabase"."engagement_subjects" x where x."engagement_id" = "id" and "better_supabase"."can_subject"(x."subject_type", x."subject_id", 'read'))
));
-- Logs an engagement on the records in subjects ([{ type, id }], the first
-- is its main record). attrs: type, title, body, outcome, ownerId, dueAt,
-- doneAt, durationSeconds, externalRef. A known externalRef updates instead.
create or replace function "better_supabase"."log_engagement"(tenant uuid, attrs jsonb, subjects jsonb)
returns jsonb
language plpgsql
security definer
set search_path = ''
as $$
declare
v_id uuid;
v_tenant uuid := log_engagement.tenant;
v_type text := log_engagement.attrs ->> 'type';
v_title text := nullif(trim(log_engagement.attrs ->> 'title'), '');
v_owner uuid := coalesce(nullif(log_engagement.attrs ->> 'ownerId', '')::uuid, auth.uid());
v_previous_owner uuid;
v_item jsonb;
v_subjects jsonb;
v_first jsonb;
v_claims text;
begin
if not (better_supabase.actor_may_write()) then
raise exception 'This session is read-only' using errcode = '42501', hint = 'ACTOR_READ_ONLY';
end if;
if not (auth.role() = 'service_role' or coalesce(better_supabase.can('tenant', v_tenant, 'engagements.write'), false)) then
raise exception 'not allowed to log engagements' using errcode = '42501', hint = 'ENGAGEMENT_FORBIDDEN';
end if;
if v_type is null or not (v_type = any ('{call,email,meeting,note,task}'::text[])) then
raise exception 'unknown engagement type' using errcode = '22023', hint = 'ENGAGEMENT_TYPE_INVALID';
end if;
if v_title is null then
raise exception 'a title is required' using errcode = '23502', hint = 'ENGAGEMENT_TITLE_REQUIRED';
end if;
declare
v_hook regprocedure := to_regprocedure('"public"."before_engagement_log"(uuid, jsonb, jsonb)');
begin
if v_hook is not null then
execute format('select %s($1::uuid, $2::jsonb, $3::jsonb)', v_hook::oid::regproc)
using log_engagement.tenant, log_engagement.attrs, log_engagement.subjects;
end if;
end;
insert into "better_supabase"."engagements" ("tenant_id", "type", "title", "body", "outcome", "owner_user_id", "due_at", "done_at", "duration_seconds", "external_ref", "created_by")
values (v_tenant, v_type, v_title, log_engagement.attrs ->> 'body', log_engagement.attrs ->> 'outcome', v_owner,
(log_engagement.attrs ->> 'dueAt')::timestamptz, (log_engagement.attrs ->> 'doneAt')::timestamptz,
(log_engagement.attrs ->> 'durationSeconds')::integer, log_engagement.attrs ->> 'externalRef', auth.uid())
on conflict ("tenant_id", "external_ref") where "external_ref" is not null do update set
"title" = excluded."title", "body" = excluded."body", "outcome" = excluded."outcome",
"due_at" = excluded."due_at", "done_at" = excluded."done_at",
"duration_seconds" = excluded."duration_seconds", "updated_at" = now()
returning "id" into v_id;
if jsonb_typeof(coalesce(log_engagement.subjects, '[]')) <> 'array' then
raise exception 'subjects must be a list of { type, id }' using errcode = '22023', hint = 'ENGAGEMENT_SUBJECTS_INVALID';
end if;
for v_item in select value from jsonb_array_elements(coalesce(log_engagement.subjects, '[]')) loop
if not false then
raise exception 'a subject is unknown or not readable in this organization' using errcode = '42501', hint = 'ENGAGEMENT_SUBJECT_FORBIDDEN';
end if;
end loop;
delete from "better_supabase"."engagement_subjects" x where x."engagement_id" = v_id;
insert into "better_supabase"."engagement_subjects" ("tenant_id", "engagement_id", "subject_type", "subject_id", "position")
select v_tenant, v_id, s.value ->> 'type', s.value ->> 'id', s.ordinality::integer
from jsonb_array_elements(coalesce(log_engagement.subjects, '[]')) with ordinality s
on conflict do nothing;
v_subjects := coalesce((select jsonb_agg(jsonb_build_object('type', sx."subject_type", 'id', sx."subject_id") order by sx."position") from "better_supabase"."engagement_subjects" sx where sx."engagement_id" = v_id), '[]'::jsonb);
v_first := v_subjects -> 0;
null;
declare
v_hook regprocedure := to_regprocedure('"public"."after_engagement_log"(uuid)');
begin
if v_hook is not null then
execute format('select %s($1::uuid)', v_hook::oid::regproc)
using v_id;
end if;
end;
return (select to_jsonb(x) || jsonb_build_object('subjects', coalesce((select jsonb_agg(jsonb_build_object('type', sx."subject_type", 'id', sx."subject_id") order by sx."position") from "better_supabase"."engagement_subjects" sx where sx."engagement_id" = x."id"), '[]'::jsonb), 'overdue', x."done_at" is null and x."due_at" < now()) from "better_supabase"."engagements" x where x."id" = v_id);
end;
$$;
revoke execute on function "better_supabase"."log_engagement"(uuid, jsonb, jsonb) from public, anon;
grant execute on function "better_supabase"."log_engagement"(uuid, jsonb, jsonb) to authenticated, service_role;
-- Changes the keys attrs names, and the records when subjects isn't null.
-- The author, the owner and members with engagements.manage may edit.
create or replace function "better_supabase"."update_engagement"(engagement uuid, attrs jsonb, subjects jsonb)
returns jsonb
language plpgsql
security definer
set search_path = ''
as $$
declare
v_id uuid := update_engagement.engagement;
v_tenant uuid;
v_type text;
v_title text;
v_owner uuid;
v_previous_owner uuid;
v_item jsonb;
v_subjects jsonb;
v_first jsonb;
v_claims text;
begin
if not (better_supabase.actor_may_write()) then
raise exception 'This session is read-only' using errcode = '42501', hint = 'ACTOR_READ_ONLY';
end if;
select x."tenant_id", x."type", x."owner_user_id" into v_tenant, v_type, v_previous_owner
from "better_supabase"."engagements" x where x."id" = v_id and (auth.role() = 'service_role' or x."created_by" = auth.uid() or x."owner_user_id" = auth.uid() or coalesce(better_supabase.can('tenant', x."tenant_id", 'engagements.manage'), false));
if v_tenant is null then
raise exception 'no such engagement you can edit' using errcode = 'P0002', hint = 'ENGAGEMENT_NOT_FOUND';
end if;
if update_engagement.attrs ? 'type' and not (update_engagement.attrs ->> 'type' = any ('{call,email,meeting,note,task}'::text[])) then
raise exception 'unknown engagement type' using errcode = '22023', hint = 'ENGAGEMENT_TYPE_INVALID';
end if;
update "better_supabase"."engagements" x set
"type" = coalesce(update_engagement.attrs ->> 'type', x."type"),
"title" = coalesce(nullif(trim(update_engagement.attrs ->> 'title'), ''), x."title"),
"body" = case when update_engagement.attrs ? 'body' then update_engagement.attrs ->> 'body' else x."body" end,
"outcome" = case when update_engagement.attrs ? 'outcome' then update_engagement.attrs ->> 'outcome' else x."outcome" end,
"external_ref" = case when update_engagement.attrs ? 'externalRef' then update_engagement.attrs ->> 'externalRef' else x."external_ref" end,
"owner_user_id" = case when update_engagement.attrs ? 'ownerId' then nullif(update_engagement.attrs ->> 'ownerId', '')::uuid else x."owner_user_id" end,
"due_at" = case when update_engagement.attrs ? 'dueAt' then (update_engagement.attrs ->> 'dueAt')::timestamptz else x."due_at" end,
"done_at" = case when update_engagement.attrs ? 'doneAt' then (update_engagement.attrs ->> 'doneAt')::timestamptz else x."done_at" end,
"duration_seconds" = case when update_engagement.attrs ? 'durationSeconds' then (update_engagement.attrs ->> 'durationSeconds')::integer else x."duration_seconds" end,
"updated_at" = now()
where x."id" = v_id
returning x."type", x."title", x."owner_user_id" into v_type, v_title, v_owner;
if update_engagement.subjects is not null then
if jsonb_typeof(coalesce(update_engagement.subjects, '[]')) <> 'array' then
raise exception 'subjects must be a list of { type, id }' using errcode = '22023', hint = 'ENGAGEMENT_SUBJECTS_INVALID';
end if;
for v_item in select value from jsonb_array_elements(coalesce(update_engagement.subjects, '[]')) loop
if not false then
raise exception 'a subject is unknown or not readable in this organization' using errcode = '42501', hint = 'ENGAGEMENT_SUBJECT_FORBIDDEN';
end if;
end loop;
delete from "better_supabase"."engagement_subjects" x where x."engagement_id" = v_id;
insert into "better_supabase"."engagement_subjects" ("tenant_id", "engagement_id", "subject_type", "subject_id", "position")
select v_tenant, v_id, s.value ->> 'type', s.value ->> 'id', s.ordinality::integer
from jsonb_array_elements(coalesce(update_engagement.subjects, '[]')) with ordinality s
on conflict do nothing;
v_subjects := coalesce((select jsonb_agg(jsonb_build_object('type', sx."subject_type", 'id', sx."subject_id") order by sx."position") from "better_supabase"."engagement_subjects" sx where sx."engagement_id" = v_id), '[]'::jsonb);
v_first := v_subjects -> 0;
else
v_subjects := coalesce((select jsonb_agg(jsonb_build_object('type', sx."subject_type", 'id', sx."subject_id") order by sx."position") from "better_supabase"."engagement_subjects" sx where sx."engagement_id" = v_id), '[]'::jsonb);
v_first := v_subjects -> 0;
end if;
null;
return (select to_jsonb(x) || jsonb_build_object('subjects', coalesce((select jsonb_agg(jsonb_build_object('type', sx."subject_type", 'id', sx."subject_id") order by sx."position") from "better_supabase"."engagement_subjects" sx where sx."engagement_id" = x."id"), '[]'::jsonb), 'overdue', x."done_at" is null and x."due_at" < now()) from "better_supabase"."engagements" x where x."id" = v_id);
end;
$$;
revoke execute on function "better_supabase"."update_engagement"(uuid, jsonb, jsonb) from public, anon;
grant execute on function "better_supabase"."update_engagement"(uuid, jsonb, jsonb) to authenticated, service_role;
-- Marks an engagement done now (done true) or open again.
create or replace function "better_supabase"."complete_engagement"(engagement uuid, done boolean, outcome text)
returns jsonb
language plpgsql
security definer
set search_path = ''
as $$
declare
v_id uuid := complete_engagement.engagement;
v_tenant uuid;
v_type text;
v_subjects jsonb := coalesce((select jsonb_agg(jsonb_build_object('type', sx."subject_type", 'id', sx."subject_id") order by sx."position") from "better_supabase"."engagement_subjects" sx where sx."engagement_id" = complete_engagement.engagement), '[]'::jsonb);
v_first jsonb := v_subjects -> 0;
begin
if not (better_supabase.actor_may_write()) then
raise exception 'This session is read-only' using errcode = '42501', hint = 'ACTOR_READ_ONLY';
end if;
update "better_supabase"."engagements" x set
"done_at" = case when complete_engagement.done then coalesce(x."done_at", now()) end,
"outcome" = coalesce(complete_engagement.outcome, x."outcome"),
"updated_at" = now()
where x."id" = v_id and (auth.role() = 'service_role' or x."created_by" = auth.uid() or x."owner_user_id" = auth.uid() or coalesce(better_supabase.can('tenant', x."tenant_id", 'engagements.manage'), false))
returning x."tenant_id", x."type" into v_tenant, v_type;
if v_tenant is null then
raise exception 'no such engagement you can edit' using errcode = 'P0002', hint = 'ENGAGEMENT_NOT_FOUND';
end if;
if complete_engagement.done then
null;
end if;
return (select to_jsonb(x) || jsonb_build_object('subjects', coalesce((select jsonb_agg(jsonb_build_object('type', sx."subject_type", 'id', sx."subject_id") order by sx."position") from "better_supabase"."engagement_subjects" sx where sx."engagement_id" = x."id"), '[]'::jsonb), 'overdue', x."done_at" is null and x."due_at" < now()) from "better_supabase"."engagements" x where x."id" = v_id);
end;
$$;
revoke execute on function "better_supabase"."complete_engagement"(uuid, boolean, text) from public, anon;
grant execute on function "better_supabase"."complete_engagement"(uuid, boolean, text) to authenticated, service_role;
create or replace function "better_supabase"."delete_engagement"(engagement uuid)
returns boolean
language plpgsql
security definer
set search_path = ''
as $$
declare
v_id uuid := delete_engagement.engagement;
v_tenant uuid;
v_type text;
v_subjects jsonb := coalesce((select jsonb_agg(jsonb_build_object('type', sx."subject_type", 'id', sx."subject_id") order by sx."position") from "better_supabase"."engagement_subjects" sx where sx."engagement_id" = delete_engagement.engagement), '[]'::jsonb);
v_first jsonb := v_subjects -> 0;
begin
if not (better_supabase.actor_may_write()) then
raise exception 'This session is read-only' using errcode = '42501', hint = 'ACTOR_READ_ONLY';
end if;
delete from "better_supabase"."engagements" x where x."id" = v_id and (auth.role() = 'service_role' or x."created_by" = auth.uid() or x."owner_user_id" = auth.uid() or coalesce(better_supabase.can('tenant', x."tenant_id", 'engagements.manage'), false))
returning x."tenant_id", x."type" into v_tenant, v_type;
if v_tenant is null then
return false;
end if;
null;
return true;
end;
$$;
revoke execute on function "better_supabase"."delete_engagement"(uuid) from public, anon;
grant execute on function "better_supabase"."delete_engagement"(uuid) to authenticated, service_role;
-- A record's engagements the caller can see, newest first: done ones by
-- when they happened, open ones by when they are due. before continues
-- after the last row of the previous page.
create or replace function "better_supabase"."engagements_of"(subject_type text, subject_id text, types text[], before timestamptz, page_size integer)
returns jsonb
language sql
stable
security invoker
set search_path = ''
as $$
select coalesce(jsonb_agg(p.value order by p.at desc, p.id desc), '[]'::jsonb)
from (
select to_jsonb(x) || jsonb_build_object('subjects', coalesce((select jsonb_agg(jsonb_build_object('type', sx."subject_type", 'id', sx."subject_id") order by sx."position") from "better_supabase"."engagement_subjects" sx where sx."engagement_id" = x."id"), '[]'::jsonb), 'overdue', x."done_at" is null and x."due_at" < now()) as value, coalesce(x."done_at", x."due_at", x."created_at") as at, x."id" as id
from "better_supabase"."engagements" x
join "better_supabase"."engagement_subjects" k on k."engagement_id" = x."id"
where k."subject_type" = engagements_of.subject_type and k."subject_id" = engagements_of.subject_id
and (engagements_of.types is null or x."type" = any (engagements_of.types))
and (engagements_of.before is null or coalesce(x."done_at", x."due_at", x."created_at") < engagements_of.before)
order by at desc, id desc
limit least(greatest(coalesce(engagements_of.page_size, 50), 1), 200)
) p
$$;
revoke execute on function "better_supabase"."engagements_of"(text, text, text[], timestamptz, integer) from public, anon;
grant execute on function "better_supabase"."engagements_of"(text, text, text[], timestamptz, integer) to authenticated, service_role;
-- The next open engagement with a due time on a record, earliest first,
-- for a "next step" and an overdue flag; null when none is planned.
create or replace function "better_supabase"."next_engagement"(subject_type text, subject_id text)
returns jsonb
language sql
stable
security invoker
set search_path = ''
as $$
select to_jsonb(x) || jsonb_build_object('subjects', coalesce((select jsonb_agg(jsonb_build_object('type', sx."subject_type", 'id', sx."subject_id") order by sx."position") from "better_supabase"."engagement_subjects" sx where sx."engagement_id" = x."id"), '[]'::jsonb), 'overdue', x."done_at" is null and x."due_at" < now())
from "better_supabase"."engagements" x
join "better_supabase"."engagement_subjects" k on k."engagement_id" = x."id"
where k."subject_type" = next_engagement.subject_type and k."subject_id" = next_engagement.subject_id
and x."done_at" is null and x."due_at" is not null
order by x."due_at", x."id"
limit 1
$$;
revoke execute on function "better_supabase"."next_engagement"(text, text) from public, anon;
grant execute on function "better_supabase"."next_engagement"(text, text) to authenticated, service_role;
-- A member's open engagements in a tenant, earliest due first: a task list.
create or replace function "better_supabase"."open_engagements"(tenant uuid, owner uuid, due_before timestamptz, page_size integer)
returns jsonb
language sql
stable
security invoker
set search_path = ''
as $$
select coalesce(jsonb_agg(p.value order by p.due nulls last, p.id), '[]'::jsonb)
from (
select to_jsonb(x) || jsonb_build_object('subjects', coalesce((select jsonb_agg(jsonb_build_object('type', sx."subject_type", 'id', sx."subject_id") order by sx."position") from "better_supabase"."engagement_subjects" sx where sx."engagement_id" = x."id"), '[]'::jsonb), 'overdue', x."done_at" is null and x."due_at" < now()) as value, x."due_at" as due, x."id" as id
from "better_supabase"."engagements" x
where x."tenant_id" = open_engagements.tenant
and x."owner_user_id" = coalesce(open_engagements.owner, auth.uid())
and x."done_at" is null
and (open_engagements.due_before is null or x."due_at" < open_engagements.due_before)
order by x."due_at" nulls last, x."id"
limit least(greatest(coalesce(open_engagements.page_size, 50), 1), 200)
) p
$$;
revoke execute on function "better_supabase"."open_engagements"(uuid, uuid, timestamptz, integer) from public, anon;
grant execute on function "better_supabase"."open_engagements"(uuid, uuid, timestamptz, integer) to authenticated, service_role;
create schema if not exists better_supabase;
create table if not exists better_supabase.modules (
name text primary key,
version integer not null,
mode text not null,
installed_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
alter table better_supabase.modules enable row level security;
revoke all on better_supabase.modules from anon, authenticated;
grant select on better_supabase.modules to service_role;
supabase/better-supabase-data/900_better_supabase_94_engagements.sql
-- better-supabase module: engagements
-- @bs-module-data engagements
-- Rows and settings a schema diff doesn't capture. Run `better-supabase sql data`
-- after the schema migration to put them in a migration.
-- Managed by `better-supabase sql add`; re-running it overwrites this file.
-- Change it through `sql.modules` in better-supabase.config.ts and the module's SQL hooks.
insert into better_supabase.modules (name, version, mode)
values ('engagements', 1, 'managed')
on conflict (name) do update
set version = excluded.version, mode = excluded.mode, updated_at = now();
This block has no React entry yet. Its TypeScript side is in better-supabase/blocks/engagements, which runs on the server.
With the outbox installed, the module writes these events in the same transaction as the change.
- engagement.completed
- engagement.deleted
- engagement.logged
- engagement.updated