Automations
Status: Shipped. Last reviewed: 2026-10-21
What
Owner-authored journeys: a trigger (member or lead event, or a daily scan) enrolls a subject, and the engine walks a flat list of delay and action steps. Message actions send through email, push, or WhatsApp (SMS is modelled but stubbed). Architecture and the trigger catalogue live in ADR-0011; lead consent in ADR-0013.
Personas
| Persona | Surface | Capabilities |
|---|---|---|
| Owner / admin | /dashboard/automations | Create, edit, activate, pause automations; preview each step |
| Member / lead | Email, push, WhatsApp | Receives messages; members opt out per channel (mobile app or /{lang}/profile/notifications on web), leads per consent |
| Guardian | Email, push, WhatsApp | Receives transactional automations about a managed member (child) they guard |
Message bodies
- A message step stores
messageBody(plain text, always present) and, when authored in the rich editor,messageBodyDoc(Tiptap JSON). - The rich node set is closed: paragraph, hard break, bullet/ordered list, heading 2–3; marks bold, italic, underline, link.
messageBodyDocSchemarejects anything else on save and at dispatch. - Links accept
https://(with a host),mailto:, and the app destinations inMESSAGE_APP_LINKS(/schedule,/shop). App destinations are stored as the bare path and resolved againstFRONTEND_URLat render time, so the delivered link is a universal/app link the member app claims (apps/web/public/.well-known/apple-app-site-association, Android intent filters in the mobile repo) and opens the app when it is installed; any other web address opens the browser. Only add a path toMESSAGE_APP_LINKSonce both platforms claim it. Tokens inside an href are URL-encoded and the protocol is re-checked after interpolation; a link that fails renders as plain text. - The dispatcher interpolates tokens (
{first_name},{org_name}, …) and hands channels bothbody(plain projection, links astext (url)) and, for rich steps,bodyHtml(escaped fragment with inline styles). Email usesbodyHtml; push, WhatsApp, and SMS usebody. - Each trigger fills only the tokens its enrollment context carries:
AUTOMATION_TOKENS_BY_TRIGGERinlibs/shared/src/lib/schemas/automation-tokens.ts(e.g.{plan_name}only forsubscription_started/membership_renewed). The editor offers only those, and the API rejects create, update and activation with 400automation_unsupported_variableswhen the subject, body or rich document uses another one — including an existing automation being re-activated. - Renderer:
libs/shared/src/lib/richtext/message-body.ts. Editor:rich-body-editor.tsxwith themessagepreset; preview:message-body-view.tsx(React, no HTML injection).
- Every automation email renders through the branded shell (
composeEmail, templateapps/api/src/notifications/templates/automation.ts): org letterhead and footer, the recipient’s locale and text direction, org From/Reply-To. - Members resolve context with
EmailBrandingService.forRecipient; leads withforOrgin the lead’s captured locale. - An empty subject falls back to the org name, never the automation’s internal name.
Opt-out
| Recipient | Purpose | Affordance |
|---|---|---|
| Member | Promotional trigger | Unsubscribe link + List-Unsubscribe / List-Unsubscribe-Post (RFC 8058). Flips the member’s automation.email preference. |
| Member | Transactional trigger | None. |
| Lead | Any | Consent footer + List-Unsubscribe (ADR-0013). |
- Trigger purpose:
apps/api/src/automations/channels/message-purpose.ts. - Member tokens and the public endpoint (
POST /email-unsubscribe;POST /announcements/unsubscribestill served for links already mailed) live inapps/api/src/member-email-unsubscribe/. Tokens carry a category (announcement|automation) and never expire. Web confirmation page:/{lang}/automation-emails/unsubscribe. - A member whose
automation.emailpreference is off receives no automation email, promotional or transactional. Members turn it back on in the app or on the web preferences page, which both unsubscribe pages link to. new_memberis transactional, so it reaches minors and carries no unsubscribe link.
New-member welcome
new_memberis the gym’s one welcome, for every new member however they joined: join link (paid or presale), invite, desk, lead conversion. It enrolls onMEMBERSHIP_ACTIVATION_CONFIRMED, once per membership (enroll keynew_member:<membershipId>), so renewals and later purchases never re-send it.- A join-link signup that chose a plan is held by the member activation gate until the purchase goes through (
subscription.started, orsubscription.presale_soldfor a presale), so the welcome never reaches someone who abandoned checkout. See staff-newsletter behavior. - The system purchase confirmation (
MembershipWelcomeService) is a separate, fixed email: amount, plan, dates and the legally required cancellation channels. An online buyer gets both. - A member whose login is still on its way — no account yet and an invitation pending in the org, as after a desk registration — enrolls when they claim it (
MEMBERSHIP_ACCOUNT_CLAIMED, same enroll key), within 30 days of joining. Every welcome points at the app, which they cannot enter until then. A member with no invitation coming is welcomed at activation as before, and a failed lookup also falls back to welcoming at once. - A managed member (child) enrolls when a guardian first accepts their link (
MANAGED_MEMBER_GUARDIAN_CONFIRMED, same enroll key), not when staff create the child — before that nobody can receive it. Imported members, managed or not, are never welcomed.
Managed members
- For a managed subject the dispatcher sends to every guardian with an active grant that includes notifications (
NotificationRecipientsService), in the guardian’s locale and under the guardian’s preferences;{first_name}stays the child’s name and the email subject is prefixed “Regarding:”. - Promotional automations about a managed member are never sent, on any channel (
promotional_to_minor_blocked). No reachable guardian →no_guardian_recipient. - With several guardians the step is
sentif any copy was delivered.
Push
Automation pushes open the member app’s home tab (/(tabs)); the app has no notification inbox.
A push that reaches nobody — no registered device (no_device) or push turned off (push_disabled / member_opted_out) — is recorded as skipped, so the step’s fallback channel sends instead. Branded-app device tokens are looked up by org.
Counters
The automations list shows, per automation, enrollments still in progress (enrolledCount, status active) and step runs sent in the last 30 days (sentLast30d).
Related
../staff-newsletter/— the activation gate and staff new-member mail.../announcements/— shares the member email unsubscribe.../leads-crm/— lead triggers and consent.