From eb9e71209db041ed7b686e5c9f15e64188deeb33 Mon Sep 17 00:00:00 2001 From: Steve Hynding Date: Thu, 9 Jul 2026 12:17:43 -0700 Subject: [PATCH 01/12] =?UTF-8?q?docs:=20sprint=209=20design=20spec=20?= =?UTF-8?q?=E2=80=94=20campaign=20lifecycle=20(retroactive=20granting,=20r?= =?UTF-8?q?ecurring=20events)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- ...7-09-sprint-9-campaign-lifecycle-design.md | 237 ++++++++++++++++++ 1 file changed, 237 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-09-sprint-9-campaign-lifecycle-design.md diff --git a/docs/superpowers/specs/2026-07-09-sprint-9-campaign-lifecycle-design.md b/docs/superpowers/specs/2026-07-09-sprint-9-campaign-lifecycle-design.md new file mode 100644 index 0000000..1ada4f9 --- /dev/null +++ b/docs/superpowers/specs/2026-07-09-sprint-9-campaign-lifecycle-design.md @@ -0,0 +1,237 @@ +# Sprint 9 Design: Campaign Lifecycle — Retroactive Granting & Recurring Timed Events + +Approved via brainstorming session 2026-07-09. Rounds out the campaign engine with +the two remaining time-shaped v1.x features: definitions can now reach *backward* +(retroactive achievement granting replays the stored event log) and *forward* +(timed events recur on a schedule). Both live in the same evaluation/scheduler +internals, so one sprint amortizes the context. + +Roadmap lineage: "Retroactive achievement granting (evaluate new definitions +against stored events)" and "Recurring timed events (recurrence rule on the +reserved field)" (v1.x, design doc §7). + +## 1. Scope + +In scope: + +- `recurrence: 'none' | 'daily' | 'weekly' | 'monthly'` + `recurrenceEndsAt` on + timed events (contracts, core, cms, adapter-strapi) +- Pure occurrence-window math in core; occurrence-aware `timedEventState`, + `activeMultiplier`, `activeEventIds` +- Per-occurrence webhook transitions: `occurrence_key` on + `timed_event_notifications` (migration 0008), widened claim unique index, + additive `occurrence: { startsAt, endsAt }` webhook payload field +- Stats participation windows enumerated per occurrence within the query range +- `POST /v1/achievements/:id/backfill` (sk-only): transactional, idempotent + retroactive granting with summary response +- SDK `backfillAchievement(id)`; additive live-events fields (`recurrence`, + `nextOccurrenceStartsAt`); demo backfill button; seed's second (weekly + recurring) demo event; OpenAPI/READMEs/changeset + +Out of scope (explicitly): RRULE/cron recurrence, per-occurrence overrides +(skip/reschedule one occurrence), dry-run backfill, per-user backfill webhooks, +materialized occurrence storage, backlog issues #5/#15/#18/#20/#21, remaining +v1.x items (config-as-code CLI, React Native SDK). + +## 2. Decisions and rationale + +| Decision | Choice | +|---|---| +| Theme | Campaign lifecycle — both features touch the evaluation/scheduler internals | +| Backfill trigger | Explicit sk endpoint — operator-initiated, auditable, config plane stays read-only (no auto-detection, no CMS write-path into the runtime) | +| Backfill awards | Progress + unlocks + unlock `pointsValue` bonuses; NO per-user webhooks (summary response instead). Two users with identical histories get identical wallets regardless of when the definition shipped | +| Recurrence model | Simple interval enum, occurrence keeps the original duration, optional `recurrenceEndsAt` — zero new dependencies; RRULE would force materialization | +| Recurrence architecture | Virtual occurrences: pure arithmetic in core, no new tables, no materializer job. Any instant maps to at most one occurrence deterministically | +| Occurrence discriminator | `occurrenceKey` = the occurrence's `startsAt` ISO string; `''` for non-recurring events and all pre-existing rows (zero behavior change) | +| Sprint purity | Pure lifecycle sprint (~9 tasks); the 5-issue backlog waits for a Sprint-6-style hardening sprint | + +## 3. Architecture + +### 3.1 Occurrence math (core, pure) + +`TimedEventDefinition` gains `recurrence` (default `'none'`) and +`recurrenceEndsAt: Date | null` (null = forever). `startsAt`/`endsAt` define +occurrence 0's window; every occurrence keeps that duration. + +New pure function: + +``` +occurrenceWindow(event, now): { index, startsAt, endsAt, key } | null +``` + +Returns the occurrence whose window contains `now`, else the next upcoming +occurrence, else null (past `recurrenceEndsAt`, or a non-recurring event that +ended). `key` is the occurrence `startsAt` ISO string (`''` when +`recurrence === 'none'`). Daily/weekly are fixed-millisecond arithmetic +(86_400_000 / 604_800_000 ms); monthly is UTC calendar-month arithmetic with +day-of-month clamping (Jan 31 + 1mo → Feb 28/29 — the Sprint 7 streak-math +precedent; no tz libraries). An occurrence whose `startsAt` is not strictly +before `recurrenceEndsAt` does not exist. + +Precondition (documented on the function, enforced in cms validation §3.4): +occurrence duration ≤ interval, so windows never self-overlap. + +`timedEventState(event, now)` becomes occurrence-aware: `draft` when disabled; +`live`/`ending_soon` inside the current occurrence window (ending-soon measured +against the occurrence's `endsAt`); `scheduled` before the first occurrence AND +between occurrences; `ended` when `occurrenceWindow` returns null. +`activeMultiplier`/`activeEventIds` delegate unchanged in signature — multipliers +apply during every occurrence automatically. + +### 3.2 Per-occurrence webhooks (migration 0008) + +`timed_event_notifications` gains `occurrence_key text NOT NULL DEFAULT ''`; the +unique index widens to `(project_id, event_id, occurrence_key, transition)`. +Pre-existing rows and non-recurring events keep `''` — no data backfill, no +behavior change. Each occurrence of a recurring event gets fresh claims under its +key, so `live`/`ending_soon`/`ended` fire per occurrence through the existing +claim → deliver → redeliver → dead-letter pipeline, which is otherwise untouched. + +`WebhookDeliveryStore` signatures (`claimTransition`, `markDelivered`, +`findStaleClaims`, `incrementAttempts`, `findExhaustedClaims`) gain +`occurrenceKey` (known-break chain, resolved within the sprint). Webhook payloads +for recurring events gain an additive `occurrence: { startsAt, endsAt }` field; +`messageId`/HMAC contract unchanged. + +Scheduler scan structure is unchanged: each tick asks core for the current state; +a new occurrence beginning produces transitions under a new claim key. Accepted +edge (documented, same at-least-once posture as today): if the scheduler is down +across an entire occurrence and past the scan grace, that occurrence's +transitions are dropped. + +### 3.3 Stats + +`GET /v1/stats` participation windows for a recurring event enumerate the +occurrence windows intersecting the from/to range (pure math in the route), +aggregated under the one event id. Guard: at most 400 windows enumerated per +event (a year of dailies); beyond that the enumeration clamps to the most recent +400 within range, with the clamp documented in the OpenAPI description. + +### 3.4 Retroactive backfill + +**Endpoint:** `POST /v1/achievements/:id/backfill` — sk-only (`keyType !== +'secret'` → 403), no request body; unknown achievement id in config → 404 +`not_found`. Synchronous in-request at MVP scale; the queue seam is documented +(same posture as live evaluation). + +**Operation** (`BackfillStore.backfillAchievement(scope, def)` in adapter-db, one +transaction): + +1. `pg_advisory_xact_lock(hashtext('{projectId}:{environment}'), + hashtext('backfill:' + achievementId))` — serializes concurrent backfills of + the same achievement (Sprint 8 lock idiom). +2. One aggregate: `SELECT user_id, COUNT(*) FROM events WHERE scope AND type = + def.eventType GROUP BY user_id`. +3. Per user: progress upserts to `GREATEST(current, LEAST(count, target))` — + backfill only ever raises progress (live progress may exceed the raw count + because multipliers applied at ingest); unlock inserted + `onConflictDoNothing` when `count >= target`; the `pointsValue` bonus ledger + row (`source: 'unlock'`, `sourceRef: achievementId` — identical to the live + path) is written ONLY when the unlock insert returned a row. +4. Backfilled `unlockedAt = now()` — the grant happens now, the qualification is + historical; backdating would corrupt time-ranged unlock stats. + +**Idempotence and races:** re-running is a no-op by construction (GREATEST + +unique unlock index + returning-gated bonus). A live ingest racing the backfill +resolves through the same unique indexes — whichever inserts the unlock awards +the bonus, the other awards nothing. + +**Response:** `{ usersEvaluated, progressRaised, unlocksGranted, pointsAwarded }` +— summary only; `pointsAwarded` is the TOTAL points credited (sum of bonus +deltas), the other three are row counts; no per-user payload; no webhooks +(decided §2). + +### 3.5 Config plane, SDK, demo + +- **cms:** timed-event schema gains `recurrence` (enumeration, default `none`, + required) and `recurrenceEndsAt` (datetime, nullable). Lifecycle validation: + when recurring, `endsAt - startsAt` ≤ interval length (monthly validates + against 28 days, the shortest month); `recurrenceEndsAt > startsAt` when set. + Config-plane timed-event responses carry both fields. +- **adapter-strapi:** timed-event schemas gain the two fields with defaults + (`recurrence` defaults `'none'`, `recurrenceEndsAt` nullable-defaulted) so + pre-existing definitions parse unchanged. +- **Live events** (`GET /v1/events/live`): for recurring events the existing + `startsAt`/`endsAt` fields report the CURRENT (or next) occurrence's window — + existing `EventCountdown` widgets work with zero changes. Additive fields: + `recurrence` and `nextOccurrenceStartsAt` (ISO or null) — the start of the + occurrence AFTER the one reported in `startsAt`/`endsAt`, null when no further + occurrence exists. +- **SDK:** `backfillAchievement(achievementId)` — secretKey posture + (redeemCoupon precedent). `getLiveEvents` parses the widened (additive) shape. + No widget changes. +- **Demo:** stats page gains a backfill form (achievement id input + button, + server action, renders the summary JSON) next to the coupon check form. +- **Seed:** adds a second, weekly-recurring demo timed event alongside the + existing one-shot event. + +## 4. Data flow + +Recurrence: marketer sets `recurrence: 'weekly'` in Strapi → TTL cache → core +computes the active window per request/tick → multiplier applies inside every +occurrence → scheduler fires per-occurrence webhooks under fresh claim keys → +live feed reports the current occurrence window → countdown widgets just work. + +Backfill: operator ships a new achievement in Strapi → calls +`POST /v1/achievements/:id/backfill` with the sk → one SQL aggregate over the +event log → transactional grants (progress raised, unlocks inserted, bonuses +gated on the insert) → summary response → wallets/leaderboards reflect the +retroactive bonuses immediately. + +## 5. Error handling + +- Backfill: 403 on pk; 404 unknown achievement id; config-plane failure → + fail-closed (established config-unavailable path, never backfill against + unknown config). Response is the summary or the error envelope — no partial + writes (single transaction). +- Recurrence: malformed/unknown `recurrence` value from the CMS fails the + adapter-strapi schema → stale-on-error (issue-#4 posture). `occurrenceWindow` + never throws on valid definitions; the duration≤interval precondition is + enforced at config write time. +- Scheduler: per-occurrence claims inherit all existing failure semantics + (redelivery sweep, exhausted-claim dead-lettering, TTL cleanup). + +## 6. Testing + +- **core:** exhaustive occurrence-math suite — window containment at boundary + instants (start inclusive, end exclusive matching existing state semantics), + between-occurrence `scheduled`, `recurrenceEndsAt` cutoff (occurrence at the + cutoff does not exist), monthly day-clamping incl. leap Feb, duration=interval + edge (back-to-back windows never overlap), `''` key for non-recurring, + occurrence-aware state/multiplier delegation. +- **adapter-db (Testcontainers):** backfill — true retroactivity (events stored + BEFORE the definition exists → grants); idempotent re-run (zero deltas); + GREATEST never lowers live progress; bonus awarded exactly once and only with + the unlock insert; live-ingest race (concurrent backfill + ingest → one bonus); + cross-tenant isolation. Webhook claims: same (event, transition) claimable + under two occurrence keys; `''` back-compat. +- **api:** scheduler occurrence rollover with fake clocks (occurrence N ended + + occurrence N+1 live under fresh keys); backfill route guards (403/404) and + summary mapping; live-events recurring shape; stats occurrence-window + enumeration incl. the 400-window clamp. +- **adapter-strapi:** recurrence fields parsed/defaulted; pre-existing + definitions (no recurrence field) still parse. +- **sdk:** backfillAchievement sk guard + path + parse; live-events additive + parse. +- **e2e (compose):** recurring event appears in the live feed with `recurrence` + + current-occurrence window and the countdown renders; backfill endpoint on an + already-granted achievement returns an idempotent zero-grant summary. (True + retroactivity is proven at the adapter/api layer where clocks and definitions + are controllable; the DoD adds a hand-verified live backfill of a definition + created mid-flight via the admin bootstrap script.) + +## 7. Definition of done + +- Full turbo suite green; compose e2e green from a fresh seed +- Hand-verified live: backfill of an achievement created mid-flight grants + retroactively (bootstrap-script-created definition, sk curl, summary + wallet + checked); recurring demo event's live feed window advances across an + occurrence boundary (short test occurrence) +- OpenAPI covers the backfill endpoint and the widened live-events shape; README + documents recurrence semantics (per-occurrence webhooks, multiplier-in-every- + occurrence, scheduler-downtime edge) and the backfill operator flow incl. the + points-award decision; changeset per house style +- PR notes call out: `WebhookDeliveryStore` signature widening (occurrenceKey), + additive webhook `occurrence` payload field, additive live-events fields, + migration 0008 (additive, no data backfill), backfill's + leaderboard/wallet-moving semantics From 373a7cc2a2dc4308554fb2faa84c0a72adccd785 Mon Sep 17 00:00:00 2001 From: Steve Hynding Date: Thu, 9 Jul 2026 12:28:51 -0700 Subject: [PATCH 02/12] =?UTF-8?q?docs:=20sprint=209=20implementation=20pla?= =?UTF-8?q?n=20=E2=80=94=20campaign=20lifecycle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../2026-07-09-sprint-9-campaign-lifecycle.md | 213 ++++++++++++++++++ 1 file changed, 213 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-09-sprint-9-campaign-lifecycle.md diff --git a/docs/superpowers/plans/2026-07-09-sprint-9-campaign-lifecycle.md b/docs/superpowers/plans/2026-07-09-sprint-9-campaign-lifecycle.md new file mode 100644 index 0000000..73cfab0 --- /dev/null +++ b/docs/superpowers/plans/2026-07-09-sprint-9-campaign-lifecycle.md @@ -0,0 +1,213 @@ +# Promocean Sprint 9: Campaign Lifecycle — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Definitions reach backward and forward in time: `POST /v1/achievements/:id/backfill` replays the stored event log against a definition (idempotent, bonus-awarding), and timed events recur (`daily|weekly|monthly`) as virtual occurrences with per-occurrence webhooks — no new tables beyond one additive column. + +**Architecture:** Pure occurrence-window arithmetic in `core` (any instant maps to at most one occurrence deterministically); the scheduler, live feed, multiplier, and stats all ask core "which window?" instead of reading `startsAt`/`endsAt` directly. Runtime state that must distinguish occurrences (webhook claims) gains an `occurrence_key` (`''` for non-recurring — zero behavior change for existing rows). Backfill is one adapter-db transaction: SQL aggregate over `events`, GREATEST-only progress raises, returning-gated unlock bonuses — the exact live-path idioms, so wallets match. Layering as always: pure calc in core, persistence in adapter-db, config in cms/adapter-strapi, routes/scheduler in apps/api, then SDK → demo/e2e. + +**Spec:** `docs/superpowers/specs/2026-07-09-sprint-9-campaign-lifecycle-design.md`. Branch `sprint-9-campaign-lifecycle` off main (PR #22 merge). + +## Global Constraints + +(All prior global constraints bind: error envelope, zod contracts single source of truth, TDD per task, per-package gates green before commit, known-break pattern recorded on port widening, compose-stack e2e in CI. The api package's pnpm filter name is `api`.) + +Sprint-9 additions (values verbatim from the spec): +- `recurrence ∈ 'none' | 'daily' | 'weekly' | 'monthly'` (default `'none'`), `recurrenceEndsAt: Date | null` (null = forever). `startsAt`/`endsAt` define occurrence 0; every occurrence keeps that duration. Daily = 86_400_000 ms, weekly = 604_800_000 ms, monthly = UTC calendar-month stepping with day-of-month clamping (Jan 31 + 1mo → Feb 28/29). Recurrence is UTC-instant arithmetic — occurrences drift against local wall clocks across DST; document, don't compensate. +- An occurrence exists iff its `startsAt` is strictly before `recurrenceEndsAt` (when set). Duration ≤ interval (monthly validates against 28 days) — enforced in cms lifecycles; core documents the precondition. +- `occurrenceKey` = the occurrence's `startsAt` ISO string (`.toISOString()`); `''` for `recurrence === 'none'` and for all pre-existing claim rows. Window containment is start-inclusive, end-exclusive (`now < startsAt` → not started; `now >= endsAt` → over) — identical to existing `timedEventState` semantics. +- Webhook payload: `data.startsAt`/`data.endsAt` stay the DEFINITION's values (unchanged); recurring transitions add `data.occurrence: { startsAt, endsAt }` (additive). `messageId`/HMAC contract unchanged. +- Backfill: sk-only; awards progress (GREATEST — only ever raises), unlocks (`onConflictDoNothing`), and `pointsValue` bonuses ONLY when the unlock insert returned a row (`source: 'unlock'`, `sourceRef: achievementId` — byte-identical to the live path); backfilled `unlockedAt = now()`; NO webhooks; single transaction under `pg_advisory_xact_lock(hashtext('{projectId}:{environment}'), hashtext('backfill:' + achievementId))`. Re-running is a no-op. Response `{ usersEvaluated, progressRaised, unlocksGranted, pointsAwarded }` — `pointsAwarded` is the SUM of bonus deltas; the other three are row counts. +- Stats: a recurring event's participation windows are its occurrence windows intersecting the query range, capped at the most recent 400 within range (clamp documented in OpenAPI); a user active in several occurrences counts once per event. +- Scheduler-downtime edge (accepted, documented): an entire occurrence missed while the scheduler is down past scan grace drops that occurrence's transitions — the existing at-least-once posture. + +--- + +### Task 1: contracts — recurrence fields + backfill response + +**Files:** Modify `packages/contracts/src/timed-events.ts`, `src/achievements.ts`, `src/index.ts` (export additions); test append `packages/contracts/test/contracts.test.ts`. + +**Interfaces — produces:** +```ts +// timed-events.ts +export const recurrenceSchema = z.enum(['none', 'daily', 'weekly', 'monthly']) +export type Recurrence = z.infer +// liveTimedEventSchema gains (additive-with-defaults so old-server responses still parse): +// recurrence: recurrenceSchema.default('none'), +// nextOccurrenceStartsAt: z.iso.datetime().nullable().default(null), +// nextOccurrenceStartsAt = start of the occurrence AFTER the one reported in startsAt/endsAt; null when none. + +// achievements.ts +export const backfillResponseSchema = z.object({ + usersEvaluated: z.number().int().min(0), + progressRaised: z.number().int().min(0), + unlocksGranted: z.number().int().min(0), + pointsAwarded: z.number().int().min(0), +}) +export type BackfillResponse = z.infer +``` +Tests (RED first): recurrence enum accepts the four values, rejects others; live event WITHOUT the two new fields still parses (defaults applied — the back-compat property, assert the parsed values are `'none'`/`null`); live event with them round-trips; backfill response round-trips, negative counts rejected. Additive only — no known break. Commit: `feat(contracts): timed-event recurrence fields and backfill response` + +--- + +### Task 2: core — occurrence math, occurrence-aware state, port widenings + +**Files:** Modify `packages/core/src/types.ts`, `src/timed-events.ts`, `src/ports.ts`, `src/index.ts`; tests `packages/core/test/occurrences.test.ts` + adjust `test/timed-events.test.ts` fixtures (existing `TimedEventDefinition` literals gain the two new required fields). + +**Interfaces — produces:** +```ts +// types.ts +export type Recurrence = 'none' | 'daily' | 'weekly' | 'monthly' +// TimedEventDefinition gains: recurrence: Recurrence; recurrenceEndsAt: Date | null + +// timed-events.ts +export interface OccurrenceWindow { index: number; startsAt: Date; endsAt: Date; key: string } +export function occurrenceWindow(event: TimedEventDefinition, now: Date): OccurrenceWindow | null +// The occurrence containing `now`, else the NEXT upcoming one, else null (no current-or-future +// occurrence: a non-recurring event past endsAt, or recurrence past recurrenceEndsAt). +// This is the DISPLAY/multiplier view. +export function transitionOccurrence(event: TimedEventDefinition, now: Date): OccurrenceWindow | null +// The latest EXISTING occurrence with startsAt <= now, else null (nothing started yet). +// This is the SCHEDULER view: between occurrences it returns the just-elapsed occurrence so its +// 'ended' transition can fire; occurrenceWindow would already be pointing at the next one. +export function occurrenceFromKey(event: TimedEventDefinition, key: string): OccurrenceWindow | null +// '' -> the definition's own window (index 0). Otherwise parse the ISO key, validate it lands +// exactly on an existing occurrence start, derive the window. null on garbage/misaligned keys. +// Used by the redelivery sweep to rebuild messages for stale per-occurrence claims. +export function occurrenceWindowsInRange(event: TimedEventDefinition, from: Date, to: Date, cap?: number): Array<{ startsAt: Date; endsAt: Date }> +// Occurrence windows intersecting [from, to]. Takes CONCRETE bounds — core stays clock-free; +// the caller defaults nulls (stats route: from ?? event.startsAt, to ?? new Date()). +// cap (default 400): keep the most RECENT `cap` windows in range, dropping the oldest. +// key convention: recurrence 'none' -> key '', single window; index N startsAt = startsAt + N·interval +// (monthly: UTC month stepping with day clamping); every window's endsAt = its startsAt + (event.endsAt - event.startsAt). + +// timedEventState / activeMultiplier / activeEventIds: signatures UNCHANGED, now occurrence-aware: +// disabled -> 'draft'; occurrenceWindow null -> 'ended'; now < window.startsAt -> 'scheduled' +// (covers both before-first and between-occurrences); inside -> 'ending_soon' when +// msLeft <= endingSoonMinutes·60_000 else 'live'. + +// ports.ts — WebhookDeliveryStore, occurrenceKey inserted after eventId in every signature: +// claimTransition(projectId, eventId, occurrenceKey: string, transition): Promise +// markDelivered(projectId, eventId, occurrenceKey, transition): Promise +// findStaleClaims(olderThan, maxAttempts): Promise> +// incrementAttempts(projectId, eventId, occurrenceKey, transition): Promise +// findExhaustedClaims(minAttempts): Promise> +// (recordDeadLetter / deleteDeadLettersBefore unchanged) +export interface BackfillStore { + backfillAchievement(scope: Scope, def: AchievementDefinition): Promise<{ + usersEvaluated: number; progressRaised: number; unlocksGranted: number; pointsAwarded: number + }> +} +``` +Tests: occurrenceWindow — non-recurring before/inside/after (window, window, null); daily/weekly containment at exact start (inclusive) and exact end (exclusive → next); between-occurrences returns next; recurrenceEndsAt cutoff (occurrence starting AT the cutoff does not exist; one starting 1ms before does); monthly stepping incl. Jan 31 → Feb 28 clamp and leap-year Feb 29; duration=interval back-to-back (end of N = start of N+1, containment unambiguous by end-exclusivity); key is '' for none, ISO of occurrence start otherwise. transitionOccurrence — nothing-started null; inside = current; between = previous; after final = final. occurrenceFromKey — '' → definition window; valid ISO on-grid → correct index; off-grid ISO / garbage → null; key beyond recurrenceEndsAt → null. occurrenceWindowsInRange — range spanning 3 occurrences → 3 windows; partial overlap at both edges included; cap keeps most recent (5 dailies, cap 3 → the latest 3). timedEventState — occurrence-aware matrix incl. between-occurrences 'scheduled' and ending_soon inside a later occurrence; activeMultiplier active inside occurrence 2 of a recurring event, inactive between occurrences. + +**Known break (record, don't patch):** `TimedEventDefinition` widening + `WebhookDeliveryStore` signature changes + new `BackfillStore` break adapter-db, adapter-strapi, apps/api until Tasks 3/5/6-7. Core gates green. Commit: `feat(core): occurrence windows, occurrence-aware state, backfill and per-occurrence webhook ports` + +--- + +### Task 3: adapter-db — migration 0008, per-occurrence claims, PgBackfillStore, stats multi-window + +**Files:** Modify `packages/adapter-db/src/schema.ts` (timedEventNotifications), `src/stores.ts` (PgWebhookDeliveryStore, PgStatsStore aggregation, new PgBackfillStore), `src/index.ts`; create migration `packages/adapter-db/migrations/0008_*` (drizzle-kit generate); tests `packages/adapter-db/test/backfill.test.ts` + extend `test/webhooks.test.ts` (or wherever delivery-store claims are covered) + extend the stats test file. + +**Schema:** `timedEventNotifications` gains `occurrenceKey: text('occurrence_key').notNull().default('')`; unique index `event_notif_uq` widens to `.on(t.projectId, t.eventId, t.occurrenceKey, t.transition)`. Migration is additive (column with default + index swap) — no data backfill; existing rows keep `''`. + +**Behavior:** +- `PgWebhookDeliveryStore`: all five widened methods thread `occurrenceKey` through values/where clauses exactly as `transition` is threaded today; `findStaleClaims`/`findExhaustedClaims` select and return it. +- `PgStatsStore.getStats`: the `timedEventWindows` param may now contain MULTIPLE windows per `eventId`. Participants per event = COUNT(DISTINCT user_id) over events falling in ANY of that event's windows (union the window predicates per eventId with OR before counting) — a user active in two occurrences counts once. +- `PgBackfillStore implements BackfillStore` — one `db.transaction`: (1) `pg_advisory_xact_lock(hashtext(${projectId + ':' + environment}), hashtext(${'backfill:' + def.id}))`; (2) aggregate `SELECT user_id, COUNT(*)::int AS cnt FROM runtime.events WHERE scope AND type = ${def.eventType} GROUP BY user_id`; `usersEvaluated` = row count; empty → all-zero summary, no writes; (3) batch-SELECT existing progress rows for those users + achievement; compute per user `desired = LEAST(cnt, def.targetCount)`; for users where `desired > (existing ?? 0)`: upsert progress `INSERT ... ON CONFLICT DO UPDATE SET current = GREATEST(current, LEAST(${cnt}, ${target})), updated_at = now()` (the GREATEST in SQL keeps a concurrent live ingest race safe even though we pre-filtered in JS); `progressRaised` = number of such users; (4) for users with `cnt >= def.targetCount`: `unlockedAt = new Date()` computed once, insert unlock `onConflictDoNothing().returning(...)`; per returned row `unlocksGranted++` and, when `def.pointsValue > 0`, insert ledger row (`delta: def.pointsValue, source: 'unlock', sourceRef: def.id`) and `pointsAwarded += def.pointsValue`; (5) return the summary. + +Tests (Testcontainers): **true retroactivity** — insert events via PgIngestionStore for a type with NO matching increment (empty increments array — the definition "doesn't exist yet"), then backfill a definition for that type → progress raised, unlocks granted, bonuses in the ledger, wallet SUM reflects them; **idempotent re-run** → all-zero deltas, ledger unchanged; **GREATEST never lowers** — pre-existing progress 8 (live multiplier inflated) + only 3 stored events, target 10 → progress stays 8, `progressRaised` 0; **bonus gating** — user already unlocked live → backfill grants nothing, no second bonus; **live-ingest race** — concurrent `backfillAchievement` + `ingestEvent` whose increment crosses the same user's target → exactly one unlock row, exactly one bonus ledger row; **zero-event type** → all-zero summary; **cross-tenant isolation**; pointsValue 0 → unlocks granted, `pointsAwarded` 0, no ledger rows. Delivery store: same (project, event, transition) claimable under two different occurrenceKeys; `''` and ISO-key claims coexist; markDelivered/incrementAttempts hit only their key's row; stale/exhausted rows return their key. Stats: one eventId with two windows, a user active in both → participants 1; users in different windows both counted; single-window events unaffected. Workspace still red (adapter-strapi, api) — known break continues. Commit: `feat(adapter-db): per-occurrence webhook claims, retroactive backfill store, multi-window stats (migration 0008)` + +--- + +### Task 4: cms — recurrence fields, timed-event lifecycles, scan-feed fix, seed + +**Files:** Modify `apps/cms/src/api/timed-event/content-types/timed-event/schema.json`, create `apps/cms/src/api/timed-event/content-types/timed-event/lifecycles.ts`; modify config-plane controller `apps/cms/src/api/config-plane/controllers/config-plane.ts` (timedEvents + timedEventsAll mappers and the timedEventsAll scan filter), seed in `apps/cms/src/index.ts`; regenerate `contentTypes.d.ts`. + +**Schema:** REPLACE the reserved `"recurrence": { "type": "json" }` with `"recurrence": { "type": "enumeration", "enum": ["none", "daily", "weekly", "monthly"], "default": "none", "required": true }` (the json field was reserved-and-unused since Sprint 3 — no data migration; any pre-existing NULL maps to `'none'` in the controller). ADD `"recurrenceEndsAt": { "type": "datetime" }`. + +**Lifecycles (beforeCreate/beforeUpdate, merged-record pattern from the reward lifecycles INCLUDING the populated-relation lesson — though no relation is needed here, only scalars):** when `recurrence !== 'none'`: `endsAt - startsAt` ≤ interval length (daily 86_400_000, weekly 604_800_000, monthly 28 · 86_400_000) — reject with a message naming the limit; `recurrenceEndsAt`, when set, must be > `startsAt`. `endsAt > startsAt` (add if not already enforced). + +**Controller:** both `timedEvents` and `timedEventsAll` mappers gain `recurrence: r.recurrence ?? 'none'`, `recurrenceEndsAt: r.recurrenceEndsAt ?? null`. **Scan-feed fix (spec seam, load-bearing):** `timedEventsAll`'s `endedWithinMinutes` filter currently drops events whose `endsAt` predates the cutoff — a months-old weekly event's occurrence-0 `endsAt` is ancient, so the scheduler would never see it. Widen the filter: keep rows where (existing endsAt-within-cutoff condition) OR (`recurrence != 'none'` AND (`recurrenceEndsAt` IS NULL OR `recurrenceEndsAt` >= cutoff)). + +**Seed:** add a second demo timed event `Weekly Happy Hour` — `recurrence: 'weekly'`, a 2-hour window (startsAt = seed-time date at a fixed UTC hour, endsAt = +2h), `multiplier: 2`, `endingSoonMinutes: 30`, `recurrenceEndsAt: null`, enabled. Existing one-shot demo event untouched. + +Verification: live curl — timed-events config-plane responses carry both fields (defaulted for the pre-existing event); timedEventsAll includes an old-but-recurring event when `endedWithinMinutes` is small (create one dated last month via the bootstrap-script method from Task 4 of Sprint 8, documented in .superpowers/sdd/task-4-report.md); lifecycle rejections (26h daily, recurrenceEndsAt ≤ startsAt) and acceptance (2h weekly); fresh-DB seed carries the recurring event; second-boot idempotence; typecheck green. Commit: `feat(cms): timed-event recurrence fields, validation, recurring-aware scan feed, seed` + +--- + +### Task 5: adapter-strapi — recurrence field parsing + +**Files:** Modify `packages/adapter-strapi/src/schemas.ts` (timedEventFieldsSchema), `src/index.ts` (both timed-event mappers); test `packages/adapter-strapi/test/adapter.test.ts`. + +**Behavior:** `timedEventFieldsSchema` gains `recurrence: z.enum(['none','daily','weekly','monthly']).default('none')` and `recurrenceEndsAt: z.string().nullable().default(null)`; `getTimedEvents` and `getAllTimedEvents` mappers gain `recurrence: e.recurrence, recurrenceEndsAt: e.recurrenceEndsAt ? new Date(e.recurrenceEndsAt) : null` — this makes the package's `TimedEventDefinition` construction complete again (package goes green). Tests: recurring event parsed with Date-typed recurrenceEndsAt; response WITHOUT the fields (old cms) parses to `'none'`/`null` (the defaults — back-compat assertion); bad recurrence value → schema throws → stale-on-error path. apps/api still red until Tasks 6–7 — recorded. Commit: `feat(adapter-strapi): timed-event recurrence parsing` + +--- + +### Task 6: api — occurrence-aware scheduler, live feed, stats windows + +**Files:** Modify `apps/api/src/webhooks.ts` (scheduler tick, message builder, reachedTransitions), `src/routes/live-events.ts`, `src/routes/stats.ts`, `apps/api/test/fakes.ts` (delivery-store fake signatures + timed-event fixtures gain the new fields); tests extend `apps/api/test/webhooks.test.ts` (or the scheduler's test home), `test/live-events.test.ts`, `test/stats.test.ts` equivalents. + +**webhooks.ts:** +- Tick phase 1 becomes occurrence-centric: for each event, `const occ = transitionOccurrence(event, now); if (!occ) continue;` then compute reached transitions AGAINST the occurrence window — replace `reachedTransitions(timedEventState(event, now))` with a local `reachedTransitionsFor(occ, now, event.endingSoonMinutes)`: `now >= occ.endsAt` → `['live','ending_soon','ended']`; `occ.endsAt - now <= endingSoonMinutes·60_000` → `['live','ending_soon']`; `now >= occ.startsAt` → `['live']`; else `[]`. Skip when `!event.enabled` (preserve the current draft-fires-nothing behavior — `timedEventState` returned 'draft' before; keep an explicit enabled check now). Claims/markDelivered calls pass `occ.key`. +- `buildTransitionMessage(event, occ, transition, now)`: `data.startsAt`/`data.endsAt` remain the definition's; when `event.recurrence !== 'none'` add `data.occurrence: { startsAt: occ.startsAt.toISOString(), endsAt: occ.endsAt.toISOString() }`. +- Redelivery sweep: stale claims carry `occurrenceKey`; rebuild via `occurrenceFromKey(event, claim.occurrenceKey)` — null (misaligned key / definition changed) → dead-letter `` + markDelivered, the existing pattern; pass the key through incrementAttempts/markDelivered/claims. +- `WebhookDispatcher.deliverTransition` gains the `occurrenceKey` param, threaded to `markDelivered`. + +**live-events.ts:** compute `const w = occurrenceWindow(e, now)`; skip when null; `state` from `timedEventState(e, now)` (unchanged filter — scheduled/live/ending_soon); report `startsAt: w.startsAt.toISOString(), endsAt: w.endsAt.toISOString()`, seconds fields computed from `w`; additive `recurrence: e.recurrence` and `nextOccurrenceStartsAt: occurrenceWindow(e, w.endsAt)?.startsAt.toISOString() ?? null` — evaluating at `w.endsAt` yields the next occurrence because containment is end-exclusive; guard the self-return case for non-recurring (occurrenceWindow at endsAt returns null for 'none'). + +**stats.ts:** `const now = new Date(); const windows = timedEventDefs.flatMap((e) => occurrenceWindowsInRange(e, from ?? e.startsAt, to ?? now).map((w) => ({ eventId: e.id, startsAt: w.startsAt, endsAt: w.endsAt })))` — the 400-cap is inside the core function; note the clamp in the OpenAPI stats description (one sentence). + +Tests: scheduler with fake clocks — occurrence 1 runs (live/ending_soon/ended claimed under key K1), advance past occurrence 2 start → fresh live claim under K2 while K1 rows remain delivered; non-recurring event still claims under `''` (back-compat assertion on the fake's recorded keys); disabled recurring event fires nothing; redelivery rebuild for an ISO-keyed stale claim produces a message with the right `occurrence` payload; unresolvable key dead-letters. Live feed — recurring event between occurrences reports next window + scheduled + correct nextOccurrenceStartsAt; inside a window reports live + the occurrence's bounds; non-recurring unchanged shape with `recurrence: 'none'`, `nextOccurrenceStartsAt: null`. Stats — recurring event with two occurrences in range yields two windows for one eventId (assert the fake/store receives them). apps/api still red on the missing backfill wiring? NO — Task 6 leaves AppDeps untouched; api package goes green only after Task 7 adds BackfillStore wiring IF Task 7 introduces it. To keep Task 6 independently green: Task 6 does NOT reference BackfillStore anywhere; api compiles once delivery-store signatures align (this task). Record: api package green at end of Task 6. Commit: `feat(api): per-occurrence scheduler and webhooks, occurrence-aware live feed and stats windows` + +--- + +### Task 7: api — backfill endpoint + +**Files:** Create `apps/api/src/routes/achievements.ts`; modify `src/app.ts` (AppDeps gains `backfillStore: BackfillStore`; mount `app.route('/v1/achievements', achievementsRoute(deps))`), `src/index.ts` (wire `PgBackfillStore`), `src/openapi.ts` (one path; count 15 → 16; note the stats occurrence-windows clamp sentence here if Task 6 didn't add it), `test/fakes.ts` (fake backfill store with settable summary + recorded calls); tests `apps/api/test/backfill.test.ts`. + +**Route:** `POST /:id/backfill` — sk guard first (`auth.keyType !== 'secret'` → 403 forbidden, coupons.ts precedent); no body parsing; `const defs = await deps.configStore.getAchievements(scope.projectId)`; unknown id → 404 not_found; `const summary = await deps.backfillStore.backfillAchievement(scope, def)`; respond `summary satisfies BackfillResponse` (200). Config-plane failure → app-level onError 500 (fail closed, never backfill against unknown config — established posture). + +Tests: 403 on pk; 404 unknown id; happy path passes the resolved def to the store (capture args) and maps the summary verbatim; openapi asserts sixteen paths + the backfill entry documents 403/404. Workspace typecheck fully green again after this task. Commit: `feat(api): retroactive achievement backfill endpoint` + +--- + +### Task 8: sdk — backfill method + live-events widening + +**Files:** Modify `packages/sdk/src/index.ts`; test `packages/sdk/test/sdk.test.ts`. + +**Interfaces — produces:** +```ts +async backfillAchievement(achievementId: string): Promise +// requires the secretKey option (redeemCoupon posture + coined message template: +// 'backfillAchievement requires the secretKey option (server-side only).'); +// POST /v1/achievements/:id/backfill (encodeURIComponent on the id), useSecretKey: true, +// no body; parse backfillResponseSchema. +// getLiveEvents(): no signature change — liveEventsResponseSchema already carries the +// additive recurrence/nextOccurrenceStartsAt fields with defaults (Task 1). +``` +Tests: sk guard throws without secretKey; sends sk bearer + right path with an id needing encoding; parses the summary; getLiveEvents parses a recurring event carrying the new fields AND an old-shape event without them (defaults — one test each). Commit: `feat(sdk): achievement backfill and recurring live-event parsing` + +--- + +### Task 9: demo, e2e, docs — sprint DoD + +**Files:** Modify `apps/demo/app/stats/page.tsx` + create `apps/demo/app/stats/backfill-actions.ts` and `apps/demo/app/stats/backfill-form.tsx` (server action + client form, mirroring the coupon-check pair: achievement-id input, submit, render the summary JSON or the error envelope — sk stays server-side); create `apps/demo/e2e/campaign-lifecycle.spec.ts`; docs: root README (recurrence semantics — per-occurrence webhooks, multiplier in every occurrence, UTC-instant drift note, scheduler-downtime edge; backfill operator flow incl. the wallet/leaderboard-moving decision and idempotence), `packages/sdk/README.md` (backfillAchievement + sk posture; live-events new fields), changeset (minor: contracts/sdk; the widened WebhookDeliveryStore is core-internal — patch-note it). + +**e2e (`campaign-lifecycle.spec.ts`):** (1) live feed carries the seeded `Weekly Happy Hour` with `recurrence: 'weekly'`, a current-or-next occurrence window, and consistent `nextOccurrenceStartsAt` (assert it equals reported `startsAt` + 7 days when present; the EventCountdown demo section renders it); (2) backfill idempotence: fresh user tracks `lesson_completed` ×1 (unlocks the seeded target-1 achievement live), sk `POST /v1/achievements/:id/backfill` for that achievement → summary shows `unlocksGranted: 0, pointsAwarded: 0` with `usersEvaluated ≥ 1` (already granted live — proves endpoint + idempotence; TRUE retroactivity is covered in adapter-db/api tests where definitions are controllable); (3) the demo backfill form round-trips the same call and renders the summary. + +**DoD steps (in order):** `pnpm turbo run typecheck build test` fully green; fresh compose stack (`docker compose --profile stack down -v && build && up -d --wait`); `pnpm --filter demo e2e` — ALL specs green; hand-verification: (a) create a NEW achievement via the admin bootstrap script (Sprint 8 Task 4 method) for an event type the e2e user already has history on, sk-curl its backfill → summary shows real grants, wallet reflects the bonus (record transcript); (b) short recurring event (bootstrap-script created, 2-minute window, daily) — watch the scheduler fire `live`/`ended` for occurrence 0 under its ISO key in the DB (`SELECT * FROM runtime.timed_event_notifications`); stack down; push branch; PR next (CI runs on the PR event; checks read on the PR page — no gh CLI here). + +PR notes must state: `WebhookDeliveryStore` signature widening (occurrenceKey — internal port, patch-level for consumers); additive webhook `data.occurrence` field for recurring transitions (HMAC/messageId unchanged); additive live-events `recurrence`/`nextOccurrenceStartsAt`; migration 0008 additive (no data backfill, `''` default preserves existing claims); backfill moves wallets/leaderboards by design (bonus points for retroactive unlocks); the cms `recurrence` json→enumeration swap (reserved-unused since Sprint 3, no data risk); delivers the final two campaign-engine v1.x slices. Commit: `feat(demo): backfill operator form and recurring-event demo; docs — sprint 9 wrap` + +--- + +## Self-Review Notes + +- **Spec coverage:** §3.1 occurrence math ✓ (T2 exhaustive, T1 contracts enum); §3.2 per-occurrence webhooks ✓ (T3 migration/store, T6 scheduler/payload/redelivery incl. `occurrenceFromKey` rebuild path); §3.3 stats ✓ (T2 windowsInRange + cap, T3 distinct-across-windows aggregation, T6 route enumeration); §3.4 backfill ✓ (T2 port, T3 transaction, T7 route, T8 sdk); §3.5 config/SDK/demo/seed ✓ (T4, T5, T8, T9); §4 flows = T9 e2e + DoD hand-verification; §5 error handling distributed (fail-closed config T7, schema-throw→stale T5, claim pipeline untouched T3/T6); §6 testing mapped 1:1 (true-retroactivity + race in T3, rollover fake-clocks in T6); §7 DoD = T9. +- **Beyond-spec seams the plan adds (flagged for reviewers, both load-bearing):** (1) the `timedEventsAll` scan-feed `endedWithinMinutes` filter must exempt still-recurring events (T4) — without it the scheduler goes blind to any recurring event older than the scan window, and no spec section said so explicitly; (2) `transitionOccurrence` (current-or-last-started) is distinct from `occurrenceWindow` (current-or-next) — the spec's "scheduler asks for the current state" phrasing hides that `ended` transitions fire BETWEEN occurrences, when the display view already points at the next window. +- **Deviation from spec text:** the cms schema already had a reserved `"recurrence": { "type": "json" }` (Sprint 3); the spec said "gains recurrence" — the plan REPLACES the json field with the enumeration (reserved-unused, no data migration). Flagged in T4 and the PR notes. +- **Known-break chain:** T2 widens TimedEventDefinition + WebhookDeliveryStore + adds BackfillStore → adapter-db green at T3, adapter-strapi at T5, api at T6 (delivery signatures) with backfill wiring landing at T7 (T6 deliberately avoids any BackfillStore reference so the api package is green at T6's gate) — recorded. +- **Type consistency:** `OccurrenceWindow { index, startsAt, endsAt, key }` identical T2/T6; occurrenceKey param position (after eventId) identical T2 (port), T3 (impl), T6 (call sites incl. deliverTransition); `Recurrence` values identical T1 (contracts enum) / T2 (core type) / T4 (cms enum) / T5 (adapter schema); `BackfillResponse` field names identical T1/T2 (store return) /T3/T7/T8/T9 (`usersEvaluated, progressRaised, unlocksGranted, pointsAwarded`); `nextOccurrenceStartsAt` null-when-none semantics identical T1/T6/T8/T9. +- **Deliberate choices encoded:** `data.startsAt`/`endsAt` in webhook payloads stay definition-level (wire-stable) with the occurrence window additive; `occurrenceWindow(e, w.endsAt)` as the next-occurrence trick (end-exclusive containment makes it exact); backfill pre-filters raise-candidates in JS but keeps GREATEST in the SQL upsert (belt for the live-ingest race the advisory lock does not cover — ingestion never takes the backfill lock); e2e asserts idempotent-zero backfill while true retroactivity lives in Testcontainers (clock/definition control), with a mandated live hand-verified real backfill in the DoD. +- **Compression note:** as with Sprints 2–8, test code specified behaviorally; production interfaces, lock keys, intervals, cap values, and validation bounds are exact. From 844e1adb44453083f8116856c6987b6812f8e27d Mon Sep 17 00:00:00 2001 From: Steve Hynding Date: Thu, 9 Jul 2026 12:34:13 -0700 Subject: [PATCH 03/12] feat(contracts): timed-event recurrence fields and backfill response --- packages/contracts/src/achievements.ts | 8 +++ packages/contracts/src/timed-events.ts | 7 ++ packages/contracts/test/contracts.test.ts | 72 ++++++++++++++++++++ packages/contracts/test/timed-events.test.ts | 4 +- 4 files changed, 90 insertions(+), 1 deletion(-) diff --git a/packages/contracts/src/achievements.ts b/packages/contracts/src/achievements.ts index c2b3254..09a2107 100644 --- a/packages/contracts/src/achievements.ts +++ b/packages/contracts/src/achievements.ts @@ -15,3 +15,11 @@ export const userAchievementsResponseSchema = z.object({ achievements: z.array(achievementStatusSchema), }) export type UserAchievementsResponse = z.infer + +export const backfillResponseSchema = z.object({ + usersEvaluated: z.number().int().min(0), + progressRaised: z.number().int().min(0), + unlocksGranted: z.number().int().min(0), + pointsAwarded: z.number().int().min(0), +}) +export type BackfillResponse = z.infer diff --git a/packages/contracts/src/timed-events.ts b/packages/contracts/src/timed-events.ts index 7121bb8..d1c1b44 100644 --- a/packages/contracts/src/timed-events.ts +++ b/packages/contracts/src/timed-events.ts @@ -1,5 +1,8 @@ import { z } from 'zod' +export const recurrenceSchema = z.enum(['none', 'daily', 'weekly', 'monthly']) +export type Recurrence = z.infer + export const liveTimedEventSchema = z.object({ eventId: z.string(), name: z.string(), @@ -10,6 +13,10 @@ export const liveTimedEventSchema = z.object({ multiplier: z.number().int().min(1), secondsUntilStart: z.number().int().nullable(), secondsUntilEnd: z.number().int(), + // Additive-with-defaults: old-server responses without these fields still parse. + recurrence: recurrenceSchema.default('none'), + // Start of the occurrence AFTER the one reported in startsAt/endsAt; null when none. + nextOccurrenceStartsAt: z.iso.datetime().nullable().default(null), }) export type LiveTimedEvent = z.infer diff --git a/packages/contracts/test/contracts.test.ts b/packages/contracts/test/contracts.test.ts index 7f53109..aaede84 100644 --- a/packages/contracts/test/contracts.test.ts +++ b/packages/contracts/test/contracts.test.ts @@ -23,6 +23,9 @@ import { validateCouponResponseSchema, redeemCouponRequestSchema, redeemCouponResponseSchema, + recurrenceSchema, + liveTimedEventSchema, + backfillResponseSchema, } from '../src/index.js' describe('trackEventRequestSchema', () => { @@ -525,3 +528,72 @@ describe('redeemCouponResponseSchema', () => { expect(redeemCouponResponseSchema.safeParse({ redeemed: false, rewardSlug: 'x', redeemedAt: '2026-07-08T10:00:00.000Z' }).success).toBe(false) }) }) + +describe('recurrenceSchema', () => { + it('accepts the four recurrence values', () => { + for (const value of ['none', 'daily', 'weekly', 'monthly']) { + expect(recurrenceSchema.safeParse(value).success).toBe(true) + } + }) + it('rejects other values', () => { + for (const value of ['yearly', 'Daily', '', 'NONE']) { + expect(recurrenceSchema.safeParse(value).success).toBe(false) + } + }) +}) + +describe('liveTimedEventSchema recurrence fields', () => { + const baseEvent = { + eventId: 'e1', + name: 'Double Points Weekend', + description: null, + state: 'live' as const, + startsAt: '2026-07-08T00:00:00.000Z', + endsAt: '2026-07-09T00:00:00.000Z', + multiplier: 2, + secondsUntilStart: null, + secondsUntilEnd: 3600, + } + + it('parses an event WITHOUT the two new fields and applies back-compat defaults', () => { + const result = liveTimedEventSchema.safeParse(baseEvent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.recurrence).toBe('none') + expect(result.data.nextOccurrenceStartsAt).toBeNull() + } + }) + + it('round-trips an event with recurrence and nextOccurrenceStartsAt set', () => { + const payload = { + ...baseEvent, + recurrence: 'weekly' as const, + nextOccurrenceStartsAt: '2026-07-15T00:00:00.000Z', + } + expect(liveTimedEventSchema.parse(payload)).toEqual(payload) + }) +}) + +describe('backfillResponseSchema', () => { + it('round-trips a valid backfill response', () => { + const payload = { + usersEvaluated: 100, + progressRaised: 40, + unlocksGranted: 10, + pointsAwarded: 500, + } + expect(backfillResponseSchema.parse(payload)).toEqual(payload) + }) + it('rejects negative counts', () => { + const valid = { + usersEvaluated: 100, + progressRaised: 40, + unlocksGranted: 10, + pointsAwarded: 500, + } + for (const key of Object.keys(valid)) { + const payload = { ...valid, [key]: -1 } + expect(backfillResponseSchema.safeParse(payload).success).toBe(false) + } + }) +}) diff --git a/packages/contracts/test/timed-events.test.ts b/packages/contracts/test/timed-events.test.ts index ef26112..c4453ee 100644 --- a/packages/contracts/test/timed-events.test.ts +++ b/packages/contracts/test/timed-events.test.ts @@ -9,7 +9,9 @@ const event = { describe('timed event schemas', () => { it('round-trips a live events response', () => { - expect(liveEventsResponseSchema.parse({ events: [event] })).toEqual({ events: [event] }) + expect(liveEventsResponseSchema.parse({ events: [event] })).toEqual({ + events: [{ ...event, recurrence: 'none', nextOccurrenceStartsAt: null }], + }) }) it('rejects draft/ended states on the wire', () => { for (const state of ['draft', 'ended', 'nope']) From fed01c2606797c82efb9a6994b041f126dafd141 Mon Sep 17 00:00:00 2001 From: Steve Hynding Date: Thu, 9 Jul 2026 12:46:09 -0700 Subject: [PATCH 04/12] feat(core): occurrence windows, occurrence-aware state, backfill and per-occurrence webhook ports --- packages/core/src/ports.ts | 19 +- packages/core/src/timed-events.ts | 227 ++++++++++++++++- packages/core/src/types.ts | 6 + packages/core/test/occurrences.test.ts | 316 ++++++++++++++++++++++++ packages/core/test/timed-events.test.ts | 3 +- 5 files changed, 562 insertions(+), 9 deletions(-) create mode 100644 packages/core/test/occurrences.test.ts diff --git a/packages/core/src/ports.ts b/packages/core/src/ports.ts index 67d33ff..6143de0 100644 --- a/packages/core/src/ports.ts +++ b/packages/core/src/ports.ts @@ -1,6 +1,15 @@ import type { ClaimRejection } from './rewards.js' import type { AchievementDefinition, AuthContext, OfferDefinition, PointRules, RewardDefinition, Scope, TimedEventDefinition, TimedEventTransition, WebhookEndpointDefinition } from './types.js' +export interface BackfillStore { + backfillAchievement(scope: Scope, def: AchievementDefinition): Promise<{ + usersEvaluated: number + progressRaised: number + unlocksGranted: number + pointsAwarded: number + }> +} + export interface ConfigStore { getAchievements(projectId: string): Promise getOffers(projectId: string): Promise @@ -99,17 +108,17 @@ export interface StatsStore { } export interface WebhookDeliveryStore { - claimTransition(projectId: string, eventId: string, transition: TimedEventTransition): Promise + claimTransition(projectId: string, eventId: string, occurrenceKey: string, transition: TimedEventTransition): Promise recordDeadLetter(projectId: string, url: string, payload: string, error: string, at: Date): Promise /** Sets delivered_at = now() on the claim row. Idempotent: already-delivered is a no-op update. */ - markDelivered(projectId: string, eventId: string, transition: TimedEventTransition): Promise + markDelivered(projectId: string, eventId: string, occurrenceKey: string, transition: TimedEventTransition): Promise /** Rows where delivered_at IS NULL AND fired_at < olderThan AND attempts < maxAttempts. */ - findStaleClaims(olderThan: Date, maxAttempts: number): Promise> - incrementAttempts(projectId: string, eventId: string, transition: TimedEventTransition): Promise + findStaleClaims(olderThan: Date, maxAttempts: number): Promise> + incrementAttempts(projectId: string, eventId: string, occurrenceKey: string, transition: TimedEventTransition): Promise /** Rows where delivered_at IS NULL AND attempts >= minAttempts — claims findStaleClaims * excludes forever once they've exhausted their redelivery attempts. Callers dead-letter * and mark these delivered so the loop stops rather than leaving them orphaned. */ - findExhaustedClaims(minAttempts: number): Promise> + findExhaustedClaims(minAttempts: number): Promise> /** Deletes dead letters created before cutoff. Returns the number deleted. */ deleteDeadLettersBefore(cutoff: Date): Promise } diff --git a/packages/core/src/timed-events.ts b/packages/core/src/timed-events.ts index cc43596..45ca6f2 100644 --- a/packages/core/src/timed-events.ts +++ b/packages/core/src/timed-events.ts @@ -1,10 +1,231 @@ import type { TimedEventDefinition, TimedEventState } from './types.js' +const DAY_MS = 86_400_000 +const WEEK_MS = 604_800_000 + +export interface OccurrenceWindow { + index: number + startsAt: Date + endsAt: Date + key: string +} + +/** Fixed millisecond step for daily/weekly recurrence; monthly has no fixed step. */ +function fixedIntervalMs(recurrence: TimedEventDefinition['recurrence']): number | null { + if (recurrence === 'daily') return DAY_MS + if (recurrence === 'weekly') return WEEK_MS + return null +} + +/** + * UTC calendar-month stepping with day-of-month clamping (Jan 31 + 1mo -> Feb 28/29). Pure + * UTC-instant arithmetic, no timezone libraries — same style as engagement.ts's day math. + */ +function addMonthsUtcClamped(date: Date, months: number): Date { + const year = date.getUTCFullYear() + const month = date.getUTCMonth() + const day = date.getUTCDate() + const totalMonths = month + months + const targetYear = year + Math.floor(totalMonths / 12) + const targetMonth = ((totalMonths % 12) + 12) % 12 + const daysInTargetMonth = new Date(Date.UTC(targetYear, targetMonth + 1, 0)).getUTCDate() + const clampedDay = Math.min(day, daysInTargetMonth) + return new Date(Date.UTC( + targetYear, targetMonth, clampedDay, + date.getUTCHours(), date.getUTCMinutes(), date.getUTCSeconds(), date.getUTCMilliseconds(), + )) +} + +/** + * Greatest occurrence index N (>= 0) whose UTC-stepped monthly start is <= `instantMs`. + * `instantMs` is guaranteed by the caller to be >= base.getTime(). Estimates N via a direct + * year/month-count division (O(1)) then corrects by at most one step to account for + * day-of-month clamping — never an unbounded scan from occurrence 0. + */ +function monthlyIndexAtOrBefore(base: Date, instantMs: number): number { + const instant = new Date(instantMs) + let n = (instant.getUTCFullYear() - base.getUTCFullYear()) * 12 + (instant.getUTCMonth() - base.getUTCMonth()) + if (n < 0) n = 0 + while (addMonthsUtcClamped(base, n).getTime() > instantMs) n-- + while (addMonthsUtcClamped(base, n + 1).getTime() <= instantMs) n++ + return n +} + +/** Occurrence start for a given index (0-based), per the recurrence's stepping rule. */ +function occurrenceStart(event: TimedEventDefinition, index: number): Date { + if (event.recurrence === 'none') return event.startsAt + if (event.recurrence === 'monthly') return addMonthsUtcClamped(event.startsAt, index) + const interval = fixedIntervalMs(event.recurrence)! // daily | weekly + return new Date(event.startsAt.getTime() + index * interval) +} + +function occurrenceDuration(event: TimedEventDefinition): number { + return event.endsAt.getTime() - event.startsAt.getTime() +} + +function windowFor(event: TimedEventDefinition, index: number): OccurrenceWindow { + const startsAt = occurrenceStart(event, index) + const endsAt = new Date(startsAt.getTime() + occurrenceDuration(event)) + const key = event.recurrence === 'none' ? '' : startsAt.toISOString() + return { index, startsAt, endsAt, key } +} + +/** + * Greatest occurrence index N (>= 0) with startsAt_N <= `instantMs`, or null if even + * occurrence 0 hasn't started by `instantMs`. O(1) for daily/weekly (fixed-interval division); + * bounded month-count arithmetic for monthly — never an unbounded loop from occurrence 0. + */ +function indexAtOrBefore(event: TimedEventDefinition, instantMs: number): number | null { + const startMs = event.startsAt.getTime() + if (instantMs < startMs) return null + if (event.recurrence === 'none') return 0 + const interval = fixedIntervalMs(event.recurrence) + if (interval !== null) return Math.floor((instantMs - startMs) / interval) + return monthlyIndexAtOrBefore(event.startsAt, instantMs) +} + +/** + * Greatest existing occurrence index, bounded by recurrenceEndsAt: null means unbounded + * (every index computed by indexAtOrBefore is valid); a finite number bounds valid indices to + * [0, n]; -1 means no occurrence exists at all (recurrenceEndsAt at or before startsAt). + * Not applicable (and not consulted) for recurrence === 'none'. + */ +function maxValidIndex(event: TimedEventDefinition): number | null { + if (!event.recurrenceEndsAt) return null + const idx = indexAtOrBefore(event, event.recurrenceEndsAt.getTime() - 1) + return idx === null ? -1 : idx +} + +/** Clamps a candidate index against recurrenceEndsAt; returns null if the index doesn't exist. */ +function existingIndex(event: TimedEventDefinition, index: number): number | null { + if (event.recurrence === 'none') return index === 0 ? 0 : null + const maxIdx = maxValidIndex(event) + if (maxIdx === null) return index + if (maxIdx < 0 || index > maxIdx) return null + return index +} + +/** + * The occurrence containing `now`, else the NEXT upcoming one, else null (no current-or-future + * occurrence: a non-recurring event past endsAt, or recurrence past recurrenceEndsAt). This is + * the DISPLAY/multiplier view. + */ +export function occurrenceWindow(event: TimedEventDefinition, now: Date): OccurrenceWindow | null { + if (event.recurrence === 'none') { + return now.getTime() < event.endsAt.getTime() ? windowFor(event, 0) : null + } + const nowMs = now.getTime() + const atOrBefore = indexAtOrBefore(event, nowMs) + let targetIndex: number + if (atOrBefore === null) { + targetIndex = 0 // before the first occurrence -> it's the upcoming one + } else { + const current = windowFor(event, atOrBefore) + targetIndex = nowMs < current.endsAt.getTime() ? atOrBefore : atOrBefore + 1 + } + const idx = existingIndex(event, targetIndex) + return idx === null ? null : windowFor(event, idx) +} + +/** + * The latest EXISTING occurrence with startsAt <= now, else null (nothing started yet). This is + * the SCHEDULER view: between occurrences it returns the just-elapsed occurrence so its + * 'ended' transition can fire; occurrenceWindow would already be pointing at the next one. + */ +export function transitionOccurrence(event: TimedEventDefinition, now: Date): OccurrenceWindow | null { + const atOrBefore = indexAtOrBefore(event, now.getTime()) + if (atOrBefore === null) return null + const maxIdx = maxValidIndex(event) + const clamped = event.recurrence === 'none' ? 0 : maxIdx === null ? atOrBefore : Math.min(atOrBefore, maxIdx) + const idx = existingIndex(event, clamped) + return idx === null ? null : windowFor(event, idx) +} + +/** + * '' -> the definition's own window (index 0). Otherwise parse the ISO key, validate it lands + * exactly on an existing occurrence start, derive the window. null on garbage/misaligned keys. + * Used by the redelivery sweep to rebuild messages for stale per-occurrence claims. + */ +export function occurrenceFromKey(event: TimedEventDefinition, key: string): OccurrenceWindow | null { + if (key === '') { + const idx = existingIndex(event, 0) + return idx === null ? null : windowFor(event, idx) + } + if (event.recurrence === 'none') return null // only '' is ever valid for non-recurring events + const parsed = new Date(key) + if (Number.isNaN(parsed.getTime()) || parsed.toISOString() !== key) return null + const instantMs = parsed.getTime() + const startMs = event.startsAt.getTime() + if (instantMs < startMs) return null + let index: number + const interval = fixedIntervalMs(event.recurrence) + if (interval !== null) { + const diff = instantMs - startMs + if (diff % interval !== 0) return null + index = diff / interval + } else { + const candidate = monthlyIndexAtOrBefore(event.startsAt, instantMs) + if (addMonthsUtcClamped(event.startsAt, candidate).getTime() !== instantMs) return null + index = candidate + } + const idx = existingIndex(event, index) + return idx === null ? null : windowFor(event, idx) +} + +/** + * Occurrence windows intersecting [from, to]. Takes CONCRETE bounds — core stays clock-free; + * the caller defaults nulls (stats route: from ?? event.startsAt, to ?? new Date()). cap + * (default 400): keeps the most RECENT `cap` windows in range, dropping the oldest. + */ +export function occurrenceWindowsInRange( + event: TimedEventDefinition, from: Date, to: Date, cap = 400, +): Array<{ startsAt: Date; endsAt: Date }> { + const fromMs = from.getTime() + const toMs = to.getTime() + if (toMs <= fromMs) return [] + + if (event.recurrence === 'none') { + const w = windowFor(event, 0) + return w.startsAt.getTime() < toMs && w.endsAt.getTime() > fromMs + ? [{ startsAt: w.startsAt, endsAt: w.endsAt }] + : [] + } + + // Only the occurrence at-or-before `from` could partially overlap it (duration <= interval + // means the previous one always ends by then); occurrences strictly after it, up through the + // one at-or-before `to`, are the rest of the candidate range. + const fromAtOrBefore = indexAtOrBefore(event, fromMs) + const lowIndex = fromAtOrBefore ?? 0 + // Overlap requires startsAt < to, i.e. startsAt <= to - 1ms — mirrors maxValidIndex's + // strict-cutoff trick so the anchor index itself is guaranteed to satisfy the filter below. + const toAtOrBefore = indexAtOrBefore(event, toMs - 1) + if (toAtOrBefore === null) return [] // nothing starts before `to` + + const maxIdx = maxValidIndex(event) + if (maxIdx !== null && maxIdx < 0) return [] + const highIndex = maxIdx === null ? toAtOrBefore : Math.min(toAtOrBefore, maxIdx) + if (highIndex < lowIndex) return [] + + // Keep computation bounded to ~cap windows even when [from, to] spans a huge range: start + // from the most-recent end and only walk back as far as needed. + const startIndex = Math.max(lowIndex, highIndex - cap + 1) + + const results: Array<{ startsAt: Date; endsAt: Date }> = [] + for (let i = startIndex; i <= highIndex; i++) { + const w = windowFor(event, i) + if (w.startsAt.getTime() < toMs && w.endsAt.getTime() > fromMs) { + results.push({ startsAt: w.startsAt, endsAt: w.endsAt }) + } + } + return results.length > cap ? results.slice(results.length - cap) : results +} + export function timedEventState(event: TimedEventDefinition, now: Date): TimedEventState { if (!event.enabled) return 'draft' - if (now < event.startsAt) return 'scheduled' - if (now >= event.endsAt) return 'ended' - const msLeft = event.endsAt.getTime() - now.getTime() + const w = occurrenceWindow(event, now) + if (w === null) return 'ended' + if (now.getTime() < w.startsAt.getTime()) return 'scheduled' // before-first or between-occurrences + const msLeft = w.endsAt.getTime() - now.getTime() return msLeft <= event.endingSoonMinutes * 60_000 ? 'ending_soon' : 'live' } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 0d0ca00..399c362 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -52,6 +52,8 @@ export interface OfferDefinition { export type TimedEventState = 'draft' | 'scheduled' | 'live' | 'ending_soon' | 'ended' export type TimedEventTransition = 'live' | 'ending_soon' | 'ended' +export type Recurrence = 'none' | 'daily' | 'weekly' | 'monthly' + export interface TimedEventDefinition { id: string name: string @@ -61,6 +63,10 @@ export interface TimedEventDefinition { endingSoonMinutes: number multiplier: number enabled: boolean + recurrence: Recurrence + /** Occurrences stop once an occurrence's startsAt would no longer be strictly before this + * instant; null means the recurrence never ends. Ignored when recurrence === 'none'. */ + recurrenceEndsAt: Date | null } export interface WebhookEndpointDefinition { diff --git a/packages/core/test/occurrences.test.ts b/packages/core/test/occurrences.test.ts new file mode 100644 index 0000000..437b081 --- /dev/null +++ b/packages/core/test/occurrences.test.ts @@ -0,0 +1,316 @@ +import { describe, expect, it } from 'vitest' +import { + activeEventIds, + activeMultiplier, + occurrenceFromKey, + occurrenceWindow, + occurrenceWindowsInRange, + timedEventState, + transitionOccurrence, + type TimedEventDefinition, +} from '../src/index.js' + +const DAY_MS = 86_400_000 + +const mk = (over: Partial): TimedEventDefinition => ({ + id: 'e1', name: 'E', description: null, + startsAt: new Date('2026-07-10T00:00:00.000Z'), endsAt: new Date('2026-07-17T00:00:00.000Z'), + endingSoonMinutes: 1440, multiplier: 2, enabled: true, + recurrence: 'none', recurrenceEndsAt: null, ...over, +}) + +// Daily fixture: 2h-duration occurrences every 24h, starting 2026-07-10T00:00Z. +// N0 [07-10T00:00, 07-10T02:00), N1 [07-11T00:00, 07-11T02:00), N2 [07-12T00:00, 07-12T02:00), +// N3 [07-13T00:00, 07-13T02:00), N4 [07-14T00:00, 07-14T02:00) +const dailyEvent = mk({ + recurrence: 'daily', + startsAt: new Date('2026-07-10T00:00:00.000Z'), + endsAt: new Date('2026-07-10T02:00:00.000Z'), + recurrenceEndsAt: null, +}) + +describe('occurrenceWindow', () => { + describe('non-recurring', () => { + const e = mk({}) + it('before start -> the single window', () => { + const w = occurrenceWindow(e, new Date('2026-07-09T00:00:00.000Z')) + expect(w).toEqual({ index: 0, startsAt: e.startsAt, endsAt: e.endsAt, key: '' }) + }) + it('inside -> the single window', () => { + const w = occurrenceWindow(e, new Date('2026-07-12T00:00:00.000Z')) + expect(w).toEqual({ index: 0, startsAt: e.startsAt, endsAt: e.endsAt, key: '' }) + }) + it('after (endsAt, exclusive) -> null', () => { + expect(occurrenceWindow(e, e.endsAt)).toBeNull() + }) + }) + + describe('daily containment', () => { + it('exact start is inclusive (current occurrence)', () => { + const w = occurrenceWindow(dailyEvent, new Date('2026-07-11T00:00:00.000Z')) + expect(w?.index).toBe(1) + expect(w?.startsAt).toEqual(new Date('2026-07-11T00:00:00.000Z')) + }) + it('exact end is exclusive -> next occurrence', () => { + const w = occurrenceWindow(dailyEvent, new Date('2026-07-11T02:00:00.000Z')) + expect(w?.index).toBe(2) + expect(w?.startsAt).toEqual(new Date('2026-07-12T00:00:00.000Z')) + }) + it('between occurrences returns the next one', () => { + const w = occurrenceWindow(dailyEvent, new Date('2026-07-10T12:00:00.000Z')) + expect(w?.index).toBe(1) + expect(w?.startsAt).toEqual(new Date('2026-07-11T00:00:00.000Z')) + }) + it('key is the ISO of the occurrence start', () => { + const w = occurrenceWindow(dailyEvent, new Date('2026-07-11T00:30:00.000Z')) + expect(w?.key).toBe('2026-07-11T00:00:00.000Z') + }) + }) + + describe('recurrenceEndsAt cutoff', () => { + // 30-min-duration daily occurrences so the gap between an occurrence's end and the next + // one starting is large and unambiguous. + const base = { recurrence: 'daily' as const, startsAt: new Date('2026-07-10T00:00:00.000Z'), endsAt: new Date('2026-07-10T00:30:00.000Z') } + it('occurrence starting AT the cutoff does not exist', () => { + const e = mk({ ...base, recurrenceEndsAt: new Date('2026-07-11T00:00:00.000Z') }) + expect(occurrenceWindow(e, new Date('2026-07-12T00:00:00.000Z'))).toBeNull() + }) + it('occurrence starting 1ms before the cutoff exists', () => { + const e = mk({ ...base, recurrenceEndsAt: new Date('2026-07-11T00:00:00.001Z') }) + const w = occurrenceWindow(e, new Date('2026-07-10T12:00:00.000Z')) + expect(w?.index).toBe(1) + expect(w?.startsAt).toEqual(new Date('2026-07-11T00:00:00.000Z')) + }) + }) + + describe('monthly stepping', () => { + const monthlyEvent = mk({ + recurrence: 'monthly', + startsAt: new Date('2026-01-31T00:00:00.000Z'), + endsAt: new Date('2026-01-31T01:00:00.000Z'), + recurrenceEndsAt: null, + }) + it('Jan 31 + 1mo clamps to Feb 28 (non-leap year)', () => { + const w = occurrenceWindow(monthlyEvent, new Date('2026-02-15T00:00:00.000Z')) + expect(w?.index).toBe(1) + expect(w?.startsAt).toEqual(new Date('2026-02-28T00:00:00.000Z')) + expect(w?.endsAt).toEqual(new Date('2026-02-28T01:00:00.000Z')) + }) + it('continues stepping correctly after a clamp (Feb -> Mar 31)', () => { + const w = occurrenceWindow(monthlyEvent, new Date('2026-03-15T00:00:00.000Z')) + expect(w?.index).toBe(2) + expect(w?.startsAt).toEqual(new Date('2026-03-31T00:00:00.000Z')) + }) + it('leap year clamps Jan 31 + 1mo to Feb 29', () => { + const leapEvent = mk({ + recurrence: 'monthly', + startsAt: new Date('2028-01-31T00:00:00.000Z'), + endsAt: new Date('2028-01-31T01:00:00.000Z'), + recurrenceEndsAt: null, + }) + const w = occurrenceWindow(leapEvent, new Date('2028-02-15T00:00:00.000Z')) + expect(w?.index).toBe(1) + expect(w?.startsAt).toEqual(new Date('2028-02-29T00:00:00.000Z')) + }) + }) + + describe('duration = interval (back-to-back occurrences)', () => { + const e = mk({ + recurrence: 'daily', + startsAt: new Date('2026-07-10T00:00:00.000Z'), + endsAt: new Date('2026-07-11T00:00:00.000Z'), // 24h duration == daily interval + recurrenceEndsAt: null, + }) + it('end of occurrence N is exactly the start of N+1, unambiguous by end-exclusivity', () => { + const w = occurrenceWindow(e, new Date('2026-07-11T00:00:00.000Z')) + expect(w?.index).toBe(1) + expect(w?.startsAt).toEqual(new Date('2026-07-11T00:00:00.000Z')) + }) + }) +}) + +describe('transitionOccurrence', () => { + it('nothing started yet -> null', () => { + expect(transitionOccurrence(dailyEvent, new Date('2026-07-09T00:00:00.000Z'))).toBeNull() + }) + it('inside an occurrence -> that occurrence', () => { + const w = transitionOccurrence(dailyEvent, new Date('2026-07-11T00:30:00.000Z')) + expect(w?.index).toBe(1) + }) + it('between occurrences -> the previous (just-elapsed) occurrence', () => { + const w = transitionOccurrence(dailyEvent, new Date('2026-07-11T12:00:00.000Z')) + expect(w?.index).toBe(1) + expect(w?.startsAt).toEqual(new Date('2026-07-11T00:00:00.000Z')) + // contrast: occurrenceWindow at the same instant points at the next occurrence + const next = occurrenceWindow(dailyEvent, new Date('2026-07-11T12:00:00.000Z')) + expect(next?.index).toBe(2) + }) + it('after the final occurrence (recurrenceEndsAt cutoff) -> the final occurrence, not null', () => { + const e = mk({ + recurrence: 'daily', + startsAt: new Date('2026-07-10T00:00:00.000Z'), + endsAt: new Date('2026-07-10T00:30:00.000Z'), + recurrenceEndsAt: new Date('2026-07-11T00:00:00.001Z'), // occurrence 1 exists, occurrence 2 does not + }) + const w = transitionOccurrence(e, new Date('2026-07-20T00:00:00.000Z')) + expect(w?.index).toBe(1) + expect(w?.startsAt).toEqual(new Date('2026-07-11T00:00:00.000Z')) + // occurrenceWindow at the same far-future instant has nothing left to show + expect(occurrenceWindow(e, new Date('2026-07-20T00:00:00.000Z'))).toBeNull() + }) + it('monthly: between occurrences returns the previous month, not the next', () => { + const monthlyEvent = mk({ + recurrence: 'monthly', + startsAt: new Date('2026-01-31T00:00:00.000Z'), + endsAt: new Date('2026-01-31T01:00:00.000Z'), + recurrenceEndsAt: null, + }) + const w = transitionOccurrence(monthlyEvent, new Date('2026-02-15T00:00:00.000Z')) + expect(w?.index).toBe(0) + expect(w?.startsAt).toEqual(new Date('2026-01-31T00:00:00.000Z')) + }) +}) + +describe('occurrenceFromKey', () => { + it("'' -> the definition's own window (index 0)", () => { + const e = mk({}) + expect(occurrenceFromKey(e, '')).toEqual({ index: 0, startsAt: e.startsAt, endsAt: e.endsAt, key: '' }) + }) + it('valid on-grid ISO -> the correct index', () => { + const w = occurrenceFromKey(dailyEvent, '2026-07-12T00:00:00.000Z') + expect(w).toEqual({ + index: 2, + startsAt: new Date('2026-07-12T00:00:00.000Z'), + endsAt: new Date('2026-07-12T02:00:00.000Z'), + key: '2026-07-12T00:00:00.000Z', + }) + }) + it('garbage string -> null', () => { + expect(occurrenceFromKey(dailyEvent, 'not-a-date')).toBeNull() + }) + it('off-grid ISO (does not land on an occurrence start) -> null', () => { + expect(occurrenceFromKey(dailyEvent, '2026-07-11T00:00:01.000Z')).toBeNull() + }) + it('ISO before the definition startsAt -> null', () => { + expect(occurrenceFromKey(dailyEvent, '2020-01-01T00:00:00.000Z')).toBeNull() + }) + it('a non-empty key against a non-recurring event -> null (only "" is valid)', () => { + const e = mk({}) + expect(occurrenceFromKey(e, e.startsAt.toISOString())).toBeNull() + }) + it('key beyond recurrenceEndsAt -> null', () => { + const e = mk({ + recurrence: 'daily', + startsAt: new Date('2026-07-10T00:00:00.000Z'), + endsAt: new Date('2026-07-10T00:30:00.000Z'), + recurrenceEndsAt: new Date('2026-07-11T00:00:00.000Z'), // occurrence 1 starts exactly here -> doesn't exist + }) + expect(occurrenceFromKey(e, '2026-07-11T00:00:00.000Z')).toBeNull() + }) + it('key just before recurrenceEndsAt -> resolves', () => { + const e = mk({ + recurrence: 'daily', + startsAt: new Date('2026-07-10T00:00:00.000Z'), + endsAt: new Date('2026-07-10T00:30:00.000Z'), + recurrenceEndsAt: new Date('2026-07-11T00:00:00.001Z'), + }) + const w = occurrenceFromKey(e, '2026-07-11T00:00:00.000Z') + expect(w?.index).toBe(1) + }) +}) + +describe('occurrenceWindowsInRange', () => { + it('range spanning 3 occurrences -> 3 windows', () => { + const from = new Date('2026-07-10T00:00:00.000Z') + const to = new Date('2026-07-13T00:00:00.000Z') // == N3.startsAt, excluded (end-exclusive on `to`) + const windows = occurrenceWindowsInRange(dailyEvent, from, to) + expect(windows).toHaveLength(3) + expect(windows.map(w => w.startsAt)).toEqual([ + new Date('2026-07-10T00:00:00.000Z'), + new Date('2026-07-11T00:00:00.000Z'), + new Date('2026-07-12T00:00:00.000Z'), + ]) + }) + it('partial overlap at both edges is included', () => { + const from = new Date('2026-07-10T01:00:00.000Z') // inside N0's window + const to = new Date('2026-07-12T01:00:00.000Z') // inside N2's window + const windows = occurrenceWindowsInRange(dailyEvent, from, to) + expect(windows).toHaveLength(3) + expect(windows[0].startsAt).toEqual(new Date('2026-07-10T00:00:00.000Z')) + expect(windows[2].startsAt).toEqual(new Date('2026-07-12T00:00:00.000Z')) + }) + it('cap keeps the most recent windows, dropping the oldest', () => { + const from = new Date('2026-07-10T00:00:00.000Z') + const to = new Date('2026-07-15T00:00:00.000Z') // spans occurrences 0..4 (5 dailies) + const windows = occurrenceWindowsInRange(dailyEvent, from, to, 3) + expect(windows).toHaveLength(3) + expect(windows.map(w => w.startsAt)).toEqual([ + new Date('2026-07-12T00:00:00.000Z'), + new Date('2026-07-13T00:00:00.000Z'), + new Date('2026-07-14T00:00:00.000Z'), + ]) + }) + it('non-recurring event with an overlapping range -> single window', () => { + const e = mk({}) + const windows = occurrenceWindowsInRange(e, new Date('2026-07-01T00:00:00.000Z'), new Date('2026-07-20T00:00:00.000Z')) + expect(windows).toEqual([{ startsAt: e.startsAt, endsAt: e.endsAt }]) + }) +}) + +describe('timedEventState — occurrence-aware', () => { + const e = mk({ + recurrence: 'daily', + startsAt: new Date('2026-07-10T00:00:00.000Z'), + endsAt: new Date('2026-07-10T02:00:00.000Z'), + endingSoonMinutes: 30, + recurrenceEndsAt: null, + }) + it('disabled -> draft', () => { + expect(timedEventState({ ...e, enabled: false }, new Date('2026-07-11T00:30:00.000Z'))).toBe('draft') + }) + it('before the first occurrence -> scheduled', () => { + expect(timedEventState(e, new Date('2026-07-09T00:00:00.000Z'))).toBe('scheduled') + }) + it('inside an occurrence, plenty of time left -> live', () => { + expect(timedEventState(e, new Date('2026-07-10T00:30:00.000Z'))).toBe('live') + }) + it('inside an occurrence, within endingSoonMinutes of its end -> ending_soon', () => { + expect(timedEventState(e, new Date('2026-07-10T01:45:00.000Z'))).toBe('ending_soon') + }) + it('between occurrences -> scheduled', () => { + expect(timedEventState(e, new Date('2026-07-10T12:00:00.000Z'))).toBe('scheduled') + }) + it('ending_soon inside a LATER occurrence', () => { + expect(timedEventState(e, new Date('2026-07-11T01:45:00.000Z'))).toBe('ending_soon') + }) + it('no occurrence left (past recurrenceEndsAt) -> ended', () => { + const capped = mk({ + recurrence: 'daily', + startsAt: new Date('2026-07-10T00:00:00.000Z'), + endsAt: new Date('2026-07-10T00:30:00.000Z'), + recurrenceEndsAt: new Date('2026-07-11T00:00:00.000Z'), + }) + expect(timedEventState(capped, new Date('2026-07-15T00:00:00.000Z'))).toBe('ended') + }) +}) + +describe('activeMultiplier / activeEventIds — occurrence-aware', () => { + const e = mk({ + recurrence: 'daily', + startsAt: new Date('2026-07-10T00:00:00.000Z'), + endsAt: new Date('2026-07-10T02:00:00.000Z'), + endingSoonMinutes: 30, + multiplier: 3, + recurrenceEndsAt: null, + }) + it('active inside occurrence 2 of a recurring event', () => { + const now = new Date('2026-07-12T00:30:00.000Z') + expect(activeMultiplier([e], now)).toBe(3) + expect(activeEventIds([e], now)).toEqual(new Set(['e1'])) + }) + it('inactive between occurrences', () => { + const now = new Date('2026-07-11T12:00:00.000Z') + expect(activeMultiplier([e], now)).toBe(1) + expect(activeEventIds([e], now)).toEqual(new Set()) + }) +}) diff --git a/packages/core/test/timed-events.test.ts b/packages/core/test/timed-events.test.ts index 8c08c63..bc3ee41 100644 --- a/packages/core/test/timed-events.test.ts +++ b/packages/core/test/timed-events.test.ts @@ -4,7 +4,8 @@ import { activeEventIds, activeMultiplier, timedEventState, type TimedEventDefin const mk = (over: Partial): TimedEventDefinition => ({ id: 'e1', name: 'E', description: null, startsAt: new Date('2026-07-10T00:00:00Z'), endsAt: new Date('2026-07-17T00:00:00Z'), - endingSoonMinutes: 1440, multiplier: 2, enabled: true, ...over, + endingSoonMinutes: 1440, multiplier: 2, enabled: true, + recurrence: 'none', recurrenceEndsAt: null, ...over, }) describe('timedEventState', () => { From 2c6d98c505a1f86adae182e029eab342b5eb4410 Mon Sep 17 00:00:00 2001 From: Steve Hynding Date: Thu, 9 Jul 2026 13:00:02 -0700 Subject: [PATCH 05/12] feat(adapter-db): per-occurrence webhook claims, retroactive backfill store, multi-window stats (migration 0008) Co-Authored-By: Claude Fable 5 --- .../migrations/0008_keen_carnage.sql | 3 + .../migrations/meta/0008_snapshot.json | 1135 +++++++++++++++++ .../adapter-db/migrations/meta/_journal.json | 7 + packages/adapter-db/src/index.ts | 2 +- packages/adapter-db/src/schema.ts | 3 +- packages/adapter-db/src/stores.ts | 145 ++- packages/adapter-db/test/backfill.test.ts | 206 +++ packages/adapter-db/test/stats.test.ts | 39 + .../adapter-db/test/webhook-delivery.test.ts | 82 +- 9 files changed, 1592 insertions(+), 30 deletions(-) create mode 100644 packages/adapter-db/migrations/0008_keen_carnage.sql create mode 100644 packages/adapter-db/migrations/meta/0008_snapshot.json create mode 100644 packages/adapter-db/test/backfill.test.ts diff --git a/packages/adapter-db/migrations/0008_keen_carnage.sql b/packages/adapter-db/migrations/0008_keen_carnage.sql new file mode 100644 index 0000000..a1c5f0f --- /dev/null +++ b/packages/adapter-db/migrations/0008_keen_carnage.sql @@ -0,0 +1,3 @@ +DROP INDEX "runtime"."event_notif_uq";--> statement-breakpoint +ALTER TABLE "runtime"."timed_event_notifications" ADD COLUMN "occurrence_key" text DEFAULT '' NOT NULL;--> statement-breakpoint +CREATE UNIQUE INDEX "event_notif_uq" ON "runtime"."timed_event_notifications" USING btree ("project_id","event_id","occurrence_key","transition"); \ No newline at end of file diff --git a/packages/adapter-db/migrations/meta/0008_snapshot.json b/packages/adapter-db/migrations/meta/0008_snapshot.json new file mode 100644 index 0000000..b93a351 --- /dev/null +++ b/packages/adapter-db/migrations/meta/0008_snapshot.json @@ -0,0 +1,1135 @@ +{ + "id": "6d4e2ce2-94d8-4b55-a71b-1912704836b5", + "prevId": "6266dfc4-aed2-49a3-b140-f9c5bafa203e", + "version": "7", + "dialect": "postgresql", + "tables": { + "runtime.achievement_progress": { + "name": "achievement_progress", + "schema": "runtime", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "environment": { + "name": "environment", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "achievement_id": { + "name": "achievement_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "current": { + "name": "current", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "progress_uq": { + "name": "progress_uq", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "environment", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "achievement_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "runtime.coupons": { + "name": "coupons", + "schema": "runtime", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "environment": { + "name": "environment", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reward_id": { + "name": "reward_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code_shared": { + "name": "code_shared", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'claimed'" + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "redeemed_at": { + "name": "redeemed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "coupons_code_uq": { + "name": "coupons_code_uq", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "environment", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"runtime\".\"coupons\".\"code_shared\" = false", + "concurrently": false, + "method": "btree", + "with": {} + }, + "coupons_code_ix": { + "name": "coupons_code_ix", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "environment", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "coupons_reward_ix": { + "name": "coupons_reward_ix", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "environment", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "reward_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "coupons_user_ix": { + "name": "coupons_user_ix", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "environment", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "reward_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "runtime.events": { + "name": "events", + "schema": "runtime", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "environment": { + "name": "environment", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "meta": { + "name": "meta", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "events_idem_uq": { + "name": "events_idem_uq", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "environment", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "events_stats_ix": { + "name": "events_stats_ix", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "environment", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "runtime.monthly_active_users": { + "name": "monthly_active_users", + "schema": "runtime", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "environment": { + "name": "environment", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "month": { + "name": "month", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "mau_uq": { + "name": "mau_uq", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "environment", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "month", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "runtime.offer_events": { + "name": "offer_events", + "schema": "runtime", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "environment": { + "name": "environment", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "offer_id": { + "name": "offer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "offer_events_idem_uq": { + "name": "offer_events_idem_uq", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "environment", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"runtime\".\"offer_events\".\"kind\" = 'impression' and \"runtime\".\"offer_events\".\"idempotency_key\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "offer_events_stats_ix": { + "name": "offer_events_stats_ix", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "environment", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "offer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "runtime.points_ledger": { + "name": "points_ledger", + "schema": "runtime", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "environment": { + "name": "environment", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "delta": { + "name": "delta", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_ref": { + "name": "source_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "points_ledger_user_ix": { + "name": "points_ledger_user_ix", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "environment", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "points_ledger_window_ix": { + "name": "points_ledger_window_ix", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "environment", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "runtime.timed_event_notifications": { + "name": "timed_event_notifications", + "schema": "runtime", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "occurrence_key": { + "name": "occurrence_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "transition": { + "name": "transition", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "fired_at": { + "name": "fired_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "delivered_at": { + "name": "delivered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "event_notif_uq": { + "name": "event_notif_uq", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurrence_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "transition", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "runtime.unlocks": { + "name": "unlocks", + "schema": "runtime", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "environment": { + "name": "environment", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "achievement_id": { + "name": "achievement_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "unlocked_at": { + "name": "unlocked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "unlocks_uq": { + "name": "unlocks_uq", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "environment", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "achievement_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "unlocks_stats_ix": { + "name": "unlocks_stats_ix", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "environment", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "achievement_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "runtime.usage_counters": { + "name": "usage_counters", + "schema": "runtime", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "environment": { + "name": "environment", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "month": { + "name": "month", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "events_count": { + "name": "events_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "usage_uq": { + "name": "usage_uq", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "environment", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "month", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "runtime.user_streaks": { + "name": "user_streaks", + "schema": "runtime", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "environment": { + "name": "environment", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "current_streak": { + "name": "current_streak", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "longest_streak": { + "name": "longest_streak", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_active_day": { + "name": "last_active_day", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_streaks_uq": { + "name": "user_streaks_uq", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "environment", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "runtime.webhook_dead_letters": { + "name": "webhook_dead_letters", + "schema": "runtime", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": { + "runtime": "runtime" + }, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/adapter-db/migrations/meta/_journal.json b/packages/adapter-db/migrations/meta/_journal.json index 65a9f9a..9eec6fc 100644 --- a/packages/adapter-db/migrations/meta/_journal.json +++ b/packages/adapter-db/migrations/meta/_journal.json @@ -57,6 +57,13 @@ "when": 1783611084414, "tag": "0007_true_lester", "breakpoints": true + }, + { + "idx": 8, + "version": "7", + "when": 1783626814040, + "tag": "0008_keen_carnage", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/adapter-db/src/index.ts b/packages/adapter-db/src/index.ts index 71586d6..71ba12c 100644 --- a/packages/adapter-db/src/index.ts +++ b/packages/adapter-db/src/index.ts @@ -13,5 +13,5 @@ export function createDb(connectionString: string): Db { return drizzle(pool) as Db } export { runMigrations } from './migrate.js' -export { PgEngagementStore, PgErasureStore, PgEventStore, PgIngestionStore, PgOfferMetricsStore, PgProgressStore, PgRewardStore, PgStatsStore, PgUsageStore, PgWebhookDeliveryStore } from './stores.js' +export { PgBackfillStore, PgEngagementStore, PgErasureStore, PgEventStore, PgIngestionStore, PgOfferMetricsStore, PgProgressStore, PgRewardStore, PgStatsStore, PgUsageStore, PgWebhookDeliveryStore } from './stores.js' export * as schema from './schema.js' diff --git a/packages/adapter-db/src/schema.ts b/packages/adapter-db/src/schema.ts index fe1c0a6..3d8ef8c 100644 --- a/packages/adapter-db/src/schema.ts +++ b/packages/adapter-db/src/schema.ts @@ -71,11 +71,12 @@ export const offerEvents = runtime.table('offer_events', { export const timedEventNotifications = runtime.table('timed_event_notifications', { projectId: text('project_id').notNull(), eventId: text('event_id').notNull(), + occurrenceKey: text('occurrence_key').notNull().default(''), transition: text('transition').notNull(), firedAt: timestamp('fired_at', { withTimezone: true }).defaultNow().notNull(), deliveredAt: timestamp('delivered_at', { withTimezone: true }), attempts: integer('attempts').notNull().default(0), -}, (t) => [uniqueIndex('event_notif_uq').on(t.projectId, t.eventId, t.transition)]) +}, (t) => [uniqueIndex('event_notif_uq').on(t.projectId, t.eventId, t.occurrenceKey, t.transition)]) export const webhookDeadLetters = runtime.table('webhook_dead_letters', { id: uuid('id').defaultRandom().primaryKey(), diff --git a/packages/adapter-db/src/stores.ts b/packages/adapter-db/src/stores.ts index f11704c..16e3933 100644 --- a/packages/adapter-db/src/stores.ts +++ b/packages/adapter-db/src/stores.ts @@ -1,6 +1,6 @@ import { randomBytes } from 'node:crypto' import { and, asc, desc, eq, gte, inArray, isNull, lt, lte, sql } from 'drizzle-orm' -import { applyStreak, couponCodeFromBytes, decideClaim, type EngagementStore, type EngagementWrite, type ErasureStore, type EventStore, type IngestionStore, type OfferMetricsStore, type ProgressStore, type RewardDefinition, type RewardStore, type Scope, type StatsStore, type StreakState, type TimedEventTransition, type UsageStore, type WebhookDeliveryStore } from '@promocean/core' +import { applyStreak, couponCodeFromBytes, decideClaim, type AchievementDefinition, type BackfillStore, type EngagementStore, type EngagementWrite, type ErasureStore, type EventStore, type IngestionStore, type OfferMetricsStore, type ProgressStore, type RewardDefinition, type RewardStore, type Scope, type StatsStore, type StreakState, type TimedEventTransition, type UsageStore, type WebhookDeliveryStore } from '@promocean/core' import { achievementProgress, coupons, events, monthlyActiveUsers, offerEvents, pointsLedger, timedEventNotifications, unlocks, usageCounters, userStreaks, webhookDeadLetters } from './schema.js' import type { Db } from './index.js' @@ -274,6 +274,22 @@ const rangeConds = (col: { name?: string } & Parameters[0], range: { return conds } +/** + * Groups the flat window list by eventId while preserving first-seen order, so a single event + * with several occurrence windows collapses to one output row whose participant count unions all + * of its windows. + */ +type TimedEventWindow = { eventId: string; startsAt: Date; endsAt: Date } +const groupWindowsByEvent = (windows: TimedEventWindow[]): Map => { + const byEvent = new Map() + for (const w of windows) { + const existing = byEvent.get(w.eventId) + if (existing) existing.push(w) + else byEvent.set(w.eventId, [w]) + } + return byEvent +} + export class PgStatsStore implements StatsStore { constructor(private db: Db) {} async getStats( @@ -292,17 +308,18 @@ export class PgStatsStore implements StatsStore { .from(offerEvents) .where(and(scoped(offerEvents, scope), ...rangeConds(offerEvents.createdAt, range))) .groupBy(offerEvents.offerId, offerEvents.kind), - Promise.all(timedEventWindows.map(async (w) => { - // Range intersected with window: GREATEST/LEAST ignore null args, so a null - // range.from/to simply falls back to the window's own bound. + Promise.all([...groupWindowsByEvent(timedEventWindows)].map(async ([eventId, windows]) => { + // Participants per event = distinct users active in ANY of that event's windows: OR the + // per-window predicates so a user active in two occurrences counts once. Range intersected + // with each window: GREATEST/LEAST ignore null args, so a null range.from/to simply falls + // back to the window's own bound. + const windowConds = windows.map((w) => sql`(occurred_at between GREATEST(${w.startsAt}::timestamptz, ${range.from}::timestamptz) and LEAST(${w.endsAt}::timestamptz, ${range.to}::timestamptz))`) const result = await this.db.execute<{ n: number }>(sql` select count(distinct user_id)::int as n from runtime.events - where project_id = ${scope.projectId} and environment = ${scope.environment} - and occurred_at between GREATEST(${w.startsAt}::timestamptz, ${range.from}::timestamptz) - and LEAST(${w.endsAt}::timestamptz, ${range.to}::timestamptz) + where project_id = ${scope.projectId} and environment = ${scope.environment} and (${sql.join(windowConds, sql` or `)}) `) - return { eventId: w.eventId, participants: Number(result.rows[0]?.n ?? 0) } + return { eventId, participants: Number(result.rows[0]?.n ?? 0) } })), (async () => { if (timedEventWindows.length === 0) return 0 @@ -347,9 +364,9 @@ export class PgStatsStore implements StatsStore { export class PgWebhookDeliveryStore implements WebhookDeliveryStore { constructor(private db: Db) {} - async claimTransition(projectId: string, eventId: string, transition: TimedEventTransition) { + async claimTransition(projectId: string, eventId: string, occurrenceKey: string, transition: TimedEventTransition) { const inserted = await this.db.insert(timedEventNotifications) - .values({ projectId, eventId, transition }) + .values({ projectId, eventId, occurrenceKey, transition }) .onConflictDoNothing() .returning({ eventId: timedEventNotifications.eventId }) return inserted.length > 0 @@ -357,12 +374,13 @@ export class PgWebhookDeliveryStore implements WebhookDeliveryStore { async recordDeadLetter(projectId: string, url: string, payload: string, error: string, at: Date) { await this.db.insert(webhookDeadLetters).values({ projectId, url, payload, error, createdAt: at }) } - async markDelivered(projectId: string, eventId: string, transition: TimedEventTransition) { + async markDelivered(projectId: string, eventId: string, occurrenceKey: string, transition: TimedEventTransition) { await this.db.update(timedEventNotifications) .set({ deliveredAt: sql`now()` }) .where(and( eq(timedEventNotifications.projectId, projectId), eq(timedEventNotifications.eventId, eventId), + eq(timedEventNotifications.occurrenceKey, occurrenceKey), eq(timedEventNotifications.transition, transition), isNull(timedEventNotifications.deliveredAt), )) @@ -371,6 +389,7 @@ export class PgWebhookDeliveryStore implements WebhookDeliveryStore { const rows = await this.db.select({ projectId: timedEventNotifications.projectId, eventId: timedEventNotifications.eventId, + occurrenceKey: timedEventNotifications.occurrenceKey, transition: timedEventNotifications.transition, attempts: timedEventNotifications.attempts, }).from(timedEventNotifications) @@ -381,12 +400,13 @@ export class PgWebhookDeliveryStore implements WebhookDeliveryStore { )) return rows.map((r) => ({ ...r, transition: r.transition as TimedEventTransition })) } - async incrementAttempts(projectId: string, eventId: string, transition: TimedEventTransition) { + async incrementAttempts(projectId: string, eventId: string, occurrenceKey: string, transition: TimedEventTransition) { await this.db.update(timedEventNotifications) .set({ attempts: sql`${timedEventNotifications.attempts} + 1` }) .where(and( eq(timedEventNotifications.projectId, projectId), eq(timedEventNotifications.eventId, eventId), + eq(timedEventNotifications.occurrenceKey, occurrenceKey), eq(timedEventNotifications.transition, transition), )) } @@ -394,6 +414,7 @@ export class PgWebhookDeliveryStore implements WebhookDeliveryStore { const rows = await this.db.select({ projectId: timedEventNotifications.projectId, eventId: timedEventNotifications.eventId, + occurrenceKey: timedEventNotifications.occurrenceKey, transition: timedEventNotifications.transition, attempts: timedEventNotifications.attempts, }).from(timedEventNotifications) @@ -616,3 +637,103 @@ export class PgRewardStore implements RewardStore { }) } } + +/** + * Retroactively applies an achievement definition against already-stored events — the path a + * newly-created (or newly-eligible) achievement takes so historical activity counts toward it. + * + * The whole run is one transaction guarded by an advisory lock keyed on + * (project+environment, 'backfill:' + def.id), so two concurrent backfills of the same definition + * serialize. Live ingestion NEVER takes this lock — so a concurrent ingestEvent can race us. Two + * belts guard that race: the progress upsert wraps its target-clamped value in GREATEST so a + * concurrent live increment is never lowered, and the unlock insert is onConflictDoNothing so only + * one of {backfill, ingest} wins the row and writes the single unlock bonus. + */ +export class PgBackfillStore implements BackfillStore { + constructor(private db: Db) {} + async backfillAchievement(scope: Scope, def: AchievementDefinition) { + return this.db.transaction(async (tx) => { + const ns = `${scope.projectId}:${scope.environment}` + await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext(${ns}), hashtext(${'backfill:' + def.id}))`) + + const aggregate = await tx.execute<{ user_id: string; cnt: number }>(sql` + SELECT user_id, COUNT(*)::int AS cnt + FROM runtime.events + WHERE project_id = ${scope.projectId} AND environment = ${scope.environment} AND type = ${def.eventType} + GROUP BY user_id + `) + const rows = aggregate.rows + const usersEvaluated = rows.length + // Empty aggregate: nothing to evaluate, so return an all-zero summary without any writes. + if (usersEvaluated === 0) { + return { usersEvaluated: 0, progressRaised: 0, unlocksGranted: 0, pointsAwarded: 0 } + } + + const userIds = rows.map((r) => r.user_id) + const existingRows = await tx.select({ + userId: achievementProgress.userId, + current: achievementProgress.current, + }) + .from(achievementProgress) + .where(and( + scoped(achievementProgress, scope), + eq(achievementProgress.achievementId, def.id), + inArray(achievementProgress.userId, userIds), + )) + const existing = new Map(existingRows.map((r) => [r.userId, r.current])) + + // Every unlock granted in this run shares one instant, exactly as PgIngestionStore does. + const unlockedAt = new Date() + let progressRaised = 0 + let unlocksGranted = 0 + let pointsAwarded = 0 + + for (const r of rows) { + const cnt = r.cnt + const desired = Math.min(cnt, def.targetCount) + const prev = existing.get(r.user_id) ?? 0 + // Only raise progress where the retroactive count actually exceeds what's stored — a live + // (possibly multiplier-inflated) value at or above `desired` is left untouched, so it does + // not count toward progressRaised. + if (desired > prev) { + await tx.insert(achievementProgress) + .values({ ...scope, userId: r.user_id, achievementId: def.id, current: desired }) + .onConflictDoUpdate({ + target: [achievementProgress.projectId, achievementProgress.environment, achievementProgress.userId, achievementProgress.achievementId], + // GREATEST belts the live-ingest race: a concurrent increment that landed after our + // read is never lowered by this write. + set: { + current: sql`GREATEST(${achievementProgress.current}, LEAST(${cnt}::int, ${def.targetCount}::int))`, + updatedAt: sql`now()`, + }, + }) + progressRaised++ + } + + if (cnt >= def.targetCount) { + const insertedUnlock = await tx.insert(unlocks) + .values({ ...scope, userId: r.user_id, achievementId: def.id, unlockedAt }) + .onConflictDoNothing() + .returning({ achievementId: unlocks.achievementId }) + if (insertedUnlock.length > 0) { + unlocksGranted++ + // Gated exactly as PgIngestionStore: award the unlock bonus only for a genuinely new + // unlock row AND a positive point value. A zero-point achievement grants no ledger row. + if (def.pointsValue > 0) { + await tx.insert(pointsLedger).values({ + ...scope, + userId: r.user_id, + delta: def.pointsValue, + source: 'unlock', + sourceRef: def.id, + }) + pointsAwarded += def.pointsValue + } + } + } + } + + return { usersEvaluated, progressRaised, unlocksGranted, pointsAwarded } + }) + } +} diff --git a/packages/adapter-db/test/backfill.test.ts b/packages/adapter-db/test/backfill.test.ts new file mode 100644 index 0000000..adaac41 --- /dev/null +++ b/packages/adapter-db/test/backfill.test.ts @@ -0,0 +1,206 @@ +import { PostgreSqlContainer, type StartedPostgreSqlContainer } from '@testcontainers/postgresql' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { createDb, runMigrations, PgBackfillStore, PgEngagementStore, PgIngestionStore, type Db } from '../src/index.js' +import type { AchievementDefinition, EngagementWrite, Scope } from '@promocean/core' + +let container: StartedPostgreSqlContainer +let db: Db +let backfill: PgBackfillStore +let ingest: PgIngestionStore + +const scope: Scope = { projectId: 'p1', environment: 'test' } +const noEngagement: EngagementWrite = { localDay: '2026-07-01', eventPoints: null, unlockPoints: {} } + +const makeDef = (over: Partial & Pick): AchievementDefinition => ({ + name: over.id, + description: null, + artworkUrl: null, + targetCount: 3, + pointsValue: 0, + ...over, +}) + +beforeAll(async () => { + container = await new PostgreSqlContainer('postgres:17').start() + db = createDb(container.getConnectionUri()) + await runMigrations(db) + backfill = new PgBackfillStore(db) + ingest = new PgIngestionStore(db) +}) +afterAll(async () => { await db.$client.end(); await container.stop() }) + +// Ingest n events of `type` for `userId` with NO matching increment — the definition "doesn't +// exist yet", so events land but no progress/unlock/ledger row is written. +async function ingestBareEvents(s: Scope, userId: string, type: string, n: number, keyPrefix: string) { + for (let i = 0; i < n; i++) { + await ingest.ingestEvent(s, { userId, type, idempotencyKey: `${keyPrefix}-${i}`, occurredAt: new Date() }, [], '2026-07', noEngagement) + } +} + +const progressCurrent = async (s: Scope, userId: string, achievementId: string) => { + const { rows } = await db.$client.query( + `select current from runtime.achievement_progress where project_id=$1 and environment=$2 and user_id=$3 and achievement_id=$4`, + [s.projectId, s.environment, userId, achievementId], + ) + return rows[0]?.current as number | undefined +} +const unlockCount = async (s: Scope, userId: string, achievementId: string) => { + const { rows } = await db.$client.query( + `select count(*)::int as n from runtime.unlocks where project_id=$1 and environment=$2 and user_id=$3 and achievement_id=$4`, + [s.projectId, s.environment, userId, achievementId], + ) + return rows[0].n as number +} +const bonusLedgerCount = async (s: Scope, userId: string, sourceRef: string) => { + const { rows } = await db.$client.query( + `select count(*)::int as n from runtime.points_ledger where project_id=$1 and environment=$2 and user_id=$3 and source='unlock' and source_ref=$4`, + [s.projectId, s.environment, userId, sourceRef], + ) + return rows[0].n as number +} +const totalLedgerRows = async (s: Scope) => { + const { rows } = await db.$client.query( + `select count(*)::int as n from runtime.points_ledger where project_id=$1 and environment=$2`, + [s.projectId, s.environment], + ) + return rows[0].n as number +} + +describe('PgBackfillStore.backfillAchievement', () => { + it('true retroactivity: events stored before the definition existed count toward it', async () => { + // userA: 4 stored events -> crosses target 3 -> unlock + bonus. userB: 2 -> progress only. + await ingestBareEvents(scope, 'ret-A', 'ret_lesson', 4, 'retA') + await ingestBareEvents(scope, 'ret-B', 'ret_lesson', 2, 'retB') + const def = makeDef({ id: 'ret-ach', eventType: 'ret_lesson', targetCount: 3, pointsValue: 50 }) + + const summary = await backfill.backfillAchievement(scope, def) + expect(summary).toEqual({ usersEvaluated: 2, progressRaised: 2, unlocksGranted: 1, pointsAwarded: 50 }) + + expect(await progressCurrent(scope, 'ret-A', 'ret-ach')).toBe(3) // clamped at target + expect(await progressCurrent(scope, 'ret-B', 'ret-ach')).toBe(2) + expect(await unlockCount(scope, 'ret-A', 'ret-ach')).toBe(1) + expect(await unlockCount(scope, 'ret-B', 'ret-ach')).toBe(0) + expect(await bonusLedgerCount(scope, 'ret-A', 'ret-ach')).toBe(1) + + // Wallet SUM reflects the retroactive bonus. + const wallet = await new PgEngagementStore(db).getWallet(scope, 'ret-A') + expect(wallet.balance).toBe(50) + }) + + it('idempotent re-run: zero deltas and the ledger row count is unchanged', async () => { + const def = makeDef({ id: 'ret-ach', eventType: 'ret_lesson', targetCount: 3, pointsValue: 50 }) + const ledgerBefore = await totalLedgerRows(scope) + + const summary = await backfill.backfillAchievement(scope, def) + // usersEvaluated still reflects the population; every DELTA is zero. + expect(summary.progressRaised).toBe(0) + expect(summary.unlocksGranted).toBe(0) + expect(summary.pointsAwarded).toBe(0) + + expect(await totalLedgerRows(scope)).toBe(ledgerBefore) // no second bonus + expect(await unlockCount(scope, 'ret-A', 'ret-ach')).toBe(1) + }) + + it('GREATEST never lowers pre-existing (multiplier-inflated) progress', async () => { + // Live progress is 8 (inflated by a multiplier) but only 3 raw events are stored; target 10. + await ingestBareEvents(scope, 'gr-U', 'gr_type', 3, 'grU') + await db.$client.query( + `insert into runtime.achievement_progress (project_id, environment, user_id, achievement_id, current) values ($1,$2,'gr-U','gr-ach',8)`, + [scope.projectId, scope.environment], + ) + const def = makeDef({ id: 'gr-ach', eventType: 'gr_type', targetCount: 10, pointsValue: 100 }) + + const summary = await backfill.backfillAchievement(scope, def) + expect(summary.progressRaised).toBe(0) + expect(summary.unlocksGranted).toBe(0) + expect(await progressCurrent(scope, 'gr-U', 'gr-ach')).toBe(8) // stays 8, not lowered to 3 + }) + + it('bonus gating: an already-live-unlocked user gets no second unlock or bonus', async () => { + await ingestBareEvents(scope, 'bg-U', 'bg_type', 5, 'bgU') + const def = makeDef({ id: 'bg-ach', eventType: 'bg_type', targetCount: 3, pointsValue: 40 }) + // Simulate the live-ingest path having already unlocked + awarded the bonus. + await db.$client.query( + `insert into runtime.achievement_progress (project_id, environment, user_id, achievement_id, current) values ($1,$2,'bg-U','bg-ach',3)`, + [scope.projectId, scope.environment], + ) + await db.$client.query( + `insert into runtime.unlocks (project_id, environment, user_id, achievement_id, unlocked_at) values ($1,$2,'bg-U','bg-ach',now())`, + [scope.projectId, scope.environment], + ) + await db.$client.query( + `insert into runtime.points_ledger (project_id, environment, user_id, delta, source, source_ref) values ($1,$2,'bg-U',40,'unlock','bg-ach')`, + [scope.projectId, scope.environment], + ) + + const summary = await backfill.backfillAchievement(scope, def) + expect(summary.progressRaised).toBe(0) + expect(summary.unlocksGranted).toBe(0) + expect(summary.pointsAwarded).toBe(0) + expect(await unlockCount(scope, 'bg-U', 'bg-ach')).toBe(1) // still exactly one + expect(await bonusLedgerCount(scope, 'bg-U', 'bg-ach')).toBe(1) // no second bonus + }) + + it('live-ingest race: concurrent backfill + crossing ingest yield exactly one unlock and one bonus', async () => { + // userR sits at progress 2 (target 3) from 2 stored events; a live ingest crosses to 3 at the + // same moment a backfill runs. Only one of them may insert the unlock / write the bonus. + await ingestBareEvents(scope, 'race-U', 'race_type', 2, 'raceU') + await db.$client.query( + `insert into runtime.achievement_progress (project_id, environment, user_id, achievement_id, current) values ($1,$2,'race-U','race-ach',2)`, + [scope.projectId, scope.environment], + ) + const def = makeDef({ id: 'race-ach', eventType: 'race_type', targetCount: 3, pointsValue: 70 }) + + await Promise.all([ + backfill.backfillAchievement(scope, def), + ingest.ingestEvent( + scope, + { userId: 'race-U', type: 'race_type', idempotencyKey: 'race-cross', occurredAt: new Date() }, + [{ achievementId: 'race-ach', delta: 1, target: 3 }], + '2026-07', + { localDay: '2026-07-01', eventPoints: null, unlockPoints: { 'race-ach': 70 } }, + ), + ]) + + expect(await unlockCount(scope, 'race-U', 'race-ach')).toBe(1) + expect(await bonusLedgerCount(scope, 'race-U', 'race-ach')).toBe(1) + expect(await progressCurrent(scope, 'race-U', 'race-ach')).toBe(3) + }) + + it('zero-event type: all-zero summary with no writes', async () => { + const def = makeDef({ id: 'ze-ach', eventType: 'no_such_type', targetCount: 3, pointsValue: 10 }) + const summary = await backfill.backfillAchievement(scope, def) + expect(summary).toEqual({ usersEvaluated: 0, progressRaised: 0, unlocksGranted: 0, pointsAwarded: 0 }) + const { rows } = await db.$client.query( + `select count(*)::int as n from runtime.achievement_progress where project_id=$1 and environment=$2 and achievement_id='ze-ach'`, + [scope.projectId, scope.environment], + ) + expect(rows[0].n).toBe(0) + }) + + it('pointsValue 0: unlocks granted but no ledger rows written', async () => { + await ingestBareEvents(scope, 'zp-U', 'zp_type', 3, 'zpU') + const def = makeDef({ id: 'zp-ach', eventType: 'zp_type', targetCount: 3, pointsValue: 0 }) + + const summary = await backfill.backfillAchievement(scope, def) + expect(summary.unlocksGranted).toBe(1) + expect(summary.pointsAwarded).toBe(0) + expect(await unlockCount(scope, 'zp-U', 'zp-ach')).toBe(1) + expect(await bonusLedgerCount(scope, 'zp-U', 'zp-ach')).toBe(0) + }) + + it('cross-tenant isolation: a backfill sees only its own tenant\'s events', async () => { + const p2: Scope = { projectId: 'p2-iso', environment: 'test' } + // Events for this type exist only under `scope` (p1), not p2. + await ingestBareEvents(scope, 'iso-U', 'iso_type', 4, 'isoU') + const def = makeDef({ id: 'iso-ach', eventType: 'iso_type', targetCount: 3, pointsValue: 25 }) + + const p2Summary = await backfill.backfillAchievement(p2, def) + expect(p2Summary).toEqual({ usersEvaluated: 0, progressRaised: 0, unlocksGranted: 0, pointsAwarded: 0 }) + expect(await unlockCount(p2, 'iso-U', 'iso-ach')).toBe(0) + + // p1's own backfill still works and is unaffected by the p2 run. + const p1Summary = await backfill.backfillAchievement(scope, def) + expect(p1Summary).toEqual({ usersEvaluated: 1, progressRaised: 1, unlocksGranted: 1, pointsAwarded: 25 }) + }) +}) diff --git a/packages/adapter-db/test/stats.test.ts b/packages/adapter-db/test/stats.test.ts index 6ab99b0..61f64cb 100644 --- a/packages/adapter-db/test/stats.test.ts +++ b/packages/adapter-db/test/stats.test.ts @@ -149,3 +149,42 @@ describe('PgStatsStore', () => { expect(stats.offers).toEqual([{ offerId: 'o1', impressions: 1, clicks: 0 }]) }) }) + +describe('PgStatsStore multi-window (recurring occurrences)', () => { + // Isolated tenant so these inserts don't perturb the shared p1/p2 fixtures. + const p3: Scope = { projectId: 'p3', environment: 'test' } + // One recurring event 'multi' with two occurrence windows: [d1,d2] and [d3,d4]. + const wa = { eventId: 'multi', startsAt: d1, endsAt: d2 } + const wb = { eventId: 'multi', startsAt: d3, endsAt: d4 } + + beforeAll(async () => { + const insertEvent = (userId: string, idem: string, occurredAt: Date) => + db.$client.query( + `insert into runtime.events (project_id, environment, user_id, type, idempotency_key, occurred_at) values ($1,$2,$3,$4,$5,$6)`, + [p3.projectId, p3.environment, userId, 'lesson_completed', idem, occurredAt], + ) + // u-both is active in BOTH occurrence windows; u-a only in the first, u-b only in the second. + await insertEvent('u-both', 'm1', d1) + await insertEvent('u-both', 'm2', d3) + await insertEvent('u-a', 'm3', d2) + await insertEvent('u-b', 'm4', d4) + }) + + it('counts a user active in two windows of the same event exactly once', async () => { + const store = new PgStatsStore(db) + const stats = await store.getStats(p3, { from: null, to: null }, [wa, wb]) + + // Two windows collapse to a single 'multi' row; u-both counted once → 3 distinct participants. + expect(stats.timedEvents).toEqual([{ eventId: 'multi', participants: 3 }]) + // Union of both windows is likewise 3 (u-both, u-a, u-b). + expect(stats.totals.timedEventParticipants).toBe(3) + }) + + it('counts users active in different windows of the same event (both included)', async () => { + const store = new PgStatsStore(db) + // Restrict range to the SECOND window only: u-both (d3) and u-b (d4) qualify; u-a (d2) drops. + const stats = await store.getStats(p3, { from: d3, to: d4 }, [wa, wb]) + expect(stats.timedEvents).toEqual([{ eventId: 'multi', participants: 2 }]) + expect(stats.totals.timedEventParticipants).toBe(2) + }) +}) diff --git a/packages/adapter-db/test/webhook-delivery.test.ts b/packages/adapter-db/test/webhook-delivery.test.ts index e185166..b825e25 100644 --- a/packages/adapter-db/test/webhook-delivery.test.ts +++ b/packages/adapter-db/test/webhook-delivery.test.ts @@ -15,10 +15,50 @@ afterAll(async () => { await db.$client.end(); await container.stop() }) describe('PgWebhookDeliveryStore', () => { it('claims a transition exactly once', async () => { const store = new PgWebhookDeliveryStore(db) - expect(await store.claimTransition('p1', 'e1', 'live')).toBe(true) - expect(await store.claimTransition('p1', 'e1', 'live')).toBe(false) - expect(await store.claimTransition('p1', 'e1', 'ended')).toBe(true) - expect(await store.claimTransition('p2', 'e1', 'live')).toBe(true) + expect(await store.claimTransition('p1', 'e1', '', 'live')).toBe(true) + expect(await store.claimTransition('p1', 'e1', '', 'live')).toBe(false) + expect(await store.claimTransition('p1', 'e1', '', 'ended')).toBe(true) + expect(await store.claimTransition('p2', 'e1', '', 'live')).toBe(true) + }) + + it('claims the same (project, event, transition) independently per occurrence key', async () => { + const store = new PgWebhookDeliveryStore(db) + // Two occurrences of a recurring event: same project/event/transition, different keys. + const k1 = '2026-01-01T00:00:00.000Z' + const k2 = '2026-01-08T00:00:00.000Z' + expect(await store.claimTransition('p-occ', 'rec', k1, 'live')).toBe(true) + expect(await store.claimTransition('p-occ', 'rec', k2, 'live')).toBe(true) + // Each key is claimable exactly once. + expect(await store.claimTransition('p-occ', 'rec', k1, 'live')).toBe(false) + expect(await store.claimTransition('p-occ', 'rec', k2, 'live')).toBe(false) + // The empty-key ('' — a non-recurring occurrence) coexists with ISO-keyed claims. + expect(await store.claimTransition('p-occ', 'rec', '', 'live')).toBe(true) + expect(await store.claimTransition('p-occ', 'rec', '', 'live')).toBe(false) + const { rows } = await db.$client.query( + `select count(*)::int as n from runtime.timed_event_notifications where project_id='p-occ' and event_id='rec' and transition='live'`, + ) + expect(rows[0].n).toBe(3) + }) + + it('markDelivered and incrementAttempts hit only the addressed occurrence key', async () => { + const store = new PgWebhookDeliveryStore(db) + const kA = 'occ-A' + const kB = 'occ-B' + await store.claimTransition('p-key', 'e-key', kA, 'live') + await store.claimTransition('p-key', 'e-key', kB, 'live') + + // Deliver only kA; kB stays undelivered. + await store.markDelivered('p-key', 'e-key', kA, 'live') + // Increment attempts only on kB; kA stays at 0. + await store.incrementAttempts('p-key', 'e-key', kB, 'live') + + const { rows } = await db.$client.query( + `select occurrence_key, delivered_at, attempts from runtime.timed_event_notifications where project_id='p-key' and event_id='e-key' and transition='live' order by occurrence_key`, + ) + expect(rows.map((r: any) => ({ occurrence_key: r.occurrence_key, delivered: r.delivered_at !== null, attempts: r.attempts }))).toEqual([ + { occurrence_key: 'occ-A', delivered: true, attempts: 0 }, + { occurrence_key: 'occ-B', delivered: false, attempts: 1 }, + ]) }) it('records dead letters', async () => { const store = new PgWebhookDeliveryStore(db) @@ -29,8 +69,8 @@ describe('PgWebhookDeliveryStore', () => { it('markDelivered sets delivered_at on the claim row and is idempotent', async () => { const store = new PgWebhookDeliveryStore(db) - await store.claimTransition('p-md', 'e-md', 'live') - await store.markDelivered('p-md', 'e-md', 'live') + await store.claimTransition('p-md', 'e-md', '', 'live') + await store.markDelivered('p-md', 'e-md', '', 'live') const { rows: rows1 } = await db.$client.query( `select delivered_at from runtime.timed_event_notifications where project_id='p-md' and event_id='e-md' and transition='live'`, ) @@ -39,7 +79,7 @@ describe('PgWebhookDeliveryStore', () => { // Idempotent: calling again on an already-delivered row is a no-op update, not an error. // Wait a bit to ensure any clock advancement would be visible if idempotency failed. await new Promise((resolve) => setTimeout(resolve, 20)) - await store.markDelivered('p-md', 'e-md', 'live') + await store.markDelivered('p-md', 'e-md', '', 'live') const { rows: rows2 } = await db.$client.query( `select delivered_at from runtime.timed_event_notifications where project_id='p-md' and event_id='e-md' and transition='live'`, ) @@ -50,9 +90,9 @@ describe('PgWebhookDeliveryStore', () => { it('incrementAttempts increments the attempts counter', async () => { const store = new PgWebhookDeliveryStore(db) - await store.claimTransition('p-ia', 'e-ia', 'live') - await store.incrementAttempts('p-ia', 'e-ia', 'live') - await store.incrementAttempts('p-ia', 'e-ia', 'live') + await store.claimTransition('p-ia', 'e-ia', '', 'live') + await store.incrementAttempts('p-ia', 'e-ia', '', 'live') + await store.incrementAttempts('p-ia', 'e-ia', '', 'live') const { rows } = await db.$client.query( `select attempts from runtime.timed_event_notifications where project_id='p-ia' and event_id='e-ia' and transition='live'`, ) @@ -80,15 +120,25 @@ describe('PgWebhookDeliveryStore', () => { `insert into runtime.timed_event_notifications (project_id, event_id, transition, fired_at, delivered_at, attempts) values ('p-sc','exhausted','live',$1,null,5)`, [old], ) - // stale: old, undelivered, attempts < maxAttempts -> included + // stale: old, undelivered, attempts < maxAttempts -> included (default '' occurrence key) await db.$client.query( `insert into runtime.timed_event_notifications (project_id, event_id, transition, fired_at, delivered_at, attempts) values ('p-sc','stale','live',$1,null,1)`, [old], ) + // stale with an explicit occurrence key -> included, and its key comes back on the row. + await db.$client.query( + `insert into runtime.timed_event_notifications (project_id, event_id, occurrence_key, transition, fired_at, delivered_at, attempts) values ('p-sc','stale','occ-1','live',$1,null,2)`, + [old], + ) const staleClaims = await store.findStaleClaims(cutoff, 5) - const staleForScope = staleClaims.filter((c) => c.projectId === 'p-sc') - expect(staleForScope).toEqual([{ projectId: 'p-sc', eventId: 'stale', transition: 'live', attempts: 1 }]) + const staleForScope = staleClaims + .filter((c) => c.projectId === 'p-sc') + .sort((a, b) => a.occurrenceKey.localeCompare(b.occurrenceKey)) + expect(staleForScope).toEqual([ + { projectId: 'p-sc', eventId: 'stale', occurrenceKey: '', transition: 'live', attempts: 1 }, + { projectId: 'p-sc', eventId: 'stale', occurrenceKey: 'occ-1', transition: 'live', attempts: 2 }, + ]) }) it('findExhaustedClaims returns only undelivered rows at or above minAttempts', async () => { @@ -148,15 +198,15 @@ describe('migration 0005 — delivered_at backfill', () => { // Simulates a claim made before migration 0004 introduced delivered_at: null it out // via raw SQL exactly as it would appear on an existing database pre-upgrade. - await store.claimTransition('p-bf', 'e-null', 'live') + await store.claimTransition('p-bf', 'e-null', '', 'live') await db.$client.query( `update runtime.timed_event_notifications set fired_at = $1, delivered_at = null where project_id='p-bf' and event_id='e-null' and transition='live'`, [firedAt], ) // An already-delivered row must be untouched by the backfill. - await store.claimTransition('p-bf', 'e-delivered', 'live') - await store.markDelivered('p-bf', 'e-delivered', 'live') + await store.claimTransition('p-bf', 'e-delivered', '', 'live') + await store.markDelivered('p-bf', 'e-delivered', '', 'live') const { rows: beforeRows } = await db.$client.query( `select delivered_at from runtime.timed_event_notifications where project_id='p-bf' and event_id='e-delivered' and transition='live'`, ) From 7f3e1c8c7df3a8c93534c586a0163ca05e8d3e32 Mon Sep 17 00:00:00 2001 From: Steve Hynding Date: Thu, 9 Jul 2026 13:13:01 -0700 Subject: [PATCH 06/12] feat(cms): timed-event recurrence fields, validation, recurring-aware scan feed, seed --- .../config-plane/controllers/config-plane.ts | 21 +++++-- .../content-types/timed-event/lifecycles.ts | 63 +++++++++++++++++++ .../content-types/timed-event/schema.json | 3 +- apps/cms/src/index.ts | 16 +++++ apps/cms/types/generated/contentTypes.d.ts | 7 ++- 5 files changed, 104 insertions(+), 6 deletions(-) create mode 100644 apps/cms/src/api/timed-event/content-types/timed-event/lifecycles.ts diff --git a/apps/cms/src/api/config-plane/controllers/config-plane.ts b/apps/cms/src/api/config-plane/controllers/config-plane.ts index 44cb1a9..8b05514 100644 --- a/apps/cms/src/api/config-plane/controllers/config-plane.ts +++ b/apps/cms/src/api/config-plane/controllers/config-plane.ts @@ -74,18 +74,29 @@ export default { endingSoonMinutes: r.endingSoonMinutes, multiplier: r.multiplier, enabled: r.enabled, + recurrence: r.recurrence ?? 'none', + recurrenceEndsAt: r.recurrenceEndsAt ?? null, })), } }, async timedEventsAll(ctx: any) { if (!configSecretOk(ctx)) return ctx.unauthorized() - // ?endedWithinMinutes=: excludes events with endsAt < now - N minutes. - // Absent or invalid (non-integer, zero, negative) -> unfiltered, for backward compatibility. + // ?endedWithinMinutes=: excludes events with endsAt < now - N minutes — + // UNLESS the event is recurring and its recurrence hasn't ended yet (recurrenceEndsAt is + // null or still in the future). A months-old weekly event's occurrence-0 endsAt is ancient; + // without this OR-branch the scheduler would never see it again. Absent or invalid + // (non-integer, zero, negative) endedWithinMinutes -> unfiltered, for backward compatibility. const rawParam = String(ctx.query.endedWithinMinutes ?? '') const filters: Record = {} if (/^[1-9][0-9]*$/.test(rawParam)) { - const cutoff = new Date(Date.now() - Number(rawParam) * 60_000) - filters.endsAt = { $gte: cutoff.toISOString() } + const cutoff = new Date(Date.now() - Number(rawParam) * 60_000).toISOString() + filters.$or = [ + { endsAt: { $gte: cutoff } }, + { + recurrence: { $ne: 'none' }, + $or: [{ recurrenceEndsAt: { $null: true } }, { recurrenceEndsAt: { $gte: cutoff } }], + }, + ] } const rows = await strapi.documents('api::timed-event.timed-event').findMany({ filters, @@ -103,6 +114,8 @@ export default { endingSoonMinutes: r.endingSoonMinutes, multiplier: r.multiplier, enabled: r.enabled, + recurrence: r.recurrence ?? 'none', + recurrenceEndsAt: r.recurrenceEndsAt ?? null, projectId: r.project.documentId, })), } diff --git a/apps/cms/src/api/timed-event/content-types/timed-event/lifecycles.ts b/apps/cms/src/api/timed-event/content-types/timed-event/lifecycles.ts new file mode 100644 index 0000000..888f161 --- /dev/null +++ b/apps/cms/src/api/timed-event/content-types/timed-event/lifecycles.ts @@ -0,0 +1,63 @@ +import { errors } from '@strapi/utils' + +const MS_PER_DAY = 86_400_000 +const INTERVAL_MS: Record = { + daily: MS_PER_DAY, + weekly: 7 * MS_PER_DAY, + monthly: 28 * MS_PER_DAY, +} + +function fail(message: string): never { + throw new errors.ValidationError(message) +} + +// Merge incoming (possibly partial, on update) data over the current row so +// cross-field validation always sees the resulting full record. +async function loadCurrent(event: any): Promise> { + const where = event.params.where + if (!where) return {} + const existing = await strapi.db.query('api::timed-event.timed-event').findOne({ where }) + return existing ?? {} +} + +function validate(merged: Record) { + const startsAt = merged.startsAt + const endsAt = merged.endsAt + const startsAtMs = startsAt != null ? new Date(startsAt).getTime() : null + const endsAtMs = endsAt != null ? new Date(endsAt).getTime() : null + + if (startsAtMs != null && endsAtMs != null) { + if (!(endsAtMs > startsAtMs)) { + fail('endsAt must be after startsAt') + } + } + + const recurrence = merged.recurrence ?? 'none' + if (recurrence !== 'none') { + const intervalMs = INTERVAL_MS[recurrence] + if (intervalMs != null && startsAtMs != null && endsAtMs != null) { + if (endsAtMs - startsAtMs > intervalMs) { + fail(`endsAt - startsAt must be at most ${intervalMs}ms for recurrence "${recurrence}"`) + } + } + + const recurrenceEndsAt = merged.recurrenceEndsAt + if (recurrenceEndsAt != null && startsAtMs != null) { + if (!(new Date(recurrenceEndsAt).getTime() > startsAtMs)) { + fail('recurrenceEndsAt must be after startsAt') + } + } + } +} + +export default { + async beforeCreate(event: any) { + const merged = { ...event.params.data } + validate(merged) + }, + async beforeUpdate(event: any) { + const current = await loadCurrent(event) + const merged = { ...current, ...event.params.data } + validate(merged) + }, +} diff --git a/apps/cms/src/api/timed-event/content-types/timed-event/schema.json b/apps/cms/src/api/timed-event/content-types/timed-event/schema.json index 911b12c..371c6ce 100644 --- a/apps/cms/src/api/timed-event/content-types/timed-event/schema.json +++ b/apps/cms/src/api/timed-event/content-types/timed-event/schema.json @@ -11,7 +11,8 @@ "endingSoonMinutes": { "type": "integer", "required": true, "min": 1, "default": 1440 }, "multiplier": { "type": "integer", "required": true, "min": 1, "default": 1 }, "enabled": { "type": "boolean", "required": true, "default": true }, - "recurrence": { "type": "json" }, + "recurrence": { "type": "enumeration", "enum": ["none", "daily", "weekly", "monthly"], "default": "none", "required": true }, + "recurrenceEndsAt": { "type": "datetime" }, "project": { "type": "relation", "relation": "manyToOne", "target": "api::project.project" } } } diff --git a/apps/cms/src/index.ts b/apps/cms/src/index.ts index d8bb7cb..4433599 100644 --- a/apps/cms/src/index.ts +++ b/apps/cms/src/index.ts @@ -100,6 +100,22 @@ export default { project: project.documentId, }, }) + const happyHourStartsAt = new Date(Date.now()) + happyHourStartsAt.setUTCHours(17, 0, 0, 0) + await strapi.documents('api::timed-event.timed-event').create({ + data: { + name: 'Weekly Happy Hour', + description: 'A recurring window of double points every week.', + startsAt: happyHourStartsAt, + endsAt: new Date(happyHourStartsAt.getTime() + 2 * 3600_000), + endingSoonMinutes: 30, + multiplier: 2, + enabled: true, + recurrence: 'weekly', + recurrenceEndsAt: null, + project: project.documentId, + }, + }) const rewards = [ { slug: 'welcome_coupon', diff --git a/apps/cms/types/generated/contentTypes.d.ts b/apps/cms/types/generated/contentTypes.d.ts index b505fd1..1b2c673 100644 --- a/apps/cms/types/generated/contentTypes.d.ts +++ b/apps/cms/types/generated/contentTypes.d.ts @@ -744,7 +744,12 @@ export interface ApiTimedEventTimedEvent extends Struct.CollectionTypeSchema { name: Schema.Attribute.String & Schema.Attribute.Required; project: Schema.Attribute.Relation<'manyToOne', 'api::project.project'>; publishedAt: Schema.Attribute.DateTime; - recurrence: Schema.Attribute.JSON; + recurrence: Schema.Attribute.Enumeration< + ['none', 'daily', 'weekly', 'monthly'] + > & + Schema.Attribute.Required & + Schema.Attribute.DefaultTo<'none'>; + recurrenceEndsAt: Schema.Attribute.DateTime; startsAt: Schema.Attribute.DateTime & Schema.Attribute.Required; updatedAt: Schema.Attribute.DateTime; updatedBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> & From 47fc2438cc215bdd45d01a657ae6954565834567 Mon Sep 17 00:00:00 2001 From: Steve Hynding Date: Thu, 9 Jul 2026 13:20:30 -0700 Subject: [PATCH 07/12] feat(adapter-strapi): timed-event recurrence parsing --- packages/adapter-strapi/src/index.ts | 4 ++ packages/adapter-strapi/src/schemas.ts | 2 + packages/adapter-strapi/test/adapter.test.ts | 61 +++++++++++++++++++- 3 files changed, 66 insertions(+), 1 deletion(-) diff --git a/packages/adapter-strapi/src/index.ts b/packages/adapter-strapi/src/index.ts index dfe0801..3db5162 100644 --- a/packages/adapter-strapi/src/index.ts +++ b/packages/adapter-strapi/src/index.ts @@ -170,6 +170,8 @@ export class StrapiConfigPlane implements ConfigStore, ApiKeyStore { endingSoonMinutes: e.endingSoonMinutes, multiplier: e.multiplier, enabled: e.enabled, + recurrence: e.recurrence, + recurrenceEndsAt: e.recurrenceEndsAt ? new Date(e.recurrenceEndsAt) : null, })) this.timedEventsCache.set(projectId, { value: events, expires: Date.now() + this.ttl }) return events @@ -203,6 +205,8 @@ export class StrapiConfigPlane implements ConfigStore, ApiKeyStore { endingSoonMinutes: e.endingSoonMinutes, multiplier: e.multiplier, enabled: e.enabled, + recurrence: e.recurrence, + recurrenceEndsAt: e.recurrenceEndsAt ? new Date(e.recurrenceEndsAt) : null, })) this.allTimedEventsCache.set(key, { value: events, expires: Date.now() + this.ttl }) return events diff --git a/packages/adapter-strapi/src/schemas.ts b/packages/adapter-strapi/src/schemas.ts index efc5946..6e08006 100644 --- a/packages/adapter-strapi/src/schemas.ts +++ b/packages/adapter-strapi/src/schemas.ts @@ -49,6 +49,8 @@ const timedEventFieldsSchema = z.object({ endingSoonMinutes: z.number().default(1440), multiplier: z.number().default(1), enabled: z.boolean(), + recurrence: z.enum(['none', 'daily', 'weekly', 'monthly']).default('none'), + recurrenceEndsAt: z.string().nullable().default(null), }) export const timedEventsResponseSchema = z.object({ diff --git a/packages/adapter-strapi/test/adapter.test.ts b/packages/adapter-strapi/test/adapter.test.ts index c5a7ea6..d8ce7e1 100644 --- a/packages/adapter-strapi/test/adapter.test.ts +++ b/packages/adapter-strapi/test/adapter.test.ts @@ -263,6 +263,7 @@ const timedEventsBody = { id: '1', name: 'Summer Sale', description: null, startsAt: '2026-07-01T00:00:00.000Z', endsAt: '2026-07-10T00:00:00.000Z', endingSoonMinutes: 60, multiplier: 2, enabled: true, + recurrence: 'weekly', recurrenceEndsAt: '2026-12-31T00:00:00.000Z', }], } @@ -274,9 +275,11 @@ describe('StrapiConfigPlane.getTimedEvents', () => { expect(events[0]).toMatchObject({ id: '1', name: 'Summer Sale', description: null, endingSoonMinutes: 60, multiplier: 2, enabled: true, + recurrence: 'weekly', }) expect(events[0].startsAt).toEqual(new Date('2026-07-01T00:00:00.000Z')) expect(events[0].endsAt).toEqual(new Date('2026-07-10T00:00:00.000Z')) + expect(events[0].recurrenceEndsAt).toEqual(new Date('2026-12-31T00:00:00.000Z')) }) it('serves stale cache when strapi errors after a successful fetch', async () => { const fetchImpl = vi.fn() @@ -291,6 +294,33 @@ describe('StrapiConfigPlane.getTimedEvents', () => { const plane = makePlane(vi.fn().mockImplementation(() => ok({ events: [{ id: '1' }] }))) await expect(plane.getTimedEvents('p1')).rejects.toThrow() }) + it('defaults recurrence to none and recurrenceEndsAt to null when absent (old cms back-compat)', async () => { + const body = { + events: [{ + id: '1', name: 'Summer Sale', description: null, + startsAt: '2026-07-01T00:00:00.000Z', endsAt: '2026-07-10T00:00:00.000Z', + endingSoonMinutes: 60, multiplier: 2, enabled: true, + }], + } + const fetchImpl = vi.fn().mockImplementation(() => ok(body)) + const events = await makePlane(fetchImpl).getTimedEvents('p1') + expect(events[0].recurrence).toBe('none') + expect(events[0].recurrenceEndsAt).toBeNull() + }) + it('serves stale cache when a bad recurrence value fails validation after expiry', async () => { + const fetchImpl = vi.fn() + .mockImplementationOnce(() => ok(timedEventsBody)) + .mockImplementation(() => ok({ events: [{ ...timedEventsBody.events[0], recurrence: 'yearly' }] })) + const plane = makePlane(fetchImpl, 0) // TTL 0: always expired + await plane.getTimedEvents('p1') + const events = await plane.getTimedEvents('p1') + expect(events[0].recurrence).toBe('weekly') // stale value, not the invalid one + }) + it('throws on a bad recurrence value with no cache', async () => { + const body = { events: [{ ...timedEventsBody.events[0], recurrence: 'yearly' }] } + const plane = makePlane(vi.fn().mockImplementation(() => ok(body))) + await expect(plane.getTimedEvents('p1')).rejects.toThrow() + }) }) const allTimedEventsBody = { @@ -298,6 +328,7 @@ const allTimedEventsBody = { id: '2', projectId: 'p1', name: 'Autumn Sale', description: 'desc', startsAt: '2026-09-01T00:00:00.000Z', endsAt: '2026-09-10T00:00:00.000Z', endingSoonMinutes: 1440, multiplier: 1, enabled: false, + recurrence: 'monthly', recurrenceEndsAt: null, }], } @@ -306,12 +337,40 @@ describe('StrapiConfigPlane.getAllTimedEvents', () => { const fetchImpl = vi.fn().mockImplementation(() => ok(allTimedEventsBody)) const events = await makePlane(fetchImpl).getAllTimedEvents() expect(String(fetchImpl.mock.calls[0][0])).toBe('http://cms.test/api/config-plane/timed-events/all') - expect(events[0]).toMatchObject({ id: '2', projectId: 'p1', name: 'Autumn Sale', enabled: false }) + expect(events[0]).toMatchObject({ id: '2', projectId: 'p1', name: 'Autumn Sale', enabled: false, recurrence: 'monthly' }) + expect(events[0].recurrenceEndsAt).toBeNull() }) it('throws on a malformed body with no cache', async () => { const plane = makePlane(vi.fn().mockImplementation(() => ok({ events: [{ id: '2' }] }))) await expect(plane.getAllTimedEvents()).rejects.toThrow() }) + it('defaults recurrence to none and recurrenceEndsAt to null when absent (old cms back-compat)', async () => { + const body = { + events: [{ + id: '2', projectId: 'p1', name: 'Autumn Sale', description: 'desc', + startsAt: '2026-09-01T00:00:00.000Z', endsAt: '2026-09-10T00:00:00.000Z', + endingSoonMinutes: 1440, multiplier: 1, enabled: false, + }], + } + const fetchImpl = vi.fn().mockImplementation(() => ok(body)) + const events = await makePlane(fetchImpl).getAllTimedEvents() + expect(events[0].recurrence).toBe('none') + expect(events[0].recurrenceEndsAt).toBeNull() + }) + it('serves stale cache when a bad recurrence value fails validation after expiry', async () => { + const fetchImpl = vi.fn() + .mockImplementationOnce(() => ok(allTimedEventsBody)) + .mockImplementation(() => ok({ events: [{ ...allTimedEventsBody.events[0], recurrence: 'yearly' }] })) + const plane = makePlane(fetchImpl, 0) // TTL 0: always expired + await plane.getAllTimedEvents() + const events = await plane.getAllTimedEvents() + expect(events[0].recurrence).toBe('monthly') // stale value, not the invalid one + }) + it('throws on a bad recurrence value with no cache', async () => { + const body = { events: [{ ...allTimedEventsBody.events[0], recurrence: 'yearly' }] } + const plane = makePlane(vi.fn().mockImplementation(() => ok(body))) + await expect(plane.getAllTimedEvents()).rejects.toThrow() + }) it('omits endedWithinMinutes when allTimedEventsEndedWithinMinutes is not configured', async () => { const fetchImpl = vi.fn().mockImplementation(() => ok(allTimedEventsBody)) const plane = new StrapiConfigPlane({ baseUrl: 'http://cms.test', configSecret: 's3cret', fetchImpl }) From c1da5729c776bd277369269254a2497b427f783c Mon Sep 17 00:00:00 2001 From: Steve Hynding Date: Thu, 9 Jul 2026 13:37:28 -0700 Subject: [PATCH 08/12] feat(api): per-occurrence scheduler and webhooks, occurrence-aware live feed and stats windows Co-Authored-By: Claude Fable 5 --- apps/api/src/openapi.ts | 1 + apps/api/src/routes/live-events.ts | 20 ++- apps/api/src/routes/stats.ts | 14 +- apps/api/src/webhooks.ts | 81 ++++++----- apps/api/test/stats.test.ts | 20 ++- apps/api/test/timed-events.test.ts | 55 +++++++- apps/api/test/webhooks.test.ts | 212 ++++++++++++++++++++++++----- 7 files changed, 320 insertions(+), 83 deletions(-) diff --git a/apps/api/src/openapi.ts b/apps/api/src/openapi.ts index 6309e1e..6d6a62a 100644 --- a/apps/api/src/openapi.ts +++ b/apps/api/src/openapi.ts @@ -175,6 +175,7 @@ export function buildOpenApiDocument(version: string) { '/v1/stats': { get: { summary: 'Aggregate project stats: totals, achievements, offers (with CTR), and timed events. Requires a secret key.', + description: 'For recurring timed events, participation is aggregated across every occurrence window intersecting the requested range, clamped to the most recent 400 occurrences per event.', parameters: [ { name: 'from', in: 'query', required: false, schema: { type: 'string', format: 'date-time' } }, { name: 'to', in: 'query', required: false, schema: { type: 'string', format: 'date-time' } }, diff --git a/apps/api/src/routes/live-events.ts b/apps/api/src/routes/live-events.ts index 1e605a5..37b6c4e 100644 --- a/apps/api/src/routes/live-events.ts +++ b/apps/api/src/routes/live-events.ts @@ -1,6 +1,6 @@ import { Hono } from 'hono' import type { LiveEventsResponse } from '@promocean/contracts' -import { timedEventState, type Scope } from '@promocean/core' +import { occurrenceWindow, timedEventState, type Scope } from '@promocean/core' import type { AppDeps } from '../app.js' export function liveEventsRoute(deps: AppDeps) { @@ -11,18 +11,24 @@ export function liveEventsRoute(deps: AppDeps) { const defs = await deps.configStore.getTimedEvents(scope.projectId) const now = new Date() const events = defs - .map((e) => ({ e, state: timedEventState(e, now) })) + .map((e) => ({ e, state: timedEventState(e, now), w: occurrenceWindow(e, now) })) + .filter((x): x is typeof x & { w: NonNullable } => x.w !== null) .filter(({ state }) => state === 'scheduled' || state === 'live' || state === 'ending_soon') - .map(({ e, state }) => ({ + .map(({ e, state, w }) => ({ eventId: e.id, name: e.name, description: e.description, state: state as 'scheduled' | 'live' | 'ending_soon', - startsAt: e.startsAt.toISOString(), - endsAt: e.endsAt.toISOString(), + // The CURRENT (or next) occurrence's window, not the definition's own bounds. + startsAt: w.startsAt.toISOString(), + endsAt: w.endsAt.toISOString(), multiplier: e.multiplier, - secondsUntilStart: state === 'scheduled' ? Math.ceil((e.startsAt.getTime() - now.getTime()) / 1000) : null, - secondsUntilEnd: Math.ceil((e.endsAt.getTime() - now.getTime()) / 1000), + secondsUntilStart: state === 'scheduled' ? Math.ceil((w.startsAt.getTime() - now.getTime()) / 1000) : null, + secondsUntilEnd: Math.ceil((w.endsAt.getTime() - now.getTime()) / 1000), + recurrence: e.recurrence, + // Evaluating occurrenceWindow AT w.endsAt yields the occurrence after this one, because + // window containment is end-exclusive; 'none' events return null there (no next). + nextOccurrenceStartsAt: occurrenceWindow(e, w.endsAt)?.startsAt.toISOString() ?? null, })) return c.json({ events } satisfies LiveEventsResponse) }) diff --git a/apps/api/src/routes/stats.ts b/apps/api/src/routes/stats.ts index 15d0276..f515aed 100644 --- a/apps/api/src/routes/stats.ts +++ b/apps/api/src/routes/stats.ts @@ -1,6 +1,6 @@ import { Hono } from 'hono' import { statsQuerySchema, type StatsResponse } from '@promocean/contracts' -import type { Scope, TimedEventDefinition } from '@promocean/core' +import { occurrenceWindowsInRange, type Scope, type TimedEventDefinition } from '@promocean/core' import type { AppDeps } from '../app.js' import { logger } from '../logger.js' @@ -32,7 +32,17 @@ export function statsRoute(deps: AppDeps) { { err }, 'timed events fetch failed; stats serving with empty timed-event windows', ) } - const windows = timedEventDefs.map((e) => ({ eventId: e.id, startsAt: e.startsAt, endsAt: e.endsAt })) + // Enumerate each event's occurrence windows intersecting the range (recurring events + // contribute one window per occurrence). occurrenceWindowsInRange clamps to the most recent + // 400 windows per event; the core defaults nulls here to the event's start / now. + const now = new Date() + const windows = timedEventDefs.flatMap((e) => + occurrenceWindowsInRange(e, from ?? e.startsAt, to ?? now).map((w) => ({ + eventId: e.id, + startsAt: w.startsAt, + endsAt: w.endsAt, + })), + ) const stats = await deps.statsStore.getStats(scope, { from, to }, windows) diff --git a/apps/api/src/webhooks.ts b/apps/api/src/webhooks.ts index a0cc54b..9efa2fe 100644 --- a/apps/api/src/webhooks.ts +++ b/apps/api/src/webhooks.ts @@ -1,7 +1,7 @@ import { createHmac, randomUUID } from 'node:crypto' import type { Logger } from 'pino' import { WEBHOOK_SIGNATURE_HEADER, type WebhookMessage } from '@promocean/contracts' -import { timedEventState, type ConfigStore, type TimedEventDefinition, type TimedEventTransition, type WebhookDeliveryStore, type WebhookEndpointDefinition } from '@promocean/core' +import { occurrenceFromKey, transitionOccurrence, type ConfigStore, type OccurrenceWindow, type TimedEventDefinition, type TimedEventTransition, type WebhookDeliveryStore, type WebhookEndpointDefinition } from '@promocean/core' import { logger as rootLogger } from './logger.js' const BASE_BACKOFF_MS = 250 @@ -55,11 +55,12 @@ export class WebhookDispatcher { async deliverTransition( projectId: string, eventId: string, + occurrenceKey: string, transition: TimedEventTransition, message: WebhookMessage, ): Promise { await this.deliver(projectId, message) - await this.deliveryStore.markDelivered(projectId, eventId, transition) + await this.deliveryStore.markDelivered(projectId, eventId, occurrenceKey, transition) } private async deliverToEndpoint(projectId: string, endpoint: WebhookEndpointDefinition, rawBody: string): Promise { @@ -102,36 +103,44 @@ export class WebhookDispatcher { } } -function reachedTransitions(state: ReturnType): TimedEventTransition[] { - switch (state) { - case 'live': - return ['live'] - case 'ending_soon': - return ['live', 'ending_soon'] - case 'ended': - return ['live', 'ending_soon', 'ended'] - default: - return [] - } +/** + * Transitions reached for a specific occurrence window as of `now` — the scheduler's view. + * Mirrors the state cascade (ended implies ending_soon implies live) but is evaluated against + * the occurrence's own bounds rather than the definition's, so between occurrences the + * just-elapsed occurrence (from transitionOccurrence) can still fire its 'ended' transition. + */ +function reachedTransitionsFor(occ: OccurrenceWindow, now: Date, endingSoonMinutes: number): TimedEventTransition[] { + const nowMs = now.getTime() + if (nowMs >= occ.endsAt.getTime()) return ['live', 'ending_soon', 'ended'] + if (occ.endsAt.getTime() - nowMs <= endingSoonMinutes * 60_000) return ['live', 'ending_soon'] + if (nowMs >= occ.startsAt.getTime()) return ['live'] + return [] } /** Builds a fresh transition message. Called with a new messageId on every delivery attempt — - * including redeliveries, which consumers must treat as a distinct message to dedup against. */ + * including redeliveries, which consumers must treat as a distinct message to dedup against. + * `data.startsAt`/`data.endsAt` stay the DEFINITION's values; for recurring events the specific + * occurrence's window is carried additively in `data.occurrence`. */ function buildTransitionMessage( event: TimedEventDefinition & { projectId: string }, + occ: OccurrenceWindow, transition: TimedEventTransition, now: Date, ): WebhookMessage { + const data: Record = { + eventId: event.id, + name: event.name, + startsAt: event.startsAt.toISOString(), + endsAt: event.endsAt.toISOString(), + multiplier: event.multiplier, + } + if (event.recurrence !== 'none') { + data.occurrence = { startsAt: occ.startsAt.toISOString(), endsAt: occ.endsAt.toISOString() } + } return { messageId: randomUUID(), type: `timed_event.${transition}`, - data: { - eventId: event.id, - name: event.name, - startsAt: event.startsAt.toISOString(), - endsAt: event.endsAt.toISOString(), - multiplier: event.multiplier, - }, + data, createdAt: now.toISOString(), } } @@ -195,12 +204,14 @@ export function startLifecycleScheduler(opts: { try { const events = await configStore.getAllTimedEvents() for (const event of events) { - const state = timedEventState(event, now) - const transitions = reachedTransitions(state) + if (!event.enabled) continue // draft fires nothing + const occ = transitionOccurrence(event, now) + if (!occ) continue + const transitions = reachedTransitionsFor(occ, now, event.endingSoonMinutes) for (const transition of transitions) { - const claimed = await deliveryStore.claimTransition(event.projectId, event.id, transition) + const claimed = await deliveryStore.claimTransition(event.projectId, event.id, occ.key, transition) if (!claimed) continue - await dispatcher.deliverTransition(event.projectId, event.id, transition, buildTransitionMessage(event, transition, now)) + await dispatcher.deliverTransition(event.projectId, event.id, occ.key, transition, buildTransitionMessage(event, occ, transition, now)) } } } catch (err) { @@ -213,23 +224,25 @@ export function startLifecycleScheduler(opts: { const eventByKey = new Map(events.map((event) => [`${event.projectId}:${event.id}`, event])) const staleClaims = await deliveryStore.findStaleClaims(new Date(now.getTime() - redeliveryGraceMs), MAX_REDELIVERY_ATTEMPTS) for (const claim of staleClaims) { - await deliveryStore.incrementAttempts(claim.projectId, claim.eventId, claim.transition) + await deliveryStore.incrementAttempts(claim.projectId, claim.eventId, claim.occurrenceKey, claim.transition) const event = eventByKey.get(`${claim.projectId}:${claim.eventId}`) - if (!event) { - // The event definition scrolled out of the scan window (or was deleted) before we - // could redrive it — nothing left to rebuild the message from. Dead-letter it and - // stop retrying rather than leaving it stale forever. + // Rebuild the occurrence from the claim's key: null means the event definition scrolled + // out of the scan window / was deleted, or its recurrence changed so the key no longer + // lands on an existing occurrence. Either way there is nothing left to rebuild the + // message from — dead-letter it and stop retrying rather than leaving it stale forever. + const occ = event ? occurrenceFromKey(event, claim.occurrenceKey) : null + if (!event || !occ) { await deliveryStore.recordDeadLetter( claim.projectId, '', JSON.stringify(claim), - 'event definition no longer in scan window', + event ? 'occurrence key no longer resolves to an occurrence' : 'event definition no longer in scan window', now, ) - await deliveryStore.markDelivered(claim.projectId, claim.eventId, claim.transition) + await deliveryStore.markDelivered(claim.projectId, claim.eventId, claim.occurrenceKey, claim.transition) continue } - await dispatcher.deliverTransition(claim.projectId, claim.eventId, claim.transition, buildTransitionMessage(event, claim.transition, now)) + await dispatcher.deliverTransition(claim.projectId, claim.eventId, claim.occurrenceKey, claim.transition, buildTransitionMessage(event, occ, claim.transition, now)) } } catch (err) { logger.error({ err }, 'lifecycle scheduler: redelivery sweep failed') @@ -249,7 +262,7 @@ export function startLifecycleScheduler(opts: { 'redelivery attempts exhausted', now, ) - await deliveryStore.markDelivered(claim.projectId, claim.eventId, claim.transition) + await deliveryStore.markDelivered(claim.projectId, claim.eventId, claim.occurrenceKey, claim.transition) logger.warn({ claim }, 'lifecycle scheduler: redelivery attempts exhausted, dead-lettering claim') } catch (err) { logger.error({ err, claim }, 'lifecycle scheduler: failed to dead-letter exhausted claim') diff --git a/apps/api/test/stats.test.ts b/apps/api/test/stats.test.ts index b66ae0a..a3f0382 100644 --- a/apps/api/test/stats.test.ts +++ b/apps/api/test/stats.test.ts @@ -8,7 +8,7 @@ const timedEvents = [ { id: 'te1', name: 'Summer Sprint', description: null, startsAt: new Date('2026-01-01T00:00:00.000Z'), endsAt: new Date('2026-01-08T00:00:00.000Z'), - endingSoonMinutes: 60, multiplier: 2, enabled: true, + endingSoonMinutes: 60, multiplier: 2, enabled: true, recurrence: 'none' as const, recurrenceEndsAt: null, }, ] const headers = { authorization: 'Bearer pk_test_valid_key_1', 'content-type': 'application/json' } @@ -79,6 +79,24 @@ describe('GET /v1/stats', () => { expect(fakes.statsCalls[0]!.timedEventWindows).toEqual([{ eventId: 'te1', startsAt: timedEvents[0]!.startsAt, endsAt: timedEvents[0]!.endsAt }]) }) + it('recurring event yields one occurrence window per occurrence intersecting the range', async () => { + const daily = [{ + id: 'te1', name: 'Daily', description: null, + startsAt: new Date('2026-01-01T00:00:00.000Z'), endsAt: new Date('2026-01-01T01:00:00.000Z'), + endingSoonMinutes: 60, multiplier: 2, enabled: true, recurrence: 'daily' as const, recurrenceEndsAt: null, + }] + const fakes = makeFakes([], skAuth(), [], daily) + const app = createApp(fakes, { rateLimitPerMinute: 0 }) + // range spans occ0 (Jan 1) and occ1 (Jan 2 00:00 < 00:30), stopping before occ2. + const res = await app.request('/v1/stats?from=2026-01-01T00:00:00.000Z&to=2026-01-02T00:30:00.000Z', { headers }) + expect(res.status).toBe(200) + expect(fakes.statsCalls).toHaveLength(1) + expect(fakes.statsCalls[0]!.timedEventWindows).toEqual([ + { eventId: 'te1', startsAt: new Date('2026-01-01T00:00:00.000Z'), endsAt: new Date('2026-01-01T01:00:00.000Z') }, + { eventId: 'te1', startsAt: new Date('2026-01-02T00:00:00.000Z'), endsAt: new Date('2026-01-02T01:00:00.000Z') }, + ]) + }) + it('config-store failure -> 200 with empty timedEvents (stats still serve)', async () => { const { app, fakes } = setup(skAuth()) fakes.configStore.getTimedEvents = async () => { throw new Error('config plane down') } diff --git a/apps/api/test/timed-events.test.ts b/apps/api/test/timed-events.test.ts index 96f1c6f..59a7c37 100644 --- a/apps/api/test/timed-events.test.ts +++ b/apps/api/test/timed-events.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { OfferDefinition, TimedEventDefinition } from '@promocean/core' import { createApp } from '../src/app.js' import { logger } from '../src/logger.js' @@ -7,7 +7,7 @@ import { makeFakes } from './fakes.js' const mk = (over: Partial = {}): TimedEventDefinition => ({ id: 'e1', name: 'Summer Sale', description: null, startsAt: new Date('2026-07-01T00:00:00Z'), endsAt: new Date('2026-07-31T00:00:00Z'), - endingSoonMinutes: 1440, multiplier: 2, enabled: true, ...over, + endingSoonMinutes: 1440, multiplier: 2, enabled: true, recurrence: 'none', recurrenceEndsAt: null, ...over, }) const defs = [ @@ -89,6 +89,57 @@ describe('GET /v1/events/live', () => { expect(typeof scheduledEvent.secondsUntilEnd).toBe('number') expect(scheduledEvent.secondsUntilEnd).toBeGreaterThan(0) }) + + it('non-recurring event carries recurrence "none" and a null nextOccurrenceStartsAt', async () => { + const live = mk({ id: 'live1', startsAt: new Date('2026-07-01T00:00:00Z'), endsAt: new Date('2026-07-20T00:00:00Z'), endingSoonMinutes: 60 }) + const fakes = makeFakes([], auth, [], [live]) + const app = createApp(fakes) + const res = await app.request('/v1/events/live', { headers }) + const json = await res.json() + const e = json.events.find((x: { eventId: string }) => x.eventId === 'live1') + expect(e.recurrence).toBe('none') + expect(e.nextOccurrenceStartsAt).toBeNull() + // non-recurring bounds are unchanged (the definition's own window) + expect(e.startsAt).toBe('2026-07-01T00:00:00.000Z') + expect(e.endsAt).toBe('2026-07-20T00:00:00.000Z') + }) +}) + +describe('GET /v1/events/live — recurring occurrences', () => { + beforeEach(() => { vi.useFakeTimers() }) + afterEach(() => { vi.useRealTimers() }) + + // daily, 1-hour occurrences: occ0 Jul1 00:00-01:00, occ1 Jul2, occ2 Jul3, ... + const daily = () => mk({ id: 'd1', recurrence: 'daily', startsAt: new Date('2026-07-01T00:00:00Z'), endsAt: new Date('2026-07-01T01:00:00Z'), endingSoonMinutes: 10 }) + + it('between occurrences: reports the NEXT window as scheduled with the following occurrence as nextOccurrenceStartsAt', async () => { + vi.setSystemTime(new Date('2026-07-01T02:00:00Z')) // after occ0 ended, before occ1 starts + const fakes = makeFakes([], auth, [], [daily()]) + const app = createApp(fakes) + const res = await app.request('/v1/events/live', { headers }) + const json = await res.json() + const e = json.events.find((x: { eventId: string }) => x.eventId === 'd1') + expect(e.state).toBe('scheduled') + expect(e.recurrence).toBe('daily') + expect(e.startsAt).toBe('2026-07-02T00:00:00.000Z') // the next occurrence window + expect(e.endsAt).toBe('2026-07-02T01:00:00.000Z') + expect(e.nextOccurrenceStartsAt).toBe('2026-07-03T00:00:00.000Z') // the one after that + expect(e.secondsUntilStart).toBeGreaterThan(0) + }) + + it('inside an occurrence: reports that occurrence live with its own bounds and the next occurrence start', async () => { + vi.setSystemTime(new Date('2026-07-02T00:30:00Z')) // inside occ1's live window + const fakes = makeFakes([], auth, [], [daily()]) + const app = createApp(fakes) + const res = await app.request('/v1/events/live', { headers }) + const json = await res.json() + const e = json.events.find((x: { eventId: string }) => x.eventId === 'd1') + expect(e.state).toBe('live') + expect(e.startsAt).toBe('2026-07-02T00:00:00.000Z') + expect(e.endsAt).toBe('2026-07-02T01:00:00.000Z') + expect(e.secondsUntilStart).toBeNull() + expect(e.nextOccurrenceStartsAt).toBe('2026-07-03T00:00:00.000Z') + }) }) describe('GET /v1/placements/:slug/offer — event-gated offers', () => { diff --git a/apps/api/test/webhooks.test.ts b/apps/api/test/webhooks.test.ts index 6fcbd6b..ec36628 100644 --- a/apps/api/test/webhooks.test.ts +++ b/apps/api/test/webhooks.test.ts @@ -7,28 +7,35 @@ import { WebhookDispatcher, resolveScanGraceMinutes, startLifecycleScheduler } f import { createApp } from '../src/app.js' import { makeFakes } from './fakes.js' +type ClaimRow = { projectId: string; eventId: string; occurrenceKey: string; transition: string } + function makeDeliveryStore() { const claimed = new Set() + const claims: ClaimRow[] = [] + const marked: ClaimRow[] = [] const deadLetters: Array<{ projectId: string; url: string; payload: string; error: string; at: Date }> = [] const deliveryStore: WebhookDeliveryStore = { - claimTransition: async (projectId, eventId, transition) => { - const key = `${projectId}:${eventId}:${transition}` + claimTransition: async (projectId, eventId, occurrenceKey, transition) => { + const key = `${projectId}:${eventId}:${occurrenceKey}:${transition}` if (claimed.has(key)) return false claimed.add(key) + claims.push({ projectId, eventId, occurrenceKey, transition }) return true }, recordDeadLetter: async (projectId, url, payload, error, at) => { deadLetters.push({ projectId, url, payload, error, at }) }, - // Safe no-op defaults for tests that don't exercise redelivery/retention directly — - // individual tests below override whichever of these they need to assert on. - markDelivered: async () => {}, + // Records delivered claims so occurrence-key back-compat can be asserted; individual tests + // below override whichever of these they need to assert on directly. + markDelivered: async (projectId, eventId, occurrenceKey, transition) => { + marked.push({ projectId, eventId, occurrenceKey, transition }) + }, findStaleClaims: async () => [], incrementAttempts: async () => {}, findExhaustedClaims: async () => [], deleteDeadLettersBefore: async () => 0, } - return { deliveryStore, deadLetters } + return { deliveryStore, deadLetters, claims, marked } } function makeConfigStore(opts: { @@ -147,8 +154,8 @@ describe('WebhookDispatcher.deliver — group B (failure handling)', () => { describe('WebhookDispatcher.deliverTransition — group B2 (delivered-marking)', () => { it('marks the claim delivered once every endpoint has resolved (succeeded or dead-lettered)', async () => { const { deliveryStore } = makeDeliveryStore() - const marked: Array<[string, string, string]> = [] - deliveryStore.markDelivered = async (projectId, eventId, transition) => { marked.push([projectId, eventId, transition]) } + const marked: Array<[string, string, string, string]> = [] + deliveryStore.markDelivered = async (projectId, eventId, occurrenceKey, transition) => { marked.push([projectId, eventId, occurrenceKey, transition]) } const configStore = makeConfigStore({ endpoints: [endpointA, endpointB] }) const fetchImpl = vi.fn().mockImplementation((url: string) => { if (url === endpointA.url) return Promise.resolve(new Response('', { status: 400 })) // dead-lettered @@ -156,9 +163,9 @@ describe('WebhookDispatcher.deliverTransition — group B2 (delivered-marking)', }) const dispatcher = new WebhookDispatcher({ configStore, deliveryStore, fetchImpl }) - await dispatcher.deliverTransition('p1', 'e1', 'live', { ...message, type: 'timed_event.live' }) + await dispatcher.deliverTransition('p1', 'e1', '', 'live', { ...message, type: 'timed_event.live' }) - expect(marked).toEqual([['p1', 'e1', 'live']]) + expect(marked).toEqual([['p1', 'e1', '', 'live']]) }) it('leaves the claim unmarked when deliver itself throws (simulated crash before markDelivered)', async () => { @@ -169,7 +176,7 @@ describe('WebhookDispatcher.deliverTransition — group B2 (delivered-marking)', const dispatcher = new WebhookDispatcher({ configStore, deliveryStore, fetchImpl: vi.fn() }) vi.spyOn(dispatcher, 'deliver').mockRejectedValue(new Error('simulated crash')) - await expect(dispatcher.deliverTransition('p1', 'e1', 'live', { ...message, type: 'timed_event.live' })).rejects.toThrow('simulated crash') + await expect(dispatcher.deliverTransition('p1', 'e1', '', 'live', { ...message, type: 'timed_event.live' })).rejects.toThrow('simulated crash') expect(marked).toEqual([]) }) @@ -178,7 +185,7 @@ describe('WebhookDispatcher.deliverTransition — group B2 (delivered-marking)', const mkEvent = (over: Partial = {}): TimedEventDefinition & { projectId: string } => ({ id: 'e1', projectId: 'p1', name: 'Summer Sale', description: null, startsAt: new Date('2026-07-01T00:00:00Z'), endsAt: new Date('2026-07-31T00:00:00Z'), - endingSoonMinutes: 60, multiplier: 2, enabled: true, ...over, + endingSoonMinutes: 60, multiplier: 2, enabled: true, recurrence: 'none', recurrenceEndsAt: null, ...over, }) type FakeDispatcher = { deliver: ReturnType; deliverTransition: ReturnType } & WebhookDispatcher @@ -208,9 +215,10 @@ describe('startLifecycleScheduler — group C (transition scan)', () => { expect(dispatcher.deliverTransition).toHaveBeenCalledTimes(1) expect(dispatcher.deliverTransition.mock.calls[0][0]).toBe('p1') expect(dispatcher.deliverTransition.mock.calls[0][1]).toBe('e1') - expect(dispatcher.deliverTransition.mock.calls[0][2]).toBe('live') - expect(dispatcher.deliverTransition.mock.calls[0][3]).toMatchObject({ type: 'timed_event.live' }) - expect(dispatcher.deliverTransition.mock.calls[0][3].messageId).toMatch(UUID_RE) + expect(dispatcher.deliverTransition.mock.calls[0][2]).toBe('') // non-recurring occurrence key + expect(dispatcher.deliverTransition.mock.calls[0][3]).toBe('live') + expect(dispatcher.deliverTransition.mock.calls[0][4]).toMatchObject({ type: 'timed_event.live' }) + expect(dispatcher.deliverTransition.mock.calls[0][4].messageId).toMatch(UUID_RE) await vi.advanceTimersByTimeAsync(1000) expect(dispatcher.deliverTransition).toHaveBeenCalledTimes(1) // already claimed, no re-fire @@ -230,10 +238,10 @@ describe('startLifecycleScheduler — group C (transition scan)', () => { await vi.advanceTimersByTimeAsync(1000) expect(dispatcher.deliverTransition).toHaveBeenCalledTimes(2) - expect(dispatcher.deliverTransition.mock.calls[0][3]).toMatchObject({ type: 'timed_event.live' }) - expect(dispatcher.deliverTransition.mock.calls[1][3]).toMatchObject({ type: 'timed_event.ending_soon' }) + expect(dispatcher.deliverTransition.mock.calls[0][4]).toMatchObject({ type: 'timed_event.live' }) + expect(dispatcher.deliverTransition.mock.calls[1][4]).toMatchObject({ type: 'timed_event.ending_soon' }) // fresh messageId per message, even within the same tick - expect(dispatcher.deliverTransition.mock.calls[0][3].messageId).not.toBe(dispatcher.deliverTransition.mock.calls[1][3].messageId) + expect(dispatcher.deliverTransition.mock.calls[0][4].messageId).not.toBe(dispatcher.deliverTransition.mock.calls[1][4].messageId) stop() }) @@ -282,6 +290,79 @@ describe('startLifecycleScheduler — group C (transition scan)', () => { }) }) +describe('startLifecycleScheduler — group C1b (occurrence-aware claims)', () => { + beforeEach(() => { vi.useFakeTimers() }) + afterEach(() => { vi.useRealTimers() }) + + it('claims a non-recurring event under the empty occurrence key (back-compat)', async () => { + vi.setSystemTime(new Date('2026-07-15T00:00:00Z')) + const event = mkEvent() // recurrence 'none' + const configStore = makeConfigStore({ allTimedEvents: [event] }) + const { deliveryStore, claims } = makeDeliveryStore() + const dispatcher = fakeDispatcher() + + const stop = startLifecycleScheduler({ configStore, deliveryStore, dispatcher, intervalMs: 1000 }) + await vi.advanceTimersByTimeAsync(1000) + stop() + + expect(claims).toEqual([{ projectId: 'p1', eventId: 'e1', occurrenceKey: '', transition: 'live' }]) + // occurrence key is threaded all the way through delivery -> markDelivered + expect(dispatcher.deliverTransition.mock.calls[0][2]).toBe('') + }) + + it('rolls occurrence claims: occurrence 1 fires its full lifecycle under K1, then occurrence 2 claims a fresh live under K2', async () => { + // daily, 1-hour occurrences; occ1 = Jul 1 00:00-01:00 (key K1), occ2 = Jul 2 00:00-01:00 (K2) + const event = mkEvent({ + recurrence: 'daily', + startsAt: new Date('2026-07-01T00:00:00Z'), + endsAt: new Date('2026-07-01T01:00:00Z'), + endingSoonMinutes: 10, + }) + const configStore = makeConfigStore({ allTimedEvents: [event] }) + const { deliveryStore, claims, marked } = makeDeliveryStore() + // real dispatcher so deliverTransition -> markDelivered records the delivered occurrence keys + const fetchImpl = vi.fn().mockResolvedValue(new Response('', { status: 200 })) + const dispatcher = new WebhookDispatcher({ configStore, deliveryStore, fetchImpl }) + + const k1 = '2026-07-01T00:00:00.000Z' + const k2 = '2026-07-02T00:00:00.000Z' + + // Tick 1: between occurrences (occ1 fully ended, occ2 not started) -> transitionOccurrence + // returns the just-elapsed occ1 so its full lifecycle fires. + vi.setSystemTime(new Date('2026-07-01T02:00:00Z')) + const stop = startLifecycleScheduler({ configStore, deliveryStore, dispatcher, intervalMs: 1000 }) + await vi.advanceTimersByTimeAsync(1000) + + expect(claims.filter((c) => c.occurrenceKey === k1).map((c) => c.transition)).toEqual(['live', 'ending_soon', 'ended']) + expect(marked.filter((m) => m.occurrenceKey === k1).map((m) => m.transition)).toEqual(['live', 'ending_soon', 'ended']) + + // Tick 2: inside occurrence 2's live window -> a fresh live claim under K2 only. + vi.setSystemTime(new Date('2026-07-02T00:30:00Z')) + await vi.advanceTimersByTimeAsync(1000) + stop() + + expect(claims.filter((c) => c.occurrenceKey === k2).map((c) => c.transition)).toEqual(['live']) + // K1 rows are untouched by tick 2 — still exactly the three from occurrence 1, all delivered. + expect(claims.filter((c) => c.occurrenceKey === k1)).toHaveLength(3) + expect(marked.filter((m) => m.occurrenceKey === k1)).toHaveLength(3) + }) + + it('fires nothing for a disabled recurring event', async () => { + vi.setSystemTime(new Date('2026-07-15T00:00:00Z')) + const event = mkEvent({ recurrence: 'daily', enabled: false }) + const configStore = makeConfigStore({ allTimedEvents: [event] }) + const { deliveryStore, claims } = makeDeliveryStore() + const dispatcher = fakeDispatcher() + + const stop = startLifecycleScheduler({ configStore, deliveryStore, dispatcher, intervalMs: 1000 }) + await vi.advanceTimersByTimeAsync(1000) + stop() + + expect(claims).toEqual([]) + expect(dispatcher.deliverTransition).not.toHaveBeenCalled() + }) +}) + describe('startLifecycleScheduler — group C2 (redelivery sweep)', () => { beforeEach(() => { vi.useFakeTimers() }) afterEach(() => { vi.useRealTimers() }) @@ -312,11 +393,11 @@ describe('startLifecycleScheduler — group C2 (redelivery sweep)', () => { const { deliveryStore } = makeDeliveryStore() deliveryStore.claimTransition = async () => false // already claimed by an earlier tick const incremented: unknown[] = [] - deliveryStore.incrementAttempts = async (projectId, eventId, transition) => { incremented.push([projectId, eventId, transition]) } + deliveryStore.incrementAttempts = async (projectId, eventId, occurrenceKey, transition) => { incremented.push([projectId, eventId, occurrenceKey, transition]) } const marked: unknown[] = [] - deliveryStore.markDelivered = async (projectId, eventId, transition) => { marked.push([projectId, eventId, transition]) } + deliveryStore.markDelivered = async (projectId, eventId, occurrenceKey, transition) => { marked.push([projectId, eventId, occurrenceKey, transition]) } deliveryStore.findStaleClaims = vi.fn() - .mockResolvedValueOnce([{ projectId: 'p1', eventId: 'e1', transition: 'live', attempts: 2 }]) + .mockResolvedValueOnce([{ projectId: 'p1', eventId: 'e1', occurrenceKey: '', transition: 'live', attempts: 2 }]) .mockResolvedValue([]) const fetchImpl = vi.fn().mockResolvedValue(new Response('', { status: 200 })) const dispatcher = new WebhookDispatcher({ configStore, deliveryStore, fetchImpl }) @@ -325,13 +406,13 @@ describe('startLifecycleScheduler — group C2 (redelivery sweep)', () => { await vi.advanceTimersByTimeAsync(1000) stop() - expect(incremented).toEqual([['p1', 'e1', 'live']]) + expect(incremented).toEqual([['p1', 'e1', '', 'live']]) expect(fetchImpl).toHaveBeenCalledTimes(1) const rawBody = (fetchImpl.mock.calls[0][1] as RequestInit).body as string const body = JSON.parse(rawBody) expect(body.type).toBe('timed_event.live') expect(body.messageId).toMatch(UUID_RE) - expect(marked).toEqual([['p1', 'e1', 'live']]) + expect(marked).toEqual([['p1', 'e1', '', 'live']]) }) it('rebuilds the message with a fresh messageId on every redelivery attempt', async () => { @@ -340,10 +421,10 @@ describe('startLifecycleScheduler — group C2 (redelivery sweep)', () => { const configStore = makeConfigStore({ allTimedEvents: [event] }) const { deliveryStore } = makeDeliveryStore() deliveryStore.claimTransition = async () => false - deliveryStore.findStaleClaims = async () => [{ projectId: 'p1', eventId: 'e1', transition: 'live', attempts: 1 }] + deliveryStore.findStaleClaims = async () => [{ projectId: 'p1', eventId: 'e1', occurrenceKey: '', transition: 'live', attempts: 1 }] const messageIds: string[] = [] const dispatcher = fakeDispatcher(async (..._args: unknown[]) => { - const msg = _args[3] as WebhookMessage + const msg = _args[4] as WebhookMessage messageIds.push(msg.messageId) }) @@ -363,8 +444,8 @@ describe('startLifecycleScheduler — group C2 (redelivery sweep)', () => { const configStore = makeConfigStore({ allTimedEvents: [] }) const { deliveryStore, deadLetters } = makeDeliveryStore() const marked: unknown[] = [] - deliveryStore.markDelivered = async (projectId, eventId, transition) => { marked.push([projectId, eventId, transition]) } - deliveryStore.findStaleClaims = async () => [{ projectId: 'p1', eventId: 'gone-1', transition: 'ended', attempts: 3 }] + deliveryStore.markDelivered = async (projectId, eventId, occurrenceKey, transition) => { marked.push([projectId, eventId, occurrenceKey, transition]) } + deliveryStore.findStaleClaims = async () => [{ projectId: 'p1', eventId: 'gone-1', occurrenceKey: '', transition: 'ended', attempts: 3 }] const dispatcher = fakeDispatcher() const stop = startLifecycleScheduler({ configStore, deliveryStore, dispatcher, intervalMs: 1000 }) @@ -374,8 +455,65 @@ describe('startLifecycleScheduler — group C2 (redelivery sweep)', () => { expect(dispatcher.deliverTransition).not.toHaveBeenCalled() expect(deadLetters).toHaveLength(1) expect(deadLetters[0]).toMatchObject({ projectId: 'p1', url: '', error: 'event definition no longer in scan window' }) - expect(JSON.parse(deadLetters[0].payload)).toEqual({ projectId: 'p1', eventId: 'gone-1', transition: 'ended', attempts: 3 }) - expect(marked).toEqual([['p1', 'gone-1', 'ended']]) + expect(JSON.parse(deadLetters[0].payload)).toEqual({ projectId: 'p1', eventId: 'gone-1', occurrenceKey: '', transition: 'ended', attempts: 3 }) + expect(marked).toEqual([['p1', 'gone-1', '', 'ended']]) + }) + + it('rebuilds a recurring redelivery with the occurrence payload derived from its ISO key', async () => { + vi.setSystemTime(new Date('2026-07-05T00:10:00Z')) + // daily, 1-hour occurrences; occ2 (index 2) = Jul 3 00:00-01:00 + const event = mkEvent({ + recurrence: 'daily', + startsAt: new Date('2026-07-01T00:00:00Z'), + endsAt: new Date('2026-07-01T01:00:00Z'), + }) + const configStore = makeConfigStore({ allTimedEvents: [event], endpoints: [endpointA] }) + const { deliveryStore } = makeDeliveryStore() + deliveryStore.claimTransition = async () => false + deliveryStore.findStaleClaims = vi.fn() + .mockResolvedValueOnce([{ projectId: 'p1', eventId: 'e1', occurrenceKey: '2026-07-03T00:00:00.000Z', transition: 'ended', attempts: 1 }]) + .mockResolvedValue([]) + const fetchImpl = vi.fn().mockResolvedValue(new Response('', { status: 200 })) + const dispatcher = new WebhookDispatcher({ configStore, deliveryStore, fetchImpl }) + + const stop = startLifecycleScheduler({ configStore, deliveryStore, dispatcher, intervalMs: 1000 }) + await vi.advanceTimersByTimeAsync(1000) + stop() + + expect(fetchImpl).toHaveBeenCalledTimes(1) + const body = JSON.parse((fetchImpl.mock.calls[0][1] as RequestInit).body as string) + expect(body.type).toBe('timed_event.ended') + // definition bounds stay the definition's own values... + expect(body.data.startsAt).toBe('2026-07-01T00:00:00.000Z') + expect(body.data.endsAt).toBe('2026-07-01T01:00:00.000Z') + // ...while the specific occurrence's window is carried additively. + expect(body.data.occurrence).toEqual({ startsAt: '2026-07-03T00:00:00.000Z', endsAt: '2026-07-03T01:00:00.000Z' }) + }) + + it('dead-letters a recurring stale claim whose occurrence key no longer resolves to an occurrence', async () => { + vi.setSystemTime(new Date('2026-07-05T00:10:00Z')) + const event = mkEvent({ + recurrence: 'daily', + startsAt: new Date('2026-07-01T00:00:00Z'), + endsAt: new Date('2026-07-01T01:00:00Z'), + }) + const configStore = makeConfigStore({ allTimedEvents: [event] }) + const { deliveryStore, deadLetters } = makeDeliveryStore() + deliveryStore.claimTransition = async () => false // isolate the redelivery sweep from phase-1 scan + const marked: unknown[] = [] + deliveryStore.markDelivered = async (projectId, eventId, occurrenceKey, transition) => { marked.push([projectId, eventId, occurrenceKey, transition]) } + // 00:30 is misaligned — daily occurrences start at 00:00, so this key resolves to null. + deliveryStore.findStaleClaims = async () => [{ projectId: 'p1', eventId: 'e1', occurrenceKey: '2026-07-03T00:30:00.000Z', transition: 'ended', attempts: 1 }] + const dispatcher = fakeDispatcher() + + const stop = startLifecycleScheduler({ configStore, deliveryStore, dispatcher, intervalMs: 1000 }) + await vi.advanceTimersByTimeAsync(1000) + stop() + + expect(dispatcher.deliverTransition).not.toHaveBeenCalled() + expect(deadLetters).toHaveLength(1) + expect(deadLetters[0]).toMatchObject({ projectId: 'p1', url: '', error: 'occurrence key no longer resolves to an occurrence' }) + expect(marked).toEqual([['p1', 'e1', '2026-07-03T00:30:00.000Z', 'ended']]) }) }) @@ -390,9 +528,9 @@ describe('startLifecycleScheduler — group C2b (exhaustion sweep)', () => { const configStore = makeConfigStore({ allTimedEvents: [] }) const { deliveryStore, deadLetters } = makeDeliveryStore() const marked: unknown[] = [] - deliveryStore.markDelivered = async (projectId, eventId, transition) => { marked.push([projectId, eventId, transition]) } + deliveryStore.markDelivered = async (projectId, eventId, occurrenceKey, transition) => { marked.push([projectId, eventId, occurrenceKey, transition]) } deliveryStore.findExhaustedClaims = vi.fn() - .mockResolvedValueOnce([{ projectId: 'p1', eventId: 'e1', transition: 'live', attempts: 5 }]) + .mockResolvedValueOnce([{ projectId: 'p1', eventId: 'e1', occurrenceKey: '', transition: 'live', attempts: 5 }]) .mockResolvedValue([]) const dispatcher = fakeDispatcher() @@ -403,8 +541,8 @@ describe('startLifecycleScheduler — group C2b (exhaustion sweep)', () => { expect(dispatcher.deliverTransition).not.toHaveBeenCalled() expect(deadLetters).toHaveLength(1) expect(deadLetters[0]).toMatchObject({ projectId: 'p1', url: '', error: 'redelivery attempts exhausted' }) - expect(JSON.parse(deadLetters[0].payload)).toEqual({ projectId: 'p1', eventId: 'e1', transition: 'live', attempts: 5 }) - expect(marked).toEqual([['p1', 'e1', 'live']]) + expect(JSON.parse(deadLetters[0].payload)).toEqual({ projectId: 'p1', eventId: 'e1', occurrenceKey: '', transition: 'live', attempts: 5 }) + expect(marked).toEqual([['p1', 'e1', '', 'live']]) }) it('calls findExhaustedClaims with MAX_REDELIVERY_ATTEMPTS (5)', async () => { @@ -427,12 +565,12 @@ describe('startLifecycleScheduler — group C2b (exhaustion sweep)', () => { const configStore = makeConfigStore() const { deliveryStore } = makeDeliveryStore() const marked: unknown[] = [] - deliveryStore.markDelivered = async (projectId, eventId, transition) => { marked.push([projectId, eventId, transition]) } + deliveryStore.markDelivered = async (projectId, eventId, occurrenceKey, transition) => { marked.push([projectId, eventId, occurrenceKey, transition]) } let call = 0 deliveryStore.recordDeadLetter = async () => { call++; if (call === 1) throw new Error('db down') } deliveryStore.findExhaustedClaims = vi.fn().mockResolvedValueOnce([ - { projectId: 'p1', eventId: 'e-fail', transition: 'live', attempts: 5 }, - { projectId: 'p1', eventId: 'e-ok', transition: 'live', attempts: 5 }, + { projectId: 'p1', eventId: 'e-fail', occurrenceKey: '', transition: 'live', attempts: 5 }, + { projectId: 'p1', eventId: 'e-ok', occurrenceKey: '', transition: 'live', attempts: 5 }, ]).mockResolvedValue([]) const dispatcher = fakeDispatcher() const testLogger = { warn: vi.fn(), error: vi.fn(), info: vi.fn() } as unknown as Logger @@ -442,7 +580,7 @@ describe('startLifecycleScheduler — group C2b (exhaustion sweep)', () => { stop() // the failing claim is not marked delivered, but the second claim still is - expect(marked).toEqual([['p1', 'e-ok', 'live']]) + expect(marked).toEqual([['p1', 'e-ok', '', 'live']]) expect(testLogger.error).toHaveBeenCalled() }) }) From f1567ac5179038dcafbc20f60c19b523ab6d8557 Mon Sep 17 00:00:00 2001 From: Steve Hynding Date: Thu, 9 Jul 2026 13:51:31 -0700 Subject: [PATCH 09/12] feat(api): retroactive achievement backfill endpoint --- apps/api/src/app.ts | 5 ++- apps/api/src/index.ts | 3 +- apps/api/src/openapi.ts | 23 +++++++++++++ apps/api/src/routes/achievements.ts | 34 +++++++++++++++++++ apps/api/test/backfill.test.ts | 51 +++++++++++++++++++++++++++++ apps/api/test/fakes.ts | 14 +++++++- apps/api/test/openapi.test.ts | 13 ++++++-- 7 files changed, 138 insertions(+), 5 deletions(-) create mode 100644 apps/api/src/routes/achievements.ts create mode 100644 apps/api/test/backfill.test.ts diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index af35dfc..d24dc7e 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -3,12 +3,13 @@ import { readFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' import { Hono } from 'hono' import { cors } from 'hono/cors' -import type { ApiKeyStore, ConfigStore, EngagementStore, ErasureStore, IngestionStore, OfferMetricsStore, ProgressStore, RewardStore, StatsStore } from '@promocean/core' +import type { ApiKeyStore, BackfillStore, ConfigStore, EngagementStore, ErasureStore, IngestionStore, OfferMetricsStore, ProgressStore, RewardStore, StatsStore } from '@promocean/core' import { authMiddleware } from './auth.js' import { envInt } from './env.js' import { logger } from './logger.js' import { buildOpenApiDocument } from './openapi.js' import { createRateLimiter } from './rate-limit.js' +import { achievementsRoute } from './routes/achievements.js' import { couponsRoute } from './routes/coupons.js' import { engagementRoute } from './routes/engagement.js' import { eventsRoute } from './routes/events.js' @@ -82,6 +83,7 @@ export interface AppDeps { statsStore: StatsStore engagementStore: EngagementStore rewardStore: RewardStore + backfillStore: BackfillStore webhooks?: WebhookDispatcher readiness?: { checkDb: () => Promise @@ -141,6 +143,7 @@ export function createApp(deps: AppDeps, opts: CreateAppOptions = {}) { app.route('/v1/stats', statsRoute(deps)) app.route('/v1/rewards', rewardsRoute(deps)) app.route('/v1/coupons', couponsRoute(deps)) + app.route('/v1/achievements', achievementsRoute(deps)) app.onError((err, c) => { logger.error({ err, requestId: c.get('requestId') }, 'unhandled error') return c.json({ error: { code: 'internal_error', message: 'Internal error.' } }, 500) diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index c6d8c91..26df369 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -1,5 +1,5 @@ import { serve } from '@hono/node-server' -import { createDb, runMigrations, PgEngagementStore, PgErasureStore, PgIngestionStore, PgOfferMetricsStore, PgProgressStore, PgRewardStore, PgStatsStore, PgWebhookDeliveryStore } from '@promocean/adapter-db' +import { createDb, runMigrations, PgBackfillStore, PgEngagementStore, PgErasureStore, PgIngestionStore, PgOfferMetricsStore, PgProgressStore, PgRewardStore, PgStatsStore, PgWebhookDeliveryStore } from '@promocean/adapter-db' import { StrapiConfigPlane } from '@promocean/adapter-strapi' import { createApp } from './app.js' import { envInt } from './env.js' @@ -44,6 +44,7 @@ const app = createApp({ statsStore: new PgStatsStore(db), engagementStore: new PgEngagementStore(db), rewardStore: new PgRewardStore(db), + backfillStore: new PgBackfillStore(db), webhooks, readiness: { checkDb: async () => { await db.$client.query('select 1') }, diff --git a/apps/api/src/openapi.ts b/apps/api/src/openapi.ts index 6d6a62a..270180b 100644 --- a/apps/api/src/openapi.ts +++ b/apps/api/src/openapi.ts @@ -1,5 +1,6 @@ import { z } from 'zod' import { + backfillResponseSchema, claimRewardRequestSchema, claimRewardResponseSchema, errorEnvelopeSchema, @@ -64,6 +65,7 @@ export function buildOpenApiDocument(version: string) { validateCouponResponse: toSchema(validateCouponResponseSchema), redeemCouponRequest: toSchema(redeemCouponRequestSchema), redeemCouponResponse: toSchema(redeemCouponResponseSchema), + backfillResponse: toSchema(backfillResponseSchema), errorEnvelope: toSchema(errorEnvelopeSchema), } @@ -320,6 +322,27 @@ export function buildOpenApiDocument(version: string) { }, }, }, + '/v1/achievements/{id}/backfill': { + post: { + summary: 'Retroactively recompute progress, unlocks, and points for an achievement against historical events. Requires a secret key.', + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], + responses: { + '200': { + description: 'Backfill summary.', + content: { 'application/json': { schema: { $ref: '#/components/schemas/backfillResponse' } } }, + }, + '403': { + description: 'A publishable key was used; a secret key is required.', + content: { 'application/json': { schema: { $ref: '#/components/schemas/errorEnvelope' } } }, + }, + '404': { + description: 'No achievement exists with this id.', + content: { 'application/json': { schema: { $ref: '#/components/schemas/errorEnvelope' } } }, + }, + default: errorResponse, + }, + }, + }, } return { diff --git a/apps/api/src/routes/achievements.ts b/apps/api/src/routes/achievements.ts new file mode 100644 index 0000000..79cac27 --- /dev/null +++ b/apps/api/src/routes/achievements.ts @@ -0,0 +1,34 @@ +import { Hono } from 'hono' +import type { BackfillResponse } from '@promocean/contracts' +import type { Scope } from '@promocean/core' +import type { AppDeps } from '../app.js' + +/** + * Retroactive achievement backfill: recomputes progress/unlocks/points for an achievement + * against all historical events of its eventType, for callers who added or changed an + * achievement definition after events had already been ingested. Mutating and potentially + * expensive (scans all matching events for the project/environment), so — like coupons.ts — + * it requires a secret key. Config-plane failures propagate to the app-level onError handler + * (500, fail closed): we never want to backfill against a definition we failed to resolve. + */ +export function achievementsRoute(deps: AppDeps) { + const app = new Hono() + + app.post('/:id/backfill', async (c) => { + const auth = c.get('auth') + if (auth.keyType !== 'secret') { + return c.json({ error: { code: 'forbidden', message: 'Secret key required.' } }, 403) + } + const id = c.req.param('id') + const scope: Scope = { projectId: auth.projectId, environment: auth.environment } + const defs = await deps.configStore.getAchievements(scope.projectId) + const def = defs.find((d) => d.id === id) + if (!def) { + return c.json({ error: { code: 'not_found', message: 'Unknown achievement id.' } }, 404) + } + const summary = await deps.backfillStore.backfillAchievement(scope, def) + return c.json(summary satisfies BackfillResponse) + }) + + return app +} diff --git a/apps/api/test/backfill.test.ts b/apps/api/test/backfill.test.ts new file mode 100644 index 0000000..bdcac55 --- /dev/null +++ b/apps/api/test/backfill.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest' +import type { AchievementDefinition, AuthContext } from '@promocean/core' +import { createApp } from '../src/app.js' +import { makeFakes } from './fakes.js' + +const headers = { authorization: 'Bearer pk_test_valid_key_1' } + +function pkAuth(): AuthContext { return { projectId: 'p1', environment: 'test', keyType: 'publishable', allowedOrigins: null } } +function skAuth(): AuthContext { return { projectId: 'p1', environment: 'test', keyType: 'secret', allowedOrigins: null } } + +function achievement(overrides: Partial = {}): AchievementDefinition { + return { + id: 'a1', name: 'Regular', description: null, artworkUrl: null, + eventType: 'purchase', targetCount: 5, pointsValue: 10, + ...overrides, + } +} + +function setup(auth: AuthContext, definitions: AchievementDefinition[] = []) { + const fakes = makeFakes(definitions, auth) + return { app: createApp(fakes, { rateLimitPerMinute: 0 }), fakes } +} + +describe('POST /v1/achievements/:id/backfill', () => { + it('publishable key -> 403 forbidden', async () => { + const { app } = setup(pkAuth(), [achievement()]) + const res = await app.request('/v1/achievements/a1/backfill', { method: 'POST', headers }) + expect(res.status).toBe(403) + expect((await res.json()).error.code).toBe('forbidden') + }) + + it('unknown achievement id -> 404 not_found', async () => { + const { app } = setup(skAuth(), [achievement({ id: 'a1' })]) + const res = await app.request('/v1/achievements/does-not-exist/backfill', { method: 'POST', headers }) + expect(res.status).toBe(404) + expect((await res.json()).error.code).toBe('not_found') + }) + + it('happy path passes the resolved definition to the store and maps the summary verbatim', async () => { + const def = achievement({ id: 'a1', eventType: 'purchase', targetCount: 5 }) + const { app, fakes } = setup(skAuth(), [def]) + fakes.setBackfillResult({ usersEvaluated: 42, progressRaised: 10, unlocksGranted: 3, pointsAwarded: 30 }) + const res = await app.request('/v1/achievements/a1/backfill', { method: 'POST', headers }) + expect(res.status).toBe(200) + const json = await res.json() + expect(json).toEqual({ usersEvaluated: 42, progressRaised: 10, unlocksGranted: 3, pointsAwarded: 30 }) + expect(fakes.backfillCalls).toHaveLength(1) + expect(fakes.backfillCalls[0].scope).toEqual({ projectId: 'p1', environment: 'test' }) + expect(fakes.backfillCalls[0].def).toEqual(def) + }) +}) diff --git a/apps/api/test/fakes.ts b/apps/api/test/fakes.ts index a8fff1f..d3e0249 100644 --- a/apps/api/test/fakes.ts +++ b/apps/api/test/fakes.ts @@ -1,5 +1,5 @@ import type { - AchievementDefinition, ApiKeyStore, AuthContext, ConfigStore, EngagementStore, EngagementWrite, ErasureStore, + AchievementDefinition, ApiKeyStore, AuthContext, BackfillStore, ConfigStore, EngagementStore, EngagementWrite, ErasureStore, IngestionStore, OfferDefinition, OfferMetricsStore, PointRules, ProgressStore, RewardDefinition, RewardStore, Scope, StatsStore, TimedEventDefinition, } from '@promocean/core' @@ -176,10 +176,22 @@ export function makeFakes( const setValidateResult = (r: ValidateCouponResult) => { validateResult = r } const setRedeemResult = (r: RedeemCouponResult) => { redeemResult = r } + type BackfillResult = Awaited> + let backfillResult: BackfillResult = { usersEvaluated: 0, progressRaised: 0, unlocksGranted: 0, pointsAwarded: 0 } + const backfillCalls: Array<{ scope: Scope; def: AchievementDefinition }> = [] + const backfillStore: BackfillStore = { + backfillAchievement: async (scope, def) => { + backfillCalls.push({ scope, def }) + return backfillResult + }, + } + const setBackfillResult = (r: BackfillResult) => { backfillResult = r } + return { configStore, apiKeyStore, progressStore, ingestionStore, usage, offerMetricsStore, metrics, erasureStore, erasedUsers, erasureCounts, statsStore, statsCalls, setStatsResult, engagementCalls, engagementStore, setWalletResult, setStreakResult, setLeaderboardResult, leaderboardCalls, rewardStore, claimCalls, validateCalls, redeemCalls, setClaimCounts, setClaimResult, setValidateResult, setRedeemResult, + backfillStore, backfillCalls, setBackfillResult, } } diff --git a/apps/api/test/openapi.test.ts b/apps/api/test/openapi.test.ts index 1e34696..22ab284 100644 --- a/apps/api/test/openapi.test.ts +++ b/apps/api/test/openapi.test.ts @@ -10,11 +10,11 @@ describe('GET /v1/openapi.json', () => { expect(res.status).toBe(200) }) - it('describes all fifteen documented endpoints', async () => { + it('describes all sixteen documented endpoints', async () => { const res = await app().request('/v1/openapi.json') const doc = await res.json() expect(doc.openapi).toBe('3.0.3') - expect(Object.keys(doc.paths)).toHaveLength(15) + expect(Object.keys(doc.paths)).toHaveLength(16) expect(Object.keys(doc.paths)).toEqual( expect.arrayContaining([ '/v1/events', @@ -32,10 +32,19 @@ describe('GET /v1/openapi.json', () => { '/v1/rewards/{slug}/claim', '/v1/coupons/validate', '/v1/coupons/redeem', + '/v1/achievements/{id}/backfill', ]), ) }) + it('documents 403 and 404 responses for the backfill endpoint', async () => { + const res = await app().request('/v1/openapi.json') + const doc = await res.json() + const responses = doc.paths['/v1/achievements/{id}/backfill'].post.responses + expect(responses['403']).toBeDefined() + expect(responses['404']).toBeDefined() + }) + it('includes the error envelope schema', async () => { const res = await app().request('/v1/openapi.json') const doc = await res.json() From 3aaca86ea479aa5d83ee6ee0cc83860200b5399f Mon Sep 17 00:00:00 2001 From: Steve Hynding Date: Thu, 9 Jul 2026 13:55:42 -0700 Subject: [PATCH 10/12] feat(sdk): achievement backfill and recurring live-event parsing --- packages/sdk/src/index.ts | 11 +++++++++- packages/sdk/test/sdk.test.ts | 41 +++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 146d2f1..7d95422 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -2,10 +2,11 @@ import { trackEventResponseSchema, userAchievementsResponseSchema, placementOfferResponseSchema, liveEventsResponseSchema, statsResponseSchema, walletResponseSchema, streakResponseSchema, leaderboardResponseSchema, rewardsResponseSchema, claimRewardResponseSchema, - validateCouponResponseSchema, redeemCouponResponseSchema, + validateCouponResponseSchema, redeemCouponResponseSchema, backfillResponseSchema, type AchievementStatus, type TrackEventResponse, type UnlockPayload, type OfferCreative, type LiveTimedEvent, type StatsResponse, type WalletResponse, type StreakResponse, type LeaderboardResponse, type Reward, type ClaimRewardResponse, type ValidateCouponResponse, type RedeemCouponResponse, + type BackfillResponse, } from '@promocean/contracts' export interface PromoceanOptions { @@ -200,6 +201,14 @@ export class Promocean { return redeemCouponResponseSchema.parse(await res.json()) } + async backfillAchievement(achievementId: string): Promise { + if (!this.opts.secretKey) throw new Error('backfillAchievement requires the secretKey option (server-side only).') + const res = await this.request(`/v1/achievements/${encodeURIComponent(achievementId)}/backfill`, { + method: 'POST', + }, { useSecretKey: true }) + return backfillResponseSchema.parse(await res.json()) + } + private dismissalKey(offerId: string) { return `promocean:dismissed:${offerId}` } dismissOffer(offerId: string): void { diff --git a/packages/sdk/test/sdk.test.ts b/packages/sdk/test/sdk.test.ts index f1d3f9f..0582b31 100644 --- a/packages/sdk/test/sdk.test.ts +++ b/packages/sdk/test/sdk.test.ts @@ -345,6 +345,8 @@ describe('getLiveEvents', () => { multiplier: 2, secondsUntilStart: null, secondsUntilEnd: 604800, + recurrence: 'weekly', + nextOccurrenceStartsAt: '2026-07-13T00:00:00.000Z', }], } const fetchImpl = vi.fn().mockImplementation(() => ok(liveEventBody)) @@ -353,4 +355,43 @@ describe('getLiveEvents', () => { expect(String(fetchImpl.mock.calls[0][0])).toBe('http://api.test/v1/events/live') expect(events).toEqual(liveEventBody.events) }) + + it('defaults recurrence and nextOccurrenceStartsAt when an old-shape event omits them', async () => { + const liveEventBody = { + events: [{ + eventId: 'evt_live_2', + name: 'Legacy Sale', + description: null, + state: 'live', + startsAt: '2026-07-06T00:00:00.000Z', + endsAt: '2026-07-13T00:00:00.000Z', + multiplier: 2, + secondsUntilStart: null, + secondsUntilEnd: 604800, + }], + } + const fetchImpl = vi.fn().mockImplementation(() => ok(liveEventBody)) + const c = client(fetchImpl) + const events = await c.getLiveEvents() + expect(events).toEqual([{ ...liveEventBody.events[0], recurrence: 'none', nextOccurrenceStartsAt: null }]) + }) +}) + +describe('backfillAchievement', () => { + const backfillOk = { usersEvaluated: 12, progressRaised: 5, unlocksGranted: 2, pointsAwarded: 40 } + it('throws when no secretKey is configured', async () => { + const c = client(vi.fn()) + await expect(c.backfillAchievement('ach_1')).rejects.toThrow('backfillAchievement requires the secretKey option (server-side only).') + }) + it('sends the secretKey as bearer auth to the encoded path with no body and parses the response', async () => { + const fetchImpl = vi.fn().mockImplementation(() => ok(backfillOk)) + const c = client(fetchImpl, { secretKey: 'sk_test_x' }) + const result = await c.backfillAchievement('ach 1/2') + const [url, init] = fetchImpl.mock.calls[0] + expect(String(url)).toBe('http://api.test/v1/achievements/ach%201%2F2/backfill') + expect(init.method).toBe('POST') + expect(init.headers.authorization).toBe('Bearer sk_test_x') + expect(init.body).toBeUndefined() + expect(result).toEqual(backfillOk) + }) }) From 469a85f8eb5c0b4a33800a8806d83d9ce86c0d41 Mon Sep 17 00:00:00 2001 From: Steve Hynding Date: Thu, 9 Jul 2026 16:35:35 -0700 Subject: [PATCH 11/12] =?UTF-8?q?feat(demo):=20backfill=20operator=20form?= =?UTF-8?q?=20and=20recurring-event=20demo;=20docs=20=E2=80=94=20sprint=20?= =?UTF-8?q?9=20wrap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .changeset/campaign-lifecycle.md | 32 +++++++++ README.md | 89 ++++++++++++++++++++++- apps/demo/app/stats/backfill-actions.ts | 34 +++++++++ apps/demo/app/stats/backfill-form.tsx | 33 +++++++++ apps/demo/app/stats/page.tsx | 2 + apps/demo/e2e/campaign-lifecycle.spec.ts | 91 ++++++++++++++++++++++++ packages/sdk/README.md | 44 +++++++++++- 7 files changed, 322 insertions(+), 3 deletions(-) create mode 100644 .changeset/campaign-lifecycle.md create mode 100644 apps/demo/app/stats/backfill-actions.ts create mode 100644 apps/demo/app/stats/backfill-form.tsx create mode 100644 apps/demo/e2e/campaign-lifecycle.spec.ts diff --git a/.changeset/campaign-lifecycle.md b/.changeset/campaign-lifecycle.md new file mode 100644 index 0000000..fdfb1ab --- /dev/null +++ b/.changeset/campaign-lifecycle.md @@ -0,0 +1,32 @@ +--- +"@promocean/contracts": minor +"@promocean/sdk": minor +--- + +Add campaign lifecycle: recurring timed events and retroactive achievement +backfill. + +- Timed events gain an optional `recurrence: 'daily' | 'weekly' | 'monthly'` + (default `'none'`) and `recurrenceEndsAt` cutoff. `getLiveEvents()` (and + the underlying `LiveTimedEvent` shape) additively gains `recurrence` and + `nextOccurrenceStartsAt` — both default when omitted, so code built + against an older `@promocean/sdk`/`@promocean/contracts` still parses an + old-shape or new-shape response either way. +- New `backfillAchievement(achievementId)` SDK method (secret-key-only, + same posture as `getStats()`/`validateCoupon()`/`redeemCoupon()`): + retroactively recomputes an achievement's progress/unlocks/points against + all historical events of its `eventType`, returning `{ usersEvaluated, + progressRaised, unlocksGranted, pointsAwarded }`. A retroactive unlock + pays out its `pointsValue` bonus exactly like a live one — see the root + README for the full operator flow. +- Webhook payloads for recurring timed-event transitions gain an additive + `data.occurrence: { startsAt, endsAt }` field (the specific occurrence + that fired); `data.startsAt`/`data.endsAt` stay the definition's own + window, unchanged. The HMAC signature and `messageId` dedup semantics are + unaffected. + +Internal-only, not a version bump here: `WebhookDeliveryStore`'s port +signature widened to key claims by `occurrenceKey` (`@promocean/core` isn't +published to npm, so this doesn't affect installed package versions, but is +worth knowing if you implement your own store against `@promocean/core`'s +types). diff --git a/README.md b/README.md index 20d352f..f97ae4d 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,75 @@ multiplier wins — multipliers don't stack. Progress is always **clamped at the achievement target**, so a ×2 event takes 9/10 to 10/10, not 11. Event windows (`startsAt`/`endsAt`) are absolute UTC instants, not durations. +**Recurrence:** a timed event can additionally be configured with a +`recurrence` of `'daily' | 'weekly' | 'monthly'` (default `'none'`) and an +optional `recurrenceEndsAt` cutoff. `GET /v1/events/live` and the SDK's +`getLiveEvents()` always report the **current-or-next occurrence's** +`startsAt`/`endsAt` — not the definition's original window — plus the +`recurrence` value itself and a `nextOccurrenceStartsAt` (the start of the +occurrence after the reported one; `null` once `recurrenceEndsAt` has +passed and no more occurrences exist). For a fixed-interval recurrence +(`daily`/`weekly`) `nextOccurrenceStartsAt` is exactly `startsAt` plus that +interval; `monthly` anchors to the definition's original day-of-month, so +short months clamp instead of drifting (e.g. a 31st-of-the-month event's +February occurrence falls back to the 28th/29th, and the occurrence after +that still anchors to the 31st where the calendar allows it). + +- **Per-occurrence webhooks:** each occurrence of a recurring event fires + its own independent `timed_event.live` / `.ending_soon` / `.ended` + transitions (see Webhooks below) — a weekly event firing every week is + not "the same" transition recurring, it's a fresh set of transitions per + occurrence, each individually claimed/delivered/redelivered. +- **Multiplier applies in every occurrence:** the event's `multiplier` + isn't a one-time bonus — it applies for the full duration of *every* + occurrence while recurrence is active, not just the first. +- **UTC-instant drift note:** because `startsAt` (and therefore every + computed occurrence) is an absolute UTC instant, a recurring event + anchored to, say, 17:00 UTC does **not** track "5pm local time" through + daylight-saving transitions in any particular timezone — it's always + 17:00 UTC, which shifts relative to local clocks that observe DST. Anchor + `startsAt` in UTC deliberately if you need a fixed wall-clock time in a + specific timezone across DST boundaries. +- **Scheduler-downtime edge:** the lifecycle scheduler only looks back + `TIMED_EVENT_SCAN_GRACE_MINUTES` (see the Webhooks table below) for + transitions to fire. If the api process is down longer than that grace + window, occurrences (including entire recurring-event occurrences) that + started and ended entirely during the outage are skipped permanently — + no claim is ever made for them and no dead letter is recorded. Size the + grace window to your expected downtime, and remember it applies + per-occurrence: a long outage can silently skip several occurrences of a + short-interval (e.g. daily) recurring event. + +### Retroactive achievement backfill + +`POST /v1/achievements/:id/backfill` (secret key only) recomputes an +achievement's progress/unlocks/points against **all** historical events of +its `eventType`, for every user in the project/environment — the operator +flow for "I added (or changed the target/points of) an achievement after +events had already been ingested, and want existing users to retroactively +qualify." It returns a summary: `{ usersEvaluated, progressRaised, +unlocksGranted, pointsAwarded }`. Rejected with `403 forbidden` for +publishable keys, `404 not_found` for an unknown achievement id. + +**This moves wallets and leaderboards by design.** A retroactive unlock +awards that achievement's `pointsValue` bonus into the user's wallet (a +`points_ledger` row, same as a live unlock) exactly as if they'd unlocked it +the moment they qualified — so running a backfill after raising an +achievement's `pointsValue`, or after a user's historical events newly +qualify them, will change wallet balances and leaderboard rankings +immediately, with no separate confirmation step. If that's not the outcome +you want (e.g. you only want the badge, not the retroactive points), don't +backfill — no other endpoint offers a "recompute without paying out" mode. + +**Idempotent by construction:** running backfill again for the same +achievement never double-grants — a user already unlocked (live or by a +previous backfill) contributes `0` to `unlocksGranted`/`pointsAwarded` on +a subsequent run; only users who newly cross the target since the last run +are granted. `usersEvaluated` still counts everyone with matching event +history, so a `usersEvaluated: 5, unlocksGranted: 0, pointsAwarded: 0` +result is the expected, correct output of a re-run against unchanged data — +not a failure. + ## Quickstart The fastest way to see the whole thing working — clone, then one command: @@ -107,7 +176,12 @@ the full earn/burn loop — claiming a free static-code reward, being blocked on a priced reward by insufficient points, earning enough to claim it (generated code, balance debited), the `/stats` page's coupon validate/redeem/re-redeem-409 flow, and that erasure counts the claimed -coupons. With cms + api already running (per above): +coupons; `campaign-lifecycle.spec.ts` proves the seeded recurring `Weekly +Happy Hour` event reports a consistent `recurrence`/`nextOccurrenceStartsAt` +on the live feed and renders in the countdown widget, and that retroactive +achievement backfill is idempotent after a live unlock (both via a direct +API call and the `/stats` page's operator-facing backfill form). With cms + +api already running (per above): pnpm --filter demo exec playwright install chromium pnpm --filter demo e2e @@ -140,6 +214,7 @@ middleware so tooling can fetch the spec without a key. | POST | `/v1/coupons/validate` | sk only | Look up a coupon code without redeeming it: `{ valid, rewardSlug?, status?, reason? }`. Rejected with `403 forbidden` for publishable keys. | | POST | `/v1/coupons/redeem` | sk only | Redeem a coupon code (one-time). Rejected with `409 already_redeemed` on a second redemption, `409 reward_unavailable` if the reward has since expired, or `404 not_found` for an unknown code. Rejected with `403 forbidden` for publishable keys. | | GET | `/v1/stats` | sk only | Aggregate stats for the project: event/unlock/impression/click totals, per-achievement unlocks, per-offer CTR, per-timed-event participant counts. Optional `?from=&to=` ISO datetime range. Rejected with `403 forbidden` for publishable keys. | +| POST | `/v1/achievements/:id/backfill` | sk only | Retroactively recompute progress/unlocks/points for an achievement against all historical events of its `eventType` — see "Retroactive achievement backfill" above. Rejected with `403 forbidden` for publishable keys, `404 not_found` for an unknown achievement id. | | GET | `/v1/openapi.json` | none | Serve the OpenAPI document, generated from the same zod contracts the routes validate against. | | GET | `/docs` | none | Serve an HTML API reference (Redoc) rendered from the same OpenAPI document. | @@ -301,6 +376,18 @@ timed-event transition is sent as a brand-new message with a fresh against a replay window (e.g. reject anything older than a few minutes) — both belong in your consumer regardless of transport. +**Recurring events fire per-occurrence:** `data.startsAt`/`data.endsAt` on a +`timed_event.*` message always describe the event **definition's** own +window (wire-stable, unaffected by recurrence). For a recurring event, an +additive `data.occurrence: { startsAt, endsAt }` field carries the specific +occurrence's window that actually fired this transition — every occurrence +of a recurring event claims, delivers, and redelivers independently, keyed +internally by that occurrence's start instant, so a weekly event firing for +ten straight weeks produces ten fully independent sets of +live/ending_soon/ended messages, not one recurring message. This field is +absent entirely for non-recurring events. The HMAC signature and +`messageId` semantics are unaffected — `data.occurrence` is purely additive. + Timed-event delivery is claim-then-mark: the scheduler claims a transition once, delivers it to every enabled endpoint (each endpoint independently retries transient failures and is dead-lettered on permanent failure), then diff --git a/apps/demo/app/stats/backfill-actions.ts b/apps/demo/app/stats/backfill-actions.ts new file mode 100644 index 0000000..cf1e8b1 --- /dev/null +++ b/apps/demo/app/stats/backfill-actions.ts @@ -0,0 +1,34 @@ +'use server' +import { Promocean, PromoceanApiError } from '@promocean/sdk' + +// Server action module: same posture as coupon-actions.ts — constructs the sk +// (secret-key) SDK client fresh on every submit from process.env.PROMOCEAN_SECRET_KEY +// (never NEXT_PUBLIC_*), never runs in the browser. `backfillAchievement()` throws +// `PromoceanApiError` on a non-2xx response (403 no-secret-key / 404 unknown id); we +// catch it here and fold it into the returned state so the client component has one +// JSON shape to render for both the success summary and the error envelope. +export type BackfillState = { result: unknown } | null + +function client() { + return new Promocean({ + publishableKey: '', + secretKey: process.env.PROMOCEAN_SECRET_KEY!, + baseUrl: process.env.PROMOCEAN_API_URL ?? process.env.NEXT_PUBLIC_PROMOCEAN_API!, + }) +} + +export async function runBackfill(_prev: BackfillState, formData: FormData): Promise { + const achievementId = String(formData.get('achievementId') ?? '').trim() + if (!achievementId) { + return { result: { error: { code: 'invalid_payload', message: 'Enter an achievement id.' } } } + } + try { + const result = await client().backfillAchievement(achievementId) + return { result } + } catch (err) { + if (err instanceof PromoceanApiError) { + return { result: { error: { code: err.code, message: err.message, status: err.status } } } + } + return { result: { error: { code: 'internal_error', message: err instanceof Error ? err.message : 'Request failed' } } } + } +} diff --git a/apps/demo/app/stats/backfill-form.tsx b/apps/demo/app/stats/backfill-form.tsx new file mode 100644 index 0000000..e4374c9 --- /dev/null +++ b/apps/demo/app/stats/backfill-form.tsx @@ -0,0 +1,33 @@ +'use client' +import { useActionState } from 'react' +import { runBackfill, type BackfillState } from './backfill-actions' + +// Operator-facing form/action pair, mirroring CouponCheckForm exactly: the sk +// (secret-key) SDK client stays entirely server-side (backfill-actions.ts); this +// client component only ever sees the JSON result the action returns (the backfill +// summary on success, or an error envelope on failure — same shape either way). +export function BackfillForm() { + const [state, formAction, pending] = useActionState(runBackfill, null) + + return ( +
+

Achievement backfill

+
+ + +
+ {state ? ( +
+          {JSON.stringify(state.result, null, 2)}
+        
+ ) : null} +
+ ) +} diff --git a/apps/demo/app/stats/page.tsx b/apps/demo/app/stats/page.tsx index d1051fd..df32a4e 100644 --- a/apps/demo/app/stats/page.tsx +++ b/apps/demo/app/stats/page.tsx @@ -1,4 +1,5 @@ import { Promocean } from '@promocean/sdk' +import { BackfillForm } from './backfill-form' import { CouponCheckForm } from './coupon-check-form' // Server component only: reads process.env.PROMOCEAN_SECRET_KEY (never @@ -92,6 +93,7 @@ export default async function StatsPage() { + ) } diff --git a/apps/demo/e2e/campaign-lifecycle.spec.ts b/apps/demo/e2e/campaign-lifecycle.spec.ts new file mode 100644 index 0000000..71d173a --- /dev/null +++ b/apps/demo/e2e/campaign-lifecycle.spec.ts @@ -0,0 +1,91 @@ +import { expect, test } from '@playwright/test' + +// API base + pk/sk match the seeded demo project (apps/cms/src/index.ts) and the +// docker-compose.yml defaults — see rewards-loop.spec.ts for the same constants. +const API_BASE = 'http://localhost:3001' +const PUBLISHABLE_KEY = 'pk_test_demo_1234567890abcdef' +const SECRET_KEY = 'sk_test_demo_1234567890abcdef' + +// Weekly recurrence has a fixed 7-day interval (apps/core/src/timed-events.ts +// INTERVAL_MS.weekly), so the occurrence AFTER the one reported in startsAt/endsAt +// always starts exactly 7 days after this one's startsAt. +const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000 + +test.describe('campaign lifecycle', () => { + test('live feed carries the recurring Weekly Happy Hour with a consistent nextOccurrenceStartsAt', async ({ page, request }) => { + const liveRes = await request.get(`${API_BASE}/v1/events/live`, { + headers: { authorization: `Bearer ${PUBLISHABLE_KEY}` }, + }) + expect(liveRes.ok()).toBeTruthy() + const { events } = await liveRes.json() as { + events: Array<{ + eventId: string; name: string; state: string; startsAt: string; endsAt: string + recurrence: string; nextOccurrenceStartsAt: string | null + }> + } + + const happyHour = events.find((e) => e.name === 'Weekly Happy Hour') + expect(happyHour).toBeDefined() + expect(happyHour!.recurrence).toBe('weekly') + // Current-or-next occurrence window: a real window, not the definition's own bounds. + expect(['scheduled', 'live', 'ending_soon']).toContain(happyHour!.state) + const startsAtMs = new Date(happyHour!.startsAt).getTime() + const endsAtMs = new Date(happyHour!.endsAt).getTime() + expect(endsAtMs).toBeGreaterThan(startsAtMs) + + // recurrenceEndsAt is seeded null, so there's always a next occurrence. + expect(happyHour!.nextOccurrenceStartsAt).not.toBeNull() + expect(new Date(happyHour!.nextOccurrenceStartsAt!).getTime()).toBe(startsAtMs + SEVEN_DAYS_MS) + + // The demo's section renders it alongside the one-shot event. + const user = `e2e-lifecycle-${Date.now()}` + await page.goto(`/?user=${user}`) + await expect(page.locator('[data-promocean-event]', { hasText: 'Weekly Happy Hour' })).toBeVisible() + }) + + test('backfill is idempotent after a live unlock, and round-trips through the demo form', async ({ page, request }) => { + const user = `e2e-backfill-${Date.now()}` + await page.goto(`/?user=${user}`) + + // A single lesson_completed event unlocks the seeded target-1 "First Lesson" + // achievement live (see engagement-loop.spec.ts for the same seed math). + await page.getByRole('button', { name: 'Complete a lesson' }).click() + await expect(page.getByRole('status')).toContainText('First Lesson') + + const achievementsRes = await request.get(`${API_BASE}/v1/users/${encodeURIComponent(user)}/achievements`, { + headers: { authorization: `Bearer ${PUBLISHABLE_KEY}` }, + }) + expect(achievementsRes.ok()).toBeTruthy() + const { achievements } = await achievementsRes.json() as { + achievements: Array<{ achievementId: string; name: string; unlockedAt: string | null }> + } + const firstLesson = achievements.find((a) => a.name === 'First Lesson') + expect(firstLesson).toBeDefined() + expect(firstLesson!.unlockedAt).not.toBeNull() + + // Retroactive backfill of an achievement this user already unlocked LIVE grants nothing new + // — this proves the endpoint works and is idempotent. TRUE retroactivity (a definition + // created AFTER events already existed) is covered by adapter-db/api tests plus the DoD's + // hand-verified live backfill against a mid-flight-created achievement. + const backfillRes = await request.post( + `${API_BASE}/v1/achievements/${encodeURIComponent(firstLesson!.achievementId)}/backfill`, + { headers: { authorization: `Bearer ${SECRET_KEY}` } }, + ) + expect(backfillRes.ok()).toBeTruthy() + const summary = await backfillRes.json() as { + usersEvaluated: number; progressRaised: number; unlocksGranted: number; pointsAwarded: number + } + expect(summary.usersEvaluated).toBeGreaterThanOrEqual(1) + expect(summary.unlocksGranted).toBe(0) + expect(summary.pointsAwarded).toBe(0) + + // The demo's operator-facing backfill form (stats page) round-trips the same call. + await page.goto('/stats') + await page.getByTestId('backfill-achievement-id-input').fill(firstLesson!.achievementId) + await page.getByTestId('backfill-submit-button').click() + const result = page.getByTestId('backfill-result') + await expect(result).toContainText('"usersEvaluated"') + await expect(result).toContainText('"unlocksGranted": 0') + await expect(result).toContainText('"pointsAwarded": 0') + }) +}) diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 7634f74..e7e3fda 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -59,6 +59,8 @@ promocean.isOfferDismissed(offer.offerId) // true // Get currently scheduled/live/ending-soon timed events (progress multipliers). const liveEvents = await promocean.getLiveEvents() +// [{ eventId, name, state, startsAt, endsAt, multiplier, secondsUntilStart, secondsUntilEnd, +// recurrence, nextOccurrenceStartsAt }] — see "Recurring events" below. // List rewards currently claimable, then claim one for the identified user. const rewards = await promocean.listRewards() @@ -78,7 +80,7 @@ await promocean.redeemCoupon(code) // { redeemed: true, rewar | `userId` | `string` | no | Seed the identified user up front instead of calling `identify()`. | | `fetchImpl` | `typeof fetch` | no | Override `fetch` (e.g. for testing or non-browser runtimes without a global `fetch`). | | `maxRetries` | `number` | no | Retries for 5xx/network failures, with exponential backoff. Default `3`. 4xx errors are never retried. | -| `secretKey` | `string` | no | **Server-side only.** Grants access to secret-key-only endpoints (currently `getStats()`). See "Server-side stats" below. When you only need `secretKey` (no browser-side calls at all from this client instance), pass `publishableKey: ''` — it's never sent unless a call actually needs it. | +| `secretKey` | `string` | no | **Server-side only.** Grants access to secret-key-only endpoints (`getStats()`, `validateCoupon()`, `redeemCoupon()`, `backfillAchievement()`). See "Server-side stats" below. When you only need `secretKey` (no browser-side calls at all from this client instance), pass `publishableKey: ''` — it's never sent unless a call actually needs it. | ### Identifying a user @@ -117,6 +119,21 @@ opaque/pseudonymous id to `identify()`/`track()` instead and map it to a display name in your own app before showing a leaderboard; Promocean has no notion of identity beyond the id you give it. +### Recurring events + +A `LiveTimedEvent` from `getLiveEvents()` can carry a `recurrence` of +`'none' | 'daily' | 'weekly' | 'monthly'` (defaults to `'none'` — old-server +responses without this field, or the `nextOccurrenceStartsAt` field below, +still parse). `startsAt`/`endsAt` are always the **current-or-next +occurrence's** window, not the event definition's original one, so a +weekly event you fetch three weeks from now still reports this week's (or +next week's) window, not the original. `nextOccurrenceStartsAt` is the +start of the occurrence after the one reported — `null` once the event's +recurrence has ended (its `recurrenceEndsAt` has passed) and no further +occurrence exists. See the root README's "Timed events" section for the +UTC-instant drift note and the scheduler-downtime edge that applies to +recurring events specifically. + ### Server-side stats (`secretKey`) `getStats()` fetches aggregate totals/achievements/offers/timed-events for @@ -181,6 +198,29 @@ this source value will fail zod-parsing a wallet response once any redemption exists in your project. Upgrade both packages together before spending/rewards go live. +### Retroactive achievement backfill (`secretKey`) + +`backfillAchievement(achievementId)` is **server-side only** — same +`secretKey` posture as `getStats()`/`validateCoupon()`/`redeemCoupon()` +above, and throws immediately if `secretKey` isn't configured: + +```ts +promocean.backfillAchievement(achievementId: string): Promise +// { usersEvaluated, progressRaised, unlocksGranted, pointsAwarded } +// Throws PromoceanApiError with code 'not_found' (404) for an unknown achievement id, or +// 'forbidden' (403) if called without a secret key. +``` + +Recomputes the named achievement's progress/unlocks/points against **all** +historical events of its `eventType` for every user in the project — the +operator flow for granting an achievement retroactively after adding it (or +changing its target/points) once events already exist. See the root +README's "Retroactive achievement backfill" section for the full operator +flow, including the wallet/leaderboard-moving decision (a retroactive +unlock pays out its `pointsValue` bonus exactly like a live one) and why +re-running it against unchanged data is idempotent (`unlocksGranted: 0, +pointsAwarded: 0`, not an error). + ### Listening for unlocks ```ts @@ -237,6 +277,6 @@ misconfigured or compromised CMS content. Request/response shapes (`TrackEventResponse`, `AchievementStatus`, `OfferCreative`, `UnlockPayload`, `LiveTimedEvent`, `WalletResponse`, `StreakResponse`, `LeaderboardResponse`, `Reward`, `ClaimRewardResponse`, -`ValidateCouponResponse`, `RedeemCouponResponse`, etc.) are re-exported +`ValidateCouponResponse`, `RedeemCouponResponse`, `BackfillResponse`, `Recurrence`, etc.) are re-exported from `@promocean/contracts` and validated at runtime with zod — a malformed API response throws rather than silently returning bad data. From 38add4a5939015c2a405091690790c9f204df411 Mon Sep 17 00:00:00 2001 From: Steve Hynding Date: Thu, 9 Jul 2026 16:59:26 -0700 Subject: [PATCH 12/12] fix: end-side scan-feed cutoff for bounded recurrence, try-lock backfill conflict, docs Co-Authored-By: Claude Fable 5 --- .changeset/campaign-lifecycle.md | 5 +- README.md | 10 +++- apps/api/src/openapi.ts | 4 ++ apps/api/src/routes/achievements.ts | 11 +++- apps/api/test/backfill.test.ts | 10 +++- apps/api/test/fakes.ts | 2 +- .../config-plane/controllers/config-plane.ts | 13 ++++- packages/adapter-db/src/stores.ts | 21 +++++-- packages/adapter-db/test/backfill.test.ts | 55 ++++++++++++++++--- packages/contracts/src/errors.ts | 1 + packages/contracts/test/contracts.test.ts | 4 ++ packages/core/src/ports.ts | 16 ++++-- packages/sdk/test/sdk.test.ts | 10 ++++ 13 files changed, 134 insertions(+), 28 deletions(-) diff --git a/.changeset/campaign-lifecycle.md b/.changeset/campaign-lifecycle.md index fdfb1ab..0ff6e01 100644 --- a/.changeset/campaign-lifecycle.md +++ b/.changeset/campaign-lifecycle.md @@ -18,7 +18,10 @@ backfill. all historical events of its `eventType`, returning `{ usersEvaluated, progressRaised, unlocksGranted, pointsAwarded }`. A retroactive unlock pays out its `pointsValue` bonus exactly like a live one — see the root - README for the full operator flow. + README for the full operator flow. The error catalog gains a + `backfill_in_progress` code (surfaced as `409` when a backfill of the same + achievement is already running — the endpoint try-locks rather than + queueing). - Webhook payloads for recurring timed-event transitions gain an additive `data.occurrence: { startsAt, endsAt }` field (the specific occurrence that fired); `data.startsAt`/`data.endsAt` stay the definition's own diff --git a/README.md b/README.md index f97ae4d..33a3bda 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,12 @@ history, so a `usersEvaluated: 5, unlocksGranted: 0, pointsAwarded: 0` result is the expected, correct output of a re-run against unchanged data — not a failure. +**One at a time per achievement:** a backfill takes a try-lock on its +achievement rather than queueing, so a second concurrent backfill of the +*same* achievement returns `409 backfill_in_progress` immediately (it does +not wait, and writes nothing) — retry once the running backfill finishes. +Backfills of *different* achievements run concurrently without contending. + ## Quickstart The fastest way to see the whole thing working — clone, then one command: @@ -213,8 +219,8 @@ middleware so tooling can fetch the spec without a key. | POST | `/v1/rewards/:slug/claim` | pk or sk | Claim a reward for a user, returning its coupon code. Rejected with `404 not_found` for an unknown slug, or `409` `reward_unavailable` / `claim_limit_reached` / `insufficient_points` when the reward, per-user limit, or points balance rules aren't met. | | POST | `/v1/coupons/validate` | sk only | Look up a coupon code without redeeming it: `{ valid, rewardSlug?, status?, reason? }`. Rejected with `403 forbidden` for publishable keys. | | POST | `/v1/coupons/redeem` | sk only | Redeem a coupon code (one-time). Rejected with `409 already_redeemed` on a second redemption, `409 reward_unavailable` if the reward has since expired, or `404 not_found` for an unknown code. Rejected with `403 forbidden` for publishable keys. | -| GET | `/v1/stats` | sk only | Aggregate stats for the project: event/unlock/impression/click totals, per-achievement unlocks, per-offer CTR, per-timed-event participant counts. Optional `?from=&to=` ISO datetime range. Rejected with `403 forbidden` for publishable keys. | -| POST | `/v1/achievements/:id/backfill` | sk only | Retroactively recompute progress/unlocks/points for an achievement against all historical events of its `eventType` — see "Retroactive achievement backfill" above. Rejected with `403 forbidden` for publishable keys, `404 not_found` for an unknown achievement id. | +| GET | `/v1/stats` | sk only | Aggregate stats for the project: event/unlock/impression/click totals, per-achievement unlocks, per-offer CTR, per-timed-event participant counts. Optional `?from=&to=` ISO datetime range. A timed event appears in the stats breakdown only when one of its participation windows intersects the queried range (changed in Sprint 9 — previously out-of-range events appeared zero-filled). Rejected with `403 forbidden` for publishable keys. | +| POST | `/v1/achievements/:id/backfill` | sk only | Retroactively recompute progress/unlocks/points for an achievement against all historical events of its `eventType` — see "Retroactive achievement backfill" above. Rejected with `403 forbidden` for publishable keys, `404 not_found` for an unknown achievement id, or `409 backfill_in_progress` when another backfill of the same achievement is already running. | | GET | `/v1/openapi.json` | none | Serve the OpenAPI document, generated from the same zod contracts the routes validate against. | | GET | `/docs` | none | Serve an HTML API reference (Redoc) rendered from the same OpenAPI document. | diff --git a/apps/api/src/openapi.ts b/apps/api/src/openapi.ts index 270180b..c5d2718 100644 --- a/apps/api/src/openapi.ts +++ b/apps/api/src/openapi.ts @@ -339,6 +339,10 @@ export function buildOpenApiDocument(version: string) { description: 'No achievement exists with this id.', content: { 'application/json': { schema: { $ref: '#/components/schemas/errorEnvelope' } } }, }, + '409': { + description: 'backfill_in_progress: a backfill for this achievement is already running.', + content: { 'application/json': { schema: { $ref: '#/components/schemas/errorEnvelope' } } }, + }, default: errorResponse, }, }, diff --git a/apps/api/src/routes/achievements.ts b/apps/api/src/routes/achievements.ts index 79cac27..5b97c40 100644 --- a/apps/api/src/routes/achievements.ts +++ b/apps/api/src/routes/achievements.ts @@ -26,7 +26,16 @@ export function achievementsRoute(deps: AppDeps) { if (!def) { return c.json({ error: { code: 'not_found', message: 'Unknown achievement id.' } }, 404) } - const summary = await deps.backfillStore.backfillAchievement(scope, def) + const result = await deps.backfillStore.backfillAchievement(scope, def) + if (!result.ok) { + return c.json({ error: { code: 'backfill_in_progress', message: 'A backfill for this achievement is already running.' } }, 409) + } + const summary: BackfillResponse = { + usersEvaluated: result.usersEvaluated, + progressRaised: result.progressRaised, + unlocksGranted: result.unlocksGranted, + pointsAwarded: result.pointsAwarded, + } return c.json(summary satisfies BackfillResponse) }) diff --git a/apps/api/test/backfill.test.ts b/apps/api/test/backfill.test.ts index bdcac55..8446f03 100644 --- a/apps/api/test/backfill.test.ts +++ b/apps/api/test/backfill.test.ts @@ -39,7 +39,7 @@ describe('POST /v1/achievements/:id/backfill', () => { it('happy path passes the resolved definition to the store and maps the summary verbatim', async () => { const def = achievement({ id: 'a1', eventType: 'purchase', targetCount: 5 }) const { app, fakes } = setup(skAuth(), [def]) - fakes.setBackfillResult({ usersEvaluated: 42, progressRaised: 10, unlocksGranted: 3, pointsAwarded: 30 }) + fakes.setBackfillResult({ ok: true, usersEvaluated: 42, progressRaised: 10, unlocksGranted: 3, pointsAwarded: 30 }) const res = await app.request('/v1/achievements/a1/backfill', { method: 'POST', headers }) expect(res.status).toBe(200) const json = await res.json() @@ -48,4 +48,12 @@ describe('POST /v1/achievements/:id/backfill', () => { expect(fakes.backfillCalls[0].scope).toEqual({ projectId: 'p1', environment: 'test' }) expect(fakes.backfillCalls[0].def).toEqual(def) }) + + it('concurrent backfill conflict -> 409 backfill_in_progress', async () => { + const { app, fakes } = setup(skAuth(), [achievement({ id: 'a1' })]) + fakes.setBackfillResult({ ok: false, reason: 'backfill_in_progress' }) + const res = await app.request('/v1/achievements/a1/backfill', { method: 'POST', headers }) + expect(res.status).toBe(409) + expect((await res.json()).error.code).toBe('backfill_in_progress') + }) }) diff --git a/apps/api/test/fakes.ts b/apps/api/test/fakes.ts index d3e0249..35a191f 100644 --- a/apps/api/test/fakes.ts +++ b/apps/api/test/fakes.ts @@ -177,7 +177,7 @@ export function makeFakes( const setRedeemResult = (r: RedeemCouponResult) => { redeemResult = r } type BackfillResult = Awaited> - let backfillResult: BackfillResult = { usersEvaluated: 0, progressRaised: 0, unlocksGranted: 0, pointsAwarded: 0 } + let backfillResult: BackfillResult = { ok: true, usersEvaluated: 0, progressRaised: 0, unlocksGranted: 0, pointsAwarded: 0 } const backfillCalls: Array<{ scope: Scope; def: AchievementDefinition }> = [] const backfillStore: BackfillStore = { backfillAchievement: async (scope, def) => { diff --git a/apps/cms/src/api/config-plane/controllers/config-plane.ts b/apps/cms/src/api/config-plane/controllers/config-plane.ts index 8b05514..0fcbcf3 100644 --- a/apps/cms/src/api/config-plane/controllers/config-plane.ts +++ b/apps/cms/src/api/config-plane/controllers/config-plane.ts @@ -89,12 +89,21 @@ export default { const rawParam = String(ctx.query.endedWithinMinutes ?? '') const filters: Record = {} if (/^[1-9][0-9]*$/.test(rawParam)) { - const cutoff = new Date(Date.now() - Number(rawParam) * 60_000).toISOString() + const now = Date.now() + const cutoff = new Date(now - Number(rawParam) * 60_000).toISOString() + // recurrenceEndsAt bounds occurrence STARTS, not ends: the final occurrence may end up to + // one interval after recurrenceEndsAt — at most 28 days, the monthly duration cap the + // timed-event lifecycle validation enforces (2_419_200_000 ms). Pad the recurring branch's + // cutoff by that max duration, or a bounded recurrence whose recurrenceEndsAt lands just + // after the final start scrolls out of the feed before its final ending_soon/ended are + // claimable. Over-fetching a finished event is harmless — its claims are conflict no-ops. + const MAX_OCCURRENCE_DURATION_MS = 2_419_200_000 // 28 days, the monthly duration cap + const recurringCutoff = new Date(now - Number(rawParam) * 60_000 - MAX_OCCURRENCE_DURATION_MS).toISOString() filters.$or = [ { endsAt: { $gte: cutoff } }, { recurrence: { $ne: 'none' }, - $or: [{ recurrenceEndsAt: { $null: true } }, { recurrenceEndsAt: { $gte: cutoff } }], + $or: [{ recurrenceEndsAt: { $null: true } }, { recurrenceEndsAt: { $gte: recurringCutoff } }], }, ] } diff --git a/packages/adapter-db/src/stores.ts b/packages/adapter-db/src/stores.ts index 16e3933..769662a 100644 --- a/packages/adapter-db/src/stores.ts +++ b/packages/adapter-db/src/stores.ts @@ -642,9 +642,11 @@ export class PgRewardStore implements RewardStore { * Retroactively applies an achievement definition against already-stored events — the path a * newly-created (or newly-eligible) achievement takes so historical activity counts toward it. * - * The whole run is one transaction guarded by an advisory lock keyed on - * (project+environment, 'backfill:' + def.id), so two concurrent backfills of the same definition - * serialize. Live ingestion NEVER takes this lock — so a concurrent ingestEvent can race us. Two + * The whole run is one transaction guarded by a TRY advisory lock keyed on + * (project+environment, 'backfill:' + def.id): a concurrent backfill of the same definition does + * NOT queue on the lock (which would stack pool connections and starve DB-backed endpoints) — + * it returns { ok: false, reason: 'backfill_in_progress' } immediately and commits its empty + * transaction. Live ingestion NEVER takes this lock — so a concurrent ingestEvent can race us. Two * belts guard that race: the progress upsert wraps its target-clamped value in GREATEST so a * concurrent live increment is never lowered, and the unlock insert is onConflictDoNothing so only * one of {backfill, ingest} wins the row and writes the single unlock bonus. @@ -654,7 +656,14 @@ export class PgBackfillStore implements BackfillStore { async backfillAchievement(scope: Scope, def: AchievementDefinition) { return this.db.transaction(async (tx) => { const ns = `${scope.projectId}:${scope.environment}` - await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext(${ns}), hashtext(${'backfill:' + def.id}))`) + const [lockRow] = (await tx.execute<{ locked: boolean }>( + sql`SELECT pg_try_advisory_xact_lock(hashtext(${ns}), hashtext(${'backfill:' + def.id})) AS locked`, + )).rows + if (!lockRow?.locked) { + // Another backfill of this achievement holds the lock — bail without waiting. The empty + // transaction commits cleanly (nothing was written), leaving the running backfill alone. + return { ok: false as const, reason: 'backfill_in_progress' as const } + } const aggregate = await tx.execute<{ user_id: string; cnt: number }>(sql` SELECT user_id, COUNT(*)::int AS cnt @@ -666,7 +675,7 @@ export class PgBackfillStore implements BackfillStore { const usersEvaluated = rows.length // Empty aggregate: nothing to evaluate, so return an all-zero summary without any writes. if (usersEvaluated === 0) { - return { usersEvaluated: 0, progressRaised: 0, unlocksGranted: 0, pointsAwarded: 0 } + return { ok: true as const, usersEvaluated: 0, progressRaised: 0, unlocksGranted: 0, pointsAwarded: 0 } } const userIds = rows.map((r) => r.user_id) @@ -733,7 +742,7 @@ export class PgBackfillStore implements BackfillStore { } } - return { usersEvaluated, progressRaised, unlocksGranted, pointsAwarded } + return { ok: true as const, usersEvaluated, progressRaised, unlocksGranted, pointsAwarded } }) } } diff --git a/packages/adapter-db/test/backfill.test.ts b/packages/adapter-db/test/backfill.test.ts index adaac41..593a78a 100644 --- a/packages/adapter-db/test/backfill.test.ts +++ b/packages/adapter-db/test/backfill.test.ts @@ -11,6 +11,15 @@ let ingest: PgIngestionStore const scope: Scope = { projectId: 'p1', environment: 'test' } const noEngagement: EngagementWrite = { localDay: '2026-07-01', eventPoints: null, unlockPoints: {} } +// backfillAchievement now returns a union — narrow to the success branch (throwing if it +// unexpectedly returned the conflict) so the property-level assertions below stay ergonomic. +type BackfillResult = Awaited> +type BackfillOk = Extract +const expectOk = (r: BackfillResult): BackfillOk => { + if (!r.ok) throw new Error(`expected ok backfill result, got ${r.reason}`) + return r +} + const makeDef = (over: Partial & Pick): AchievementDefinition => ({ name: over.id, description: null, @@ -74,7 +83,7 @@ describe('PgBackfillStore.backfillAchievement', () => { const def = makeDef({ id: 'ret-ach', eventType: 'ret_lesson', targetCount: 3, pointsValue: 50 }) const summary = await backfill.backfillAchievement(scope, def) - expect(summary).toEqual({ usersEvaluated: 2, progressRaised: 2, unlocksGranted: 1, pointsAwarded: 50 }) + expect(summary).toEqual({ ok: true, usersEvaluated: 2, progressRaised: 2, unlocksGranted: 1, pointsAwarded: 50 }) expect(await progressCurrent(scope, 'ret-A', 'ret-ach')).toBe(3) // clamped at target expect(await progressCurrent(scope, 'ret-B', 'ret-ach')).toBe(2) @@ -91,7 +100,7 @@ describe('PgBackfillStore.backfillAchievement', () => { const def = makeDef({ id: 'ret-ach', eventType: 'ret_lesson', targetCount: 3, pointsValue: 50 }) const ledgerBefore = await totalLedgerRows(scope) - const summary = await backfill.backfillAchievement(scope, def) + const summary = expectOk(await backfill.backfillAchievement(scope, def)) // usersEvaluated still reflects the population; every DELTA is zero. expect(summary.progressRaised).toBe(0) expect(summary.unlocksGranted).toBe(0) @@ -110,7 +119,7 @@ describe('PgBackfillStore.backfillAchievement', () => { ) const def = makeDef({ id: 'gr-ach', eventType: 'gr_type', targetCount: 10, pointsValue: 100 }) - const summary = await backfill.backfillAchievement(scope, def) + const summary = expectOk(await backfill.backfillAchievement(scope, def)) expect(summary.progressRaised).toBe(0) expect(summary.unlocksGranted).toBe(0) expect(await progressCurrent(scope, 'gr-U', 'gr-ach')).toBe(8) // stays 8, not lowered to 3 @@ -133,7 +142,7 @@ describe('PgBackfillStore.backfillAchievement', () => { [scope.projectId, scope.environment], ) - const summary = await backfill.backfillAchievement(scope, def) + const summary = expectOk(await backfill.backfillAchievement(scope, def)) expect(summary.progressRaised).toBe(0) expect(summary.unlocksGranted).toBe(0) expect(summary.pointsAwarded).toBe(0) @@ -170,7 +179,7 @@ describe('PgBackfillStore.backfillAchievement', () => { it('zero-event type: all-zero summary with no writes', async () => { const def = makeDef({ id: 'ze-ach', eventType: 'no_such_type', targetCount: 3, pointsValue: 10 }) const summary = await backfill.backfillAchievement(scope, def) - expect(summary).toEqual({ usersEvaluated: 0, progressRaised: 0, unlocksGranted: 0, pointsAwarded: 0 }) + expect(summary).toEqual({ ok: true, usersEvaluated: 0, progressRaised: 0, unlocksGranted: 0, pointsAwarded: 0 }) const { rows } = await db.$client.query( `select count(*)::int as n from runtime.achievement_progress where project_id=$1 and environment=$2 and achievement_id='ze-ach'`, [scope.projectId, scope.environment], @@ -182,7 +191,7 @@ describe('PgBackfillStore.backfillAchievement', () => { await ingestBareEvents(scope, 'zp-U', 'zp_type', 3, 'zpU') const def = makeDef({ id: 'zp-ach', eventType: 'zp_type', targetCount: 3, pointsValue: 0 }) - const summary = await backfill.backfillAchievement(scope, def) + const summary = expectOk(await backfill.backfillAchievement(scope, def)) expect(summary.unlocksGranted).toBe(1) expect(summary.pointsAwarded).toBe(0) expect(await unlockCount(scope, 'zp-U', 'zp-ach')).toBe(1) @@ -196,11 +205,41 @@ describe('PgBackfillStore.backfillAchievement', () => { const def = makeDef({ id: 'iso-ach', eventType: 'iso_type', targetCount: 3, pointsValue: 25 }) const p2Summary = await backfill.backfillAchievement(p2, def) - expect(p2Summary).toEqual({ usersEvaluated: 0, progressRaised: 0, unlocksGranted: 0, pointsAwarded: 0 }) + expect(p2Summary).toEqual({ ok: true, usersEvaluated: 0, progressRaised: 0, unlocksGranted: 0, pointsAwarded: 0 }) expect(await unlockCount(p2, 'iso-U', 'iso-ach')).toBe(0) // p1's own backfill still works and is unaffected by the p2 run. const p1Summary = await backfill.backfillAchievement(scope, def) - expect(p1Summary).toEqual({ usersEvaluated: 1, progressRaised: 1, unlocksGranted: 1, pointsAwarded: 25 }) + expect(p1Summary).toEqual({ ok: true, usersEvaluated: 1, progressRaised: 1, unlocksGranted: 1, pointsAwarded: 25 }) + }) + + it('try-lock conflict: a concurrent backfill of the same achievement returns backfill_in_progress with no writes', async () => { + // Hold the achievement's advisory lock from a SEPARATE connection's open transaction, then + // call backfillAchievement — it must try-lock, fail, and bail without touching any rows. + await ingestBareEvents(scope, 'lk-U', 'lk_type', 4, 'lkU') // would unlock (target 3) if it ran + const def = makeDef({ id: 'lk-ach', eventType: 'lk_type', targetCount: 3, pointsValue: 30 }) + + const ns = `${scope.projectId}:${scope.environment}` + const holder = await db.$client.connect() + try { + await holder.query('BEGIN') + await holder.query(`SELECT pg_advisory_xact_lock(hashtext($1), hashtext($2))`, [ns, `backfill:${def.id}`]) + + const result = await backfill.backfillAchievement(scope, def) + expect(result).toEqual({ ok: false, reason: 'backfill_in_progress' }) + + // No progress / unlock / ledger rows were written for the contended achievement. + expect(await progressCurrent(scope, 'lk-U', 'lk-ach')).toBeUndefined() + expect(await unlockCount(scope, 'lk-U', 'lk-ach')).toBe(0) + expect(await bonusLedgerCount(scope, 'lk-U', 'lk-ach')).toBe(0) + } finally { + await holder.query('ROLLBACK') // release the advisory lock + holder.release() + } + + // Once the lock is released, a fresh backfill succeeds and grants the unlock. + const after = expectOk(await backfill.backfillAchievement(scope, def)) + expect(after.unlocksGranted).toBe(1) + expect(await unlockCount(scope, 'lk-U', 'lk-ach')).toBe(1) }) }) diff --git a/packages/contracts/src/errors.ts b/packages/contracts/src/errors.ts index 0002761..6f5ecfb 100644 --- a/packages/contracts/src/errors.ts +++ b/packages/contracts/src/errors.ts @@ -13,6 +13,7 @@ export const errorCodeSchema = z.enum([ 'claim_limit_reached', 'insufficient_points', 'already_redeemed', + 'backfill_in_progress', ]) export type ErrorCode = z.infer diff --git a/packages/contracts/test/contracts.test.ts b/packages/contracts/test/contracts.test.ts index aaede84..bb7bb91 100644 --- a/packages/contracts/test/contracts.test.ts +++ b/packages/contracts/test/contracts.test.ts @@ -215,6 +215,10 @@ describe('error codes', () => { expect(result.success).toBe(true) } }) + it('accepts the backfill_in_progress error code', () => { + const result = errorEnvelopeSchema.safeParse({ error: { code: 'backfill_in_progress', message: 'x' } }) + expect(result.success).toBe(true) + }) }) describe('webhookMessageSchema', () => { diff --git a/packages/core/src/ports.ts b/packages/core/src/ports.ts index 6143de0..d6c0184 100644 --- a/packages/core/src/ports.ts +++ b/packages/core/src/ports.ts @@ -2,12 +2,16 @@ import type { ClaimRejection } from './rewards.js' import type { AchievementDefinition, AuthContext, OfferDefinition, PointRules, RewardDefinition, Scope, TimedEventDefinition, TimedEventTransition, WebhookEndpointDefinition } from './types.js' export interface BackfillStore { - backfillAchievement(scope: Scope, def: AchievementDefinition): Promise<{ - usersEvaluated: number - progressRaised: number - unlocksGranted: number - pointsAwarded: number - }> + /** + * Try-lock semantics: takes a `pg_try_advisory_xact_lock` on the achievement rather than + * queueing on it, so a concurrent backfill of the SAME achievement returns + * `{ ok: false, reason: 'backfill_in_progress' }` immediately instead of waiting on the lock + * while holding a pool connection (which would starve every DB-backed endpoint). + */ + backfillAchievement(scope: Scope, def: AchievementDefinition): Promise< + | { ok: true; usersEvaluated: number; progressRaised: number; unlocksGranted: number; pointsAwarded: number } + | { ok: false; reason: 'backfill_in_progress' } + > } export interface ConfigStore { diff --git a/packages/sdk/test/sdk.test.ts b/packages/sdk/test/sdk.test.ts index 0582b31..520eb60 100644 --- a/packages/sdk/test/sdk.test.ts +++ b/packages/sdk/test/sdk.test.ts @@ -394,4 +394,14 @@ describe('backfillAchievement', () => { expect(init.body).toBeUndefined() expect(result).toEqual(backfillOk) }) + it('propagates a 409 backfill_in_progress envelope as a typed PromoceanApiError', async () => { + const fetchImpl = vi.fn().mockImplementation(() => Promise.resolve( + new Response(JSON.stringify({ error: { code: 'backfill_in_progress', message: 'A backfill for this achievement is already running.' } }), { status: 409 }), + )) + const c = client(fetchImpl, { secretKey: 'sk_test_x' }) + const err = await c.backfillAchievement('ach_1').catch((e: unknown) => e) + expect(err).toBeInstanceOf(PromoceanApiError) + expect((err as PromoceanApiError).code).toBe('backfill_in_progress') + expect((err as PromoceanApiError).status).toBe(409) + }) })