Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .changeset/campaign-lifecycle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
---
"@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. 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
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).
97 changes: 95 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,81 @@ 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.

**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:
Expand Down Expand Up @@ -107,7 +182,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
Expand Down Expand Up @@ -139,7 +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. |
| 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. |

Expand Down Expand Up @@ -301,6 +382,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
Expand Down
5 changes: 4 additions & 1 deletion apps/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -82,6 +83,7 @@ export interface AppDeps {
statsStore: StatsStore
engagementStore: EngagementStore
rewardStore: RewardStore
backfillStore: BackfillStore
webhooks?: WebhookDispatcher
readiness?: {
checkDb: () => Promise<void>
Expand Down Expand Up @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion apps/api/src/index.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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') },
Expand Down
28 changes: 28 additions & 0 deletions apps/api/src/openapi.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { z } from 'zod'
import {
backfillResponseSchema,
claimRewardRequestSchema,
claimRewardResponseSchema,
errorEnvelopeSchema,
Expand Down Expand Up @@ -64,6 +65,7 @@ export function buildOpenApiDocument(version: string) {
validateCouponResponse: toSchema(validateCouponResponseSchema),
redeemCouponRequest: toSchema(redeemCouponRequestSchema),
redeemCouponResponse: toSchema(redeemCouponResponseSchema),
backfillResponse: toSchema(backfillResponseSchema),
errorEnvelope: toSchema(errorEnvelopeSchema),
}

Expand Down Expand Up @@ -175,6 +177,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' } },
Expand Down Expand Up @@ -319,6 +322,31 @@ 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' } } },
},
'409': {
description: 'backfill_in_progress: a backfill for this achievement is already running.',
content: { 'application/json': { schema: { $ref: '#/components/schemas/errorEnvelope' } } },
},
default: errorResponse,
},
},
},
}

return {
Expand Down
43 changes: 43 additions & 0 deletions apps/api/src/routes/achievements.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
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 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)
})

return app
}
Loading
Loading