Přeskočit na hlavní obsah

Campaigns

A campaign wires (segment + template + provider) into a sendable artifact. Three types: broadcast (one-off send), triggered (re-fired by event), journey (multi-step — see Journeys).

Lifecycle

draft → scheduled → sending → sent
draft → cancelled
sending → cancelled (drains in-flight; no new fan-out)

Send fan-out

POST /v1/campaigns/:id/send walks the segment in batches of 1000 and:

  1. Filters — excludes self-excluded players (capability_mock), players exceeding frequency caps, players in quiet hours.
  2. Variant assigns — deterministic FNV-1a hash of (campaign_id, player_id) → CDF bucket lookup against campaign_variants.
  3. Bulk inserts message_sends rows + enqueues BullMQ jobs.

Skipped rows land with status=failed + error_code=SKIPPED_*. The campaign stats panel shows them as "Suppressed" rather than provider failures.

A/B variants (Sprint 17)

Each campaign has N variants (table campaign_variants):

[
{ "name": "Variant A", "label": "A", "template_id": "...", "weight": 50, "sort_order": 0 },
{ "name": "Variant B", "label": "B", "template_id": "...", "weight": 50, "sort_order": 1 }
]

The migration backfilled a default variant for every existing campaign (referencing the campaign's own template_id), so the code path is uniform.

Operators can shift traffic mid-campaign by editing weights — only new sends respect the new split; already-enqueued message_sends keep their assigned variant_id.

Conversion goals (Sprint 17)

{
"goal_event": "deposit_confirmed",
"goal_attribution_days": 7,
"goal_value_property": "amount_base"
}

GET /v1/campaigns/:id/conversion-stats joins message_sends (PG) with raw_events (CH) — for each player who received a message, the earliest goal_event in [sent_at, sent_at + window) counts as a conversion.

Stats include per-variant recipients, conversions, conversion_rate, optional conversion_value sum + a winner pick (highest rate, min sample 30).

Frequency capping

tenant.config.messaging.frequency_caps:

{
"email": { "per_day": 1, "per_week": 5 },
"sms": { "per_day": 1, "per_week": 3 },
"push": { "per_day": 3, "per_week": 14 }
}

Cap query joins message_sends count per channel within rolling window. Suppressed rows (SKIPPED_*) don't count toward the cap.

Quiet hours

tenant.config.messaging.quiet_hours + per-player players.timezone (IANA name). Quiet-window check uses Intl.DateTimeFormat for DST-aware local-clock conversion. Players in window get error_code=SKIPPED_QUIET_HOURS.