Skip to Content
Living documentation — last reviewed 2026-05-28
FeaturesAutomationsAutomations

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

PersonaSurfaceCapabilities
Owner / admin/dashboard/automationsCreate, edit, activate, pause automations; preview each step
Member / leadEmail, push, WhatsAppReceives messages; members opt out per channel (mobile app or /{lang}/profile/notifications on web), leads per consent
GuardianEmail, push, WhatsAppReceives 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. messageBodyDocSchema rejects anything else on save and at dispatch.
  • Links accept https:// (with a host), mailto:, and the app destinations in MESSAGE_APP_LINKS (/schedule, /shop). App destinations are stored as the bare path and resolved against FRONTEND_URL at 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 to MESSAGE_APP_LINKS once 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 both body (plain projection, links as text (url)) and, for rich steps, bodyHtml (escaped fragment with inline styles). Email uses bodyHtml; push, WhatsApp, and SMS use body.
  • Each trigger fills only the tokens its enrollment context carries: AUTOMATION_TOKENS_BY_TRIGGER in libs/shared/src/lib/schemas/automation-tokens.ts (e.g. {plan_name} only for subscription_started / membership_renewed). The editor offers only those, and the API rejects create, update and activation with 400 automation_unsupported_variables when 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.tsx with the message preset; preview: message-body-view.tsx (React, no HTML injection).

Email

  • Every automation email renders through the branded shell (composeEmail, template apps/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 with forOrg in the lead’s captured locale.
  • An empty subject falls back to the org name, never the automation’s internal name.

Opt-out

RecipientPurposeAffordance
MemberPromotional triggerUnsubscribe link + List-Unsubscribe / List-Unsubscribe-Post (RFC 8058). Flips the member’s automation.email preference.
MemberTransactional triggerNone.
LeadAnyConsent 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/unsubscribe still served for links already mailed) live in apps/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.email preference 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_member is transactional, so it reaches minors and carries no unsubscribe link.

New-member welcome

  • new_member is the gym’s one welcome, for every new member however they joined: join link (paid or presale), invite, desk, lead conversion. It enrolls on MEMBERSHIP_ACTIVATION_CONFIRMED, once per membership (enroll key new_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, or subscription.presale_sold for 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 sent if 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).