Action for Happiness
Log in

← All schema domains · View source on GitHub →

Nudge

The intervention system: reusable messages (NUDGE), reusable schedules (NUDGE_SCHEDULE / NUDGE_CRON), and an append-only delivery log. Sends have two paths: recipe-driven for group/cohort/theme audiences (defined in recipe) and API-driven for one-off or event-actor sends (the caller hits a trigger_type=webhook schedule and supplies the user_id). NUDGE_DELIVERY_LOG.recipe_id is nullable to reflect this — null means the send came in via API rather than a recipe.

See also: recipe for sequencing + targeting, user / cohort (delivery context).

Tables

NUDGE

A reusable intervention — an encouragement, prompt, reminder, or link to an exercise. type classifies it (prompt, reminder, encouragement, link, exercise), body_md is the message content, and link optionally points to a URL the recipient should visit. The "what to send", kept separate from both the "when" (NUDGE_SCHEDULE) and the "who" (RECIPE). Carries the same publishing lifecycle as a course: status (draftpublishedarchived) so a half-written nudge can't fire, author_id for ownership, version per published revision, and published_at / archived_at timestamps.

id            ndg_01_three_things
author_id     usr_09_teacher
title         Three things
description   Daily prompt to list three things you're grateful for.
type          prompt
body_md       Take a minute — what are **three things** you're grateful for today?
link          https://app.afh.example/exercise/three-things
status        published
version        1
published_at  2026-03-01T09:00:00Z
archived_at   null

NUDGE_SCHEDULE

The "when" half of a nudge. trigger_type picks one of three modes:

  • cron — fires on the cadence in cron_id.
  • event — fires when a USER_ACTION whose action_type matches event_action_type is recorded. offset_days / offset_time set how long after the event the nudge actually sends (both null = immediate). Optionally scope the match to a single target with event_target_type + event_target_id (matched against USER_ACTION.target_type / target_id): leave both null to fire on any target of that action type (e.g. any module completed), or set them to fire only for one specific target (e.g. only when module X is completed).
  • webhook — fires when an external caller hits webhook_url. The DB doesn't know what the event was; whatever called the URL is the trigger.

dedupe_window_minutes is the schedule-level send policy: the minimum minutes that must pass before this same schedule may produce another delivery for the same user. For example 1440 means "don't send this scheduled/event-triggered nudge to the same user more than once per day"; null means no window-based dedupe beyond the per-send idempotency_key on the delivery log. This is enforced in application logic (often by deriving a dedupe_bucket from the window and folding it into the delivery idempotency_key), not by a constraint on this row.

Each row binds one nudge to one trigger.

id                 sch_01_three_things_daily
nudge_id           ndg_01_three_things
trigger_type       cron
cron_id            cron_weekday_9am
event_action_type  null
event_target_type  null
event_target_id    null
offset_days        null
offset_time        null
webhook_url        null
dedupe_window_minutes  null

Event-triggered example — "send the welcome nudge 1 day after a user enrolls, at 9am". event_target_* are null, so it fires on any enrolled action:

id                 sch_02_post_enrollment
nudge_id           ndg_02_welcome
trigger_type       event
cron_id            null
event_action_type  enrolled
event_target_type  null
event_target_id    null
offset_days        1
offset_time        09:00:00
webhook_url        null
dedupe_window_minutes  null

Target-scoped example — "send the forum prompt only when a learner completes this one module" (event_target_id pins the match to a single module):

id                 sch_03_day1_done
nudge_id           ndg_module_done
trigger_type       event
cron_id            null
event_action_type  completed_module
event_target_type  module
event_target_id    mod_01_day1
offset_days        null
offset_time        null
webhook_url        null
dedupe_window_minutes  1440

NUDGE_CRON

A named, reusable cron expression with its YAML config alongside. Many nudge schedules can share one cron (e.g. "every Monday 9am") without duplicating the expression.

id               cron_weekday_9am
title            Weekdays at 9am
description      Mon–Fri at 09:00 UTC
cron_expression  0 9 * * 1-5
yaml_text        schedule:\n  timezone: UTC\n  expression: "0 9 * * 1-5"

NUDGE_DELIVERY_LOG

Append-only audit of every nudge sent — and the enforcement point for idempotent delivery. Records who received it, via which channel (email, sms, push, slack, in_app), the status (queued, sent, failed, bounced), and the raw provider response JSONB.

Three nullable provenance FKs capture what caused the send (any combination may be set depending on the path):

  • source_action_idUSER_ACTION.id — the action that triggered an event/webhook send.
  • nudge_schedule_idNUDGE_SCHEDULE.id — the schedule/trigger rule that produced it.
  • recipe_step_idRECIPE_STEP.id — the recipe step that produced it, when recipe-driven.

recipe_id is nullable as before: set when the send was driven by a recipe, null for a direct API call against a trigger_type=webhook schedule.

Deduplication lives in two columns:

  • idempotency_keynot null, a deterministic key the application computes (see below) that means "this exact send should happen only once." A unique index makes a second insert a no-op.
  • dedupe_bucket — an optional time bucket (e.g. 2026-05-28, 2026-W22, 2026-05-28T14) used for windowed dedupe; typically folded into the idempotency_key so a per-window send collapses to one row.
CREATE UNIQUE INDEX nudge_delivery_log_idempotency_key_unique
  ON nudge_delivery_log (idempotency_key);
id                 dlv_2026_04_03_001
nudge_id           ndg_01_three_things
user_id            usr_42_maya
enrollment_id      enr_01_april_maya
recipe_id          rcp_01_gratitude_30d
source_action_id   null
nudge_schedule_id  sch_01_three_things_daily
recipe_step_id     step_01_day1
channel            email
status             sent
idempotency_key    nudge_delivery:usr_42_maya:ndg_01_three_things:weekly:2026-W14:email
dedupe_bucket      2026-W14
response           {"provider": "postmark", "message_id": "pm_abc123"}
sent_at            2026-04-03T09:00:00Z

Generating idempotency keys

Idempotency keys are built by the application, not generated randomly by Postgres. The application derives a deterministic key from the business meaning of the send, so that the same logical send always produces the same key — and the unique index above collapses retries, duplicate webhook deliveries, and re-run worker jobs into a single row. Postgres only enforces uniqueness; it never decides what "the same send" means.

Patterns by trigger path:

# event-triggered — keyed on the action that caused it
nudge_delivery:{user_id}:{nudge_id}:{source_action_id}:{channel}

# cron-triggered weekly — keyed on the time bucket
nudge_delivery:{user_id}:{nudge_id}:weekly:{week_bucket}:{channel}

# recipe step — keyed on step + dedupe bucket
nudge_delivery:{user_id}:{recipe_id}:{recipe_step_id}:{dedupe_bucket}:{channel}

# direct API send — keyed on the caller's request id
nudge_delivery:direct:{request_id}:{user_id}:{nudge_id}:{channel}

Idempotent inserts

Workers, webhooks, and retries all insert the same way — let the unique index absorb duplicates:

INSERT INTO nudge_delivery_log (...)
VALUES (...)
ON CONFLICT (idempotency_key) DO NOTHING;

If the same job runs twice, the second insert is silently ignored, so a delivery is logged (and dispatched) exactly once. This is what makes the whole pipeline safe to retry.

How the three dedupe mechanisms divide the work

Each layer owns one concern, and they stay separate:

  • USER_ACTION (source_system / source_event_id / idempotency_key) prevents the same source event from being recorded twice.
  • NUDGE_DELIVERY_LOG.idempotency_key prevents the same send from happening twice.
  • NUDGE_SCHEDULE.dedupe_window_minutes defines how often a schedule is allowed to send again for the same user.

Keeping event ingestion, send logging, and schedule policy in three places means a fix or change to one never has to disturb the others.