From d9c2ba4161147d331f357a4223e8cf3a62347cbb Mon Sep 17 00:00:00 2001 From: Steve Hynding Date: Mon, 6 Jul 2026 22:41:34 -0700 Subject: [PATCH 01/13] =?UTF-8?q?docs:=20Sprint=203=20implementation=20pla?= =?UTF-8?q?n=20=E2=80=94=20timed=20events=20+=20lifecycle=20webhooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../plans/2026-07-07-sprint-3-timed-events.md | 565 ++++++++++++++++++ 1 file changed, 565 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-07-sprint-3-timed-events.md diff --git a/docs/superpowers/plans/2026-07-07-sprint-3-timed-events.md b/docs/superpowers/plans/2026-07-07-sprint-3-timed-events.md new file mode 100644 index 0000000..f6b4ef4 --- /dev/null +++ b/docs/superpowers/plans/2026-07-07-sprint-3-timed-events.md @@ -0,0 +1,565 @@ +# Promocean Sprint 3: Timed Events + Lifecycle Webhooks — 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:** Ship the TimedEvent primitive end-to-end: a "Double Progress Weekend" defined in Strapi doubles achievement progress while live, offers can attach to event windows, the demo shows a live countdown, and lifecycle transitions + achievement unlocks fire signed webhooks with retry and dead-lettering. + +**Architecture:** State is computed on read from `startsAt`/`endsAt` — no cron for correctness (spec §4.2). A lightweight in-process scheduler exists solely to fire lifecycle webhooks, deduplicating transitions through a claim table. Definitions (timed events, webhook endpoints) live in Strapi behind new config-plane endpoints; the webhook dispatcher and scheduler live in `apps/api`. + +**Tech Stack:** unchanged (Node 22, pnpm/Turborepo, Zod 4, Hono 4, Drizzle, Strapi 5, Next 15, Vitest 3, Playwright). + +**Spec:** `docs/superpowers/specs/2026-07-06-promocean-design.md` §4.1 (TimedEvent), §4.2 (lifecycle), §4.3 (unlock webhooks), §5 (signed webhooks, dead-letter). Recurrence and per-user timezone windows remain deferred (schema reserves `recurrence`). + +## Global Constraints + +(All prior global constraints bind: licensing, strict TS/ESM, tenancy, error envelope, conventional commits, tsconfig outDir pattern, lockfile committed, append-only metrics.) + +Sprint-3 additions: +- Lifecycle: `draft → scheduled → live → ending_soon → ended`. `draft` = `enabled: false`. `scheduled` = `now < startsAt`. `ended` = `now >= endsAt`. `ending_soon` = live AND `endsAt - now <= endingSoonMinutes` (default 1440 = 24h). `live` otherwise when `startsAt <= now < endsAt`. All instants UTC. +- Multiplier applies while state is `live` OR `ending_soon`. Multiple concurrent events: the **max** multiplier wins (never multiply together); floor 1. +- An offer with `timedEventId` set is resolvable only while that event is live/ending_soon. +- Webhooks: body is the JSON message; header `X-Promocean-Signature` = hex HMAC-SHA256 of the raw body using the endpoint's secret. 3 retries with 250ms-base exponential backoff; exhaustion → dead-letter row. Disabled endpoints skipped. Webhook failures never affect API responses. +- Lifecycle transitions (`live`, `ending_soon`, `ended`) fire **exactly once per (project, event, transition)** — enforced by a unique-claim insert, not by scheduler memory. +- Webhook message types: `timed_event.live`, `timed_event.ending_soon`, `timed_event.ended`, `achievement.unlocked`. +- Known cross-task break (same pattern as Sprint 2): Task 2 widens `ConfigStore` and `OfferDefinition`, leaving adapter-strapi/apps/api typecheck RED until Tasks 5–6. Record, don't patch early. +- Seed timing: the seeded event must be live at seed time (`startsAt = now − 1h`, `endsAt = now + 7d`) — the ONLY permitted use of wall-clock in seed code. +- The seeded live multiplier changes the existing achievement e2e: one `lesson_completed` click now yields `2/10` on Getting Started. Task 10 explicitly updates that assertion. + +--- + +### Task 1: `@promocean/contracts` — timed-event + webhook schemas + +**Files:** +- Create: `packages/contracts/src/timed-events.ts`, `packages/contracts/src/webhooks.ts` +- Modify: `packages/contracts/src/index.ts` +- Test: `packages/contracts/test/timed-events.test.ts` + +**Interfaces:** +- Produces: + - `liveTimedEventSchema` / `LiveTimedEvent = { eventId: string; name: string; description: string | null; state: 'scheduled' | 'live' | 'ending_soon'; startsAt: string; endsAt: string; multiplier: number; secondsUntilStart: number | null; secondsUntilEnd: number }` + - `liveEventsResponseSchema` / `LiveEventsResponse = { events: LiveTimedEvent[] }` + - `webhookMessageSchema` / `WebhookMessage = { type: 'timed_event.live' | 'timed_event.ending_soon' | 'timed_event.ended' | 'achievement.unlocked'; data: Record; createdAt: string }` + - `WEBHOOK_SIGNATURE_HEADER = 'x-promocean-signature'` + +- [ ] **Step 1: Write the failing tests** + +`packages/contracts/test/timed-events.test.ts`: +```ts +import { describe, expect, it } from 'vitest' +import { liveEventsResponseSchema, webhookMessageSchema, WEBHOOK_SIGNATURE_HEADER } from '../src/index.js' + +const event = { + eventId: 'e1', name: 'Double Progress Weekend', description: null, state: 'live', + startsAt: '2026-07-07T00:00:00.000Z', endsAt: '2026-07-14T00:00:00.000Z', + multiplier: 2, secondsUntilStart: null, secondsUntilEnd: 604800, +} + +describe('timed event schemas', () => { + it('round-trips a live events response', () => { + expect(liveEventsResponseSchema.parse({ events: [event] })).toEqual({ events: [event] }) + }) + it('rejects draft/ended states on the wire', () => { + for (const state of ['draft', 'ended', 'nope']) + expect(liveEventsResponseSchema.safeParse({ events: [{ ...event, state }] }).success).toBe(false) + }) + it('validates webhook messages and exports the signature header', () => { + expect(webhookMessageSchema.parse({ type: 'achievement.unlocked', data: { userId: 'u1' }, createdAt: event.startsAt }).type).toBe('achievement.unlocked') + expect(webhookMessageSchema.safeParse({ type: 'other', data: {}, createdAt: event.startsAt }).success).toBe(false) + expect(WEBHOOK_SIGNATURE_HEADER).toBe('x-promocean-signature') + }) +}) +``` + +- [ ] **Step 2: Run to verify RED** + +Run: `pnpm --filter @promocean/contracts test` — Expected: new file FAILS, existing 10 pass. + +- [ ] **Step 3: Implement** + +`packages/contracts/src/timed-events.ts`: +```ts +import { z } from 'zod' + +export const liveTimedEventSchema = z.object({ + eventId: z.string(), + name: z.string(), + description: z.string().nullable(), + state: z.enum(['scheduled', 'live', 'ending_soon']), + startsAt: z.iso.datetime(), + endsAt: z.iso.datetime(), + multiplier: z.number().int().min(1), + secondsUntilStart: z.number().int().nullable(), + secondsUntilEnd: z.number().int(), +}) +export type LiveTimedEvent = z.infer + +export const liveEventsResponseSchema = z.object({ events: z.array(liveTimedEventSchema) }) +export type LiveEventsResponse = z.infer +``` + +`packages/contracts/src/webhooks.ts`: +```ts +import { z } from 'zod' + +export const webhookMessageSchema = z.object({ + type: z.enum(['timed_event.live', 'timed_event.ending_soon', 'timed_event.ended', 'achievement.unlocked']), + data: z.record(z.string(), z.unknown()), + createdAt: z.iso.datetime(), +}) +export type WebhookMessage = z.infer + +export const WEBHOOK_SIGNATURE_HEADER = 'x-promocean-signature' +``` + +Append both exports to `src/index.ts`. + +- [ ] **Step 4: GREEN + build** + +Run: `pnpm --filter @promocean/contracts test && pnpm --filter @promocean/contracts build` — Expected: 13 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add packages/contracts +git commit -m "feat(contracts): live timed event and webhook message schemas" +``` + +--- + +### Task 2: `@promocean/core` — state machine, multiplier, offer attachment, webhook ports + +**Files:** +- Create: `packages/core/src/timed-events.ts` +- Modify: `packages/core/src/types.ts`, `packages/core/src/ports.ts`, `packages/core/src/offers.ts`, `packages/core/src/index.ts` +- Test: `packages/core/test/timed-events.test.ts`; modify `packages/core/test/offers.test.ts` + +**Interfaces:** +- Produces (exact signatures): + +```ts +// types.ts additions +export type TimedEventState = 'draft' | 'scheduled' | 'live' | 'ending_soon' | 'ended' +export type TimedEventTransition = 'live' | 'ending_soon' | 'ended' +export interface TimedEventDefinition { + id: string; name: string; description: string | null + startsAt: Date; endsAt: Date; endingSoonMinutes: number + multiplier: number; enabled: boolean +} +export interface WebhookEndpointDefinition { id: string; url: string; secret: string; enabled: boolean } +// OfferDefinition gains: timedEventId: string | null + +// timed-events.ts +export function timedEventState(event: TimedEventDefinition, now: Date): TimedEventState +export function activeMultiplier(events: TimedEventDefinition[], now: Date): number // max over live/ending_soon, floor 1 +export function activeEventIds(events: TimedEventDefinition[], now: Date): Set // ids of live/ending_soon events + +// offers.ts — signature change +export function resolveOffer(placementSlug: string, offers: OfferDefinition[], now: Date, activeEvents?: ReadonlySet): OfferDefinition | null +// an offer with timedEventId !== null resolves only when activeEvents?.has(timedEventId) + +// ports.ts — ConfigStore gains: +getTimedEvents(projectId: string): Promise +getAllTimedEvents(): Promise> // scheduler sweep +getWebhookEndpoints(projectId: string): Promise +// new port: +export interface WebhookDeliveryStore { + claimTransition(projectId: string, eventId: string, transition: TimedEventTransition): Promise + recordDeadLetter(projectId: string, url: string, payload: string, error: string, at: Date): Promise +} +``` + +- [ ] **Step 1: Write the failing tests** + +`packages/core/test/timed-events.test.ts`: +```ts +import { describe, expect, it } from 'vitest' +import { activeEventIds, activeMultiplier, timedEventState, type TimedEventDefinition } from '../src/index.js' + +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, +}) + +describe('timedEventState', () => { + const e = mk({}) + it('walks the full lifecycle', () => { + expect(timedEventState(mk({ enabled: false }), new Date('2026-07-12T00:00:00Z'))).toBe('draft') + expect(timedEventState(e, new Date('2026-07-09T00:00:00Z'))).toBe('scheduled') + expect(timedEventState(e, new Date('2026-07-10T00:00:00Z'))).toBe('live') // startsAt inclusive + expect(timedEventState(e, new Date('2026-07-16T00:00:00Z'))).toBe('ending_soon') // exactly 24h left + expect(timedEventState(e, new Date('2026-07-17T00:00:00Z'))).toBe('ended') // endsAt exclusive + }) +}) + +describe('activeMultiplier / activeEventIds', () => { + const now = new Date('2026-07-12T00:00:00Z') + it('takes the max across live events, floor 1', () => { + expect(activeMultiplier([], now)).toBe(1) + expect(activeMultiplier([mk({ multiplier: 2 }), mk({ id: 'e2', multiplier: 3 })], now)).toBe(3) + expect(activeMultiplier([mk({ enabled: false, multiplier: 5 })], now)).toBe(1) + expect(activeMultiplier([mk({ startsAt: new Date('2026-08-01T00:00:00Z'), multiplier: 5 })], now)).toBe(1) + }) + it('collects live and ending_soon ids only', () => { + const events = [mk({}), mk({ id: 'e2', endsAt: new Date('2026-07-12T12:00:00Z') }), mk({ id: 'e3', enabled: false })] + expect(activeEventIds(events, now)).toEqual(new Set(['e1', 'e2'])) + }) +}) +``` + +Append to `packages/core/test/offers.test.ts`: +```ts +describe('resolveOffer with event attachment', () => { + const attached: OfferDefinition = { ...base, id: 'event-offer', placementSlug: 'homepage-banner', startsAt: null, endsAt: null, priority: 99, timedEventId: 'e1' } + it('resolves attached offers only while their event is active', () => { + expect(resolveOffer('homepage-banner', [attached], now, new Set(['e1']))?.id).toBe('event-offer') + expect(resolveOffer('homepage-banner', [attached], now, new Set())).toBeNull() + expect(resolveOffer('homepage-banner', [attached], now)).toBeNull() + }) +}) +``` +(Existing offer fixtures in that file gain `timedEventId: null` in `base`.) + +- [ ] **Step 2: RED** + +Run: `pnpm --filter @promocean/core test` — Expected: FAIL (new exports missing; offers test type errors). + +- [ ] **Step 3: Implement** + +`packages/core/src/timed-events.ts`: +```ts +import type { TimedEventDefinition, TimedEventState } from './types.js' + +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() + return msLeft <= event.endingSoonMinutes * 60_000 ? 'ending_soon' : 'live' +} + +const isActive = (s: TimedEventState) => s === 'live' || s === 'ending_soon' + +export function activeMultiplier(events: TimedEventDefinition[], now: Date): number { + let max = 1 + for (const e of events) if (isActive(timedEventState(e, now)) && e.multiplier > max) max = e.multiplier + return max +} + +export function activeEventIds(events: TimedEventDefinition[], now: Date): Set { + const ids = new Set() + for (const e of events) if (isActive(timedEventState(e, now))) ids.add(e.id) + return ids +} +``` + +`offers.ts` — add the fourth parameter; inside the loop, before schedule checks: +```ts +if (offer.timedEventId !== null && !activeEvents?.has(offer.timedEventId)) continue +``` + +Add types/ports exactly per the Interfaces block; `export * from './timed-events.js'` in index. + +- [ ] **Step 4: GREEN + record the expected downstream break** + +Run: `pnpm --filter @promocean/core test && pnpm --filter @promocean/core build && pnpm turbo run typecheck` +Expected: core green; **typecheck RED in adapter-strapi and apps/api** (missing ConfigStore methods, OfferDefinition.timedEventId). Record failures; do not patch (Tasks 5–6). + +- [ ] **Step 5: Commit** + +```bash +git add packages/core +git commit -m "feat(core): timed event lifecycle, multiplier resolution, offer attachment, webhook ports" +``` + +--- + +### Task 3: `@promocean/adapter-db` — webhook delivery store + +**Files:** +- Modify: `packages/adapter-db/src/schema.ts`, `packages/adapter-db/src/stores.ts`, `packages/adapter-db/src/index.ts` +- Create (generated): new migration +- Test: `packages/adapter-db/test/webhook-delivery.test.ts` + +**Interfaces:** +- Produces: `class PgWebhookDeliveryStore implements WebhookDeliveryStore`; tables `runtime.timed_event_notifications` (unique `(project_id, event_id, transition)`) and `runtime.webhook_dead_letters`. + +- [ ] **Step 1: Schema + migration** + +Append to `schema.ts`: +```ts +export const timedEventNotifications = runtime.table('timed_event_notifications', { + projectId: text('project_id').notNull(), + eventId: text('event_id').notNull(), + transition: text('transition').notNull(), + firedAt: timestamp('fired_at', { withTimezone: true }).defaultNow().notNull(), +}, (t) => [uniqueIndex('event_notif_uq').on(t.projectId, t.eventId, t.transition)]) + +export const webhookDeadLetters = runtime.table('webhook_dead_letters', { + id: uuid('id').defaultRandom().primaryKey(), + projectId: text('project_id').notNull(), + url: text('url').notNull(), + payload: text('payload').notNull(), + error: text('error').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).notNull(), +}) +``` +Run `pnpm --filter @promocean/adapter-db db:generate`. + +- [ ] **Step 2: RED test** + +`test/webhook-delivery.test.ts` (same Testcontainers scaffold as `offer-metrics.test.ts`, pool closed before container stop): +```ts +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) + }) + it('records dead letters', async () => { + const store = new PgWebhookDeliveryStore(db) + await store.recordDeadLetter('p1', 'https://x.test/hook', '{"type":"t"}', 'server 500 after 4 attempts', new Date()) + const { rows } = await db.$client.query(`select url, error from runtime.webhook_dead_letters where project_id='p1'`) + expect(rows).toEqual([{ url: 'https://x.test/hook', error: 'server 500 after 4 attempts' }]) + }) +}) +``` + +- [ ] **Step 3: Implement (GREEN)** + +Append to `stores.ts`: +```ts +export class PgWebhookDeliveryStore implements WebhookDeliveryStore { + constructor(private db: Db) {} + async claimTransition(projectId: string, eventId: string, transition: TimedEventTransition) { + const inserted = await this.db.insert(timedEventNotifications) + .values({ projectId, eventId, transition }) + .onConflictDoNothing() + .returning({ eventId: timedEventNotifications.eventId }) + return inserted.length > 0 + } + async recordDeadLetter(projectId: string, url: string, payload: string, error: string, at: Date) { + await this.db.insert(webhookDeadLetters).values({ projectId, url, payload, error, createdAt: at }) + } +} +``` +Export from index. Run `pnpm --filter @promocean/adapter-db test && ... build` — Expected: 7/7. + +- [ ] **Step 4: Commit** + +```bash +git add packages/adapter-db +git commit -m "feat(adapter-db): webhook transition claims and dead-letter store" +``` + +--- + +### Task 4: `apps/cms` — TimedEvent + WebhookEndpoint types, config-plane endpoints, seed + +**Files:** +- Create: `apps/cms/src/api/timed-event/content-types/timed-event/schema.json` (+ factory files) +- Create: `apps/cms/src/api/webhook-endpoint/content-types/webhook-endpoint/schema.json` (+ factory files), `.../webhook-endpoint/lifecycles.ts` +- Modify: `apps/cms/src/api/offer/content-types/offer/schema.json` (add `timedEvent` relation), config-plane routes/controller, `apps/cms/src/index.ts` (seed) + +**Interfaces:** +- Produces protocol (Task 5 consumes): + - `GET /api/config-plane/timed-events?projectId=` → `{ events: [{ id, name, description, startsAt, endsAt, endingSoonMinutes, multiplier, enabled }] }` (ISO strings, explicit nulls; 400 without projectId) + - `GET /api/config-plane/timed-events/all` → same items plus `projectId` (no query param) + - `GET /api/config-plane/webhook-endpoints?projectId=` → `{ endpoints: [{ id, url, secret, enabled }] }` + - Offers endpoint mapping gains `timedEventId: r.timedEvent?.documentId ?? null` (populate `timedEvent`) + - All guarded by `configSecretOk`. +- Content types: `timed-event` — name (req), description (text), startsAt/endsAt (datetime req), endingSoonMinutes (int default 1440 min 1), multiplier (int default 1 min 1), enabled (bool default true), recurrence (json, reserved/unused), project relation. `webhook-endpoint` — url (req), secret (string, configurable false), enabled (bool default true), project relation; beforeCreate lifecycle generates `whsec_<32 hex>` when absent, logs prefix only unless `LOG_PLAINTEXT_KEYS==='true'` (same pattern as api-key lifecycle). +- Seed additions (inside existing gate): timed event **Double Progress Weekend** — description "All achievement progress counts double.", `startsAt = new Date(Date.now() - 3600_000)`, `endsAt = new Date(Date.now() + 7 * 24 * 3600_000)`, endingSoonMinutes 1440, multiplier 2, enabled true. (Wall-clock permitted here only.) No seeded webhook endpoint (nothing listens in dev). + +- [ ] **Step 1: Content types + lifecycle + relation** — per Interfaces block, mirroring existing patterns (achievement schema, api-key lifecycle). +- [ ] **Step 2: Config-plane routes + handlers** — three new routes; reuse `configSecretOk`; the `/timed-events/all` route must be registered BEFORE `/timed-events` if the router is prefix-greedy (verify; Strapi matches exact paths, but confirm). +- [ ] **Step 3: Offers mapping** — add `populate: ['placement', 'timedEvent']` and `timedEventId` to the offers handler. +- [ ] **Step 4: Seed** — append per Interfaces block. +- [ ] **Step 5: Verify live** — fresh DB (`docker compose down -v && docker compose up -d postgres`), boot cms; curl all three endpoints (+ offers endpoint now carrying `timedEventId: null` for the welcome offer) with/without secret; capture outputs; stop Strapi. `pnpm --filter cms typecheck` green (regenerate types via `npx strapi ts:generate-types` as in Sprint 2). +- [ ] **Step 6: Commit** + +```bash +git add apps/cms +git commit -m "feat(cms): timed-event and webhook-endpoint types, config-plane endpoints, live demo event seed" +``` + +--- + +### Task 5: `@promocean/adapter-strapi` — timed events, webhook endpoints, offer mapping + +**Files:** +- Modify: `packages/adapter-strapi/src/index.ts` +- Test: append to `packages/adapter-strapi/test/adapter.test.ts` + +**Interfaces:** +- Produces on `StrapiConfigPlane`: `getTimedEvents(projectId)` (TTL cache + stale-on-error, dates → `Date`, per Interfaces of Task 2), `getAllTimedEvents()` (cache key `'*'`, maps `projectId` through), `getWebhookEndpoints(projectId)` (cached), and the offers mapping gains `timedEventId: (o.timedEventId as string | null) ?? null`. +- Package typecheck green again after this task (apps/api still red until Task 6). + +- [ ] **Step 1: RED tests** — four new: timed-events fetch+mapping (ISO→Date, enabled boolean), stale-on-error for timed events, getAllTimedEvents URL + projectId passthrough, webhook-endpoints fetch. Follow the existing offers-test style with `makePlane`. +- [ ] **Step 2: Implement (GREEN)** — mirror `getOffers` structure; three new cache maps. `pnpm --filter @promocean/adapter-strapi test` (12/12) + typecheck + build green. +- [ ] **Step 3: Commit** + +```bash +git add packages/adapter-strapi +git commit -m "feat(adapter-strapi): timed events, webhook endpoints, and offer event attachment" +``` + +--- + +### Task 6: `apps/api` — multiplier wiring, event-gated offers, live events endpoint + +**Files:** +- Create: `apps/api/src/routes/live-events.ts` +- Modify: `apps/api/src/routes/events.ts`, `apps/api/src/routes/placements.ts`, `apps/api/src/app.ts`, `apps/api/src/index.ts`, `apps/api/test/fakes.ts` +- Test: `apps/api/test/timed-events.test.ts` + +**Interfaces:** +- Produces: + - `POST /v1/events` now computes `const multiplier = activeMultiplier(await deps.configStore.getTimedEvents(scope.projectId), occurredAt)` and passes it to `evaluateEvent(event, definitions, counts, multiplier)`. Config-plane failure on the timed-events fetch must NOT fail ingestion — wrap in try/catch defaulting to 1 (log), since multipliers are an enhancement, not correctness. + - `GET /v1/placements/:slug/offer` passes `activeEventIds(events, now)` as `resolveOffer`'s fourth arg (same failure tolerance: on error pass `undefined`... no — attached offers must NOT appear if event state is unknown; on fetch failure pass `new Set()` and log). + - `GET /v1/events/live` → `LiveEventsResponse`: all non-draft, non-ended events mapped with `state`, `secondsUntilStart` (null unless scheduled), `secondsUntilEnd` (ceil((endsAt−now)/1000)). + - Fakes: `makeFakes` gains `timedEvents: TimedEventDefinition[] = []`; config fake gains `getTimedEvents`/`getAllTimedEvents`/`getWebhookEndpoints` (returning the param / [] / []). + - Workspace typecheck fully green after this task. + +- [ ] **Step 1: RED tests** (`test/timed-events.test.ts`): (a) with a live multiplier-2 event, one `lesson_completed` yields `progress current 2` and unlocks a target-2 achievement; (b) with no events, multiplier 1 behavior unchanged; (c) config-store timed-events failure still ingests at multiplier 1; (d) `/v1/events/live` maps states and countdowns (scheduled event → `secondsUntilStart` number; live → null) and excludes draft/ended; (e) placements: an offer attached to a live event resolves; attached to an inactive event does not. Complete test code written at implementation time following `offers.test.ts` conventions — fixtures per Task 2's `mk` pattern. +- [ ] **Step 2: Implement (GREEN)** — per Interfaces block. `pnpm --filter api test` (14 existing + ~6 new) and `pnpm turbo run typecheck` fully green. +- [ ] **Step 3: Commit** + +```bash +git add apps/api +git commit -m "feat(api): timed-event multipliers, event-gated offers, and live events endpoint" +``` + +--- + +### Task 7: `apps/api` — webhook dispatcher + lifecycle scheduler + unlock webhooks + +**Files:** +- Create: `apps/api/src/webhooks.ts` +- Modify: `apps/api/src/routes/events.ts` (fire unlock webhooks), `apps/api/src/app.ts` (AppDeps gains optional `webhooks?: WebhookDispatcher`), `apps/api/src/index.ts` (construct dispatcher + start scheduler) +- Test: `apps/api/test/webhooks.test.ts` + +**Interfaces:** +- Produces in `src/webhooks.ts`: + +```ts +export class WebhookDispatcher { + constructor(opts: { + configStore: ConfigStore + deliveryStore: WebhookDeliveryStore + fetchImpl?: typeof fetch + maxRetries?: number // default 3, 250ms-base exponential backoff + }) + async deliver(projectId: string, message: WebhookMessage): Promise + // For each enabled endpoint: POST JSON body, header WEBHOOK_SIGNATURE_HEADER = hex hmac-sha256(body, endpoint.secret). + // Per-endpoint isolation: one endpoint failing never blocks others. Exhausted retries → deliveryStore.recordDeadLetter. + // NEVER throws. +} + +export function startLifecycleScheduler(opts: { + configStore: ConfigStore; deliveryStore: WebhookDeliveryStore; dispatcher: WebhookDispatcher; intervalMs?: number // default 30_000 +}): () => void +// Each tick: getAllTimedEvents(); for each event compute state(now); for each REACHED transition in order +// ('live' if state is live|ending_soon|ended, 'ending_soon' if ending_soon|ended, 'ended' if ended) and enabled events only: +// if claimTransition(...) returns true → dispatcher.deliver(projectId, { type: `timed_event.${transition}`, data: {...event fields ISO...}, createdAt: now }). +// Catch-all inside the tick; returns a stop function clearing the interval. +``` + +- Unlock webhooks: in the events route, after building `unlocks`, when non-empty and `deps.webhooks` present: `void deps.webhooks.deliver(scope.projectId, { type: 'achievement.unlocked', data: { userId, environment: scope.environment, unlocks }, createdAt: unlockedAt.toISOString() })` — fire-and-forget, never awaited into the response path. +- Backfill semantics are intentional: an event already `ending_soon` when first observed claims+fires `live` then `ending_soon` on the same tick (ordered), so subscribers always see a complete transition history. + +- [ ] **Step 1: RED tests** (`test/webhooks.test.ts`, mocked fetch + in-memory fakes): + (a) deliver posts to both enabled endpoints with correct HMAC (recompute with `createHmac` in the test and compare), skips disabled; + (b) 5xx then success → retried, single dead-letter-free delivery; persistent failure → `recordDeadLetter` called with the payload and each OTHER endpoint still delivered; + (c) scheduler tick claims and fires `live` exactly once across two ticks (fake claim store), fires `live`+`ending_soon` in order for an event first seen ending_soon, skips disabled events, and stop() halts ticking (use `vi.useFakeTimers`). +- [ ] **Step 2: Implement (GREEN)** — per Interfaces block; wire in `index.ts` (`new WebhookDispatcher(...)`, `startLifecycleScheduler(...)`; pass dispatcher into `createApp` deps). `pnpm --filter api test` all green; workspace typecheck green. +- [ ] **Step 3: Commit** + +```bash +git add apps/api +git commit -m "feat(api): signed webhook dispatcher, lifecycle scheduler, and unlock webhooks" +``` + +--- + +### Task 8: `@promocean/sdk` — getLiveEvents + +**Files:** Modify `packages/sdk/src/index.ts`; append to `packages/sdk/test/sdk.test.ts` + +**Interfaces:** +- Produces: `getLiveEvents(): Promise` — GET `/v1/events/live`, parsed via `liveEventsResponseSchema`, works without identify. + +- [ ] **Step 1: RED test** — mocks fetch, asserts URL and parsed array round-trip (one live event fixture from Task 1's test). +- [ ] **Step 2: Implement (GREEN)** — three-line method using `request()`. 12/12 tests; build clean. +- [ ] **Step 3: Commit** + +```bash +git add packages/sdk +git commit -m "feat(sdk): live timed events query" +``` + +--- + +### Task 9: `@promocean/widgets` — EventCountdown + +**Files:** Create `packages/widgets/src/event-countdown.tsx`; modify `src/index.ts`; append to `test/widgets.test.tsx` + +**Interfaces:** +- Produces: `` — fetches `getLiveEvents()` on mount (fail silent-to-empty); renders each scheduled/live/ending_soon event as a row (`data-promocean-event={eventId}`): name, state badge text (`Starts in`/`Ends in`), and a `HHh MMm SSs` countdown ticking every second (single interval for the component, cleared on unmount; recompute from `endsAt`/`startsAt` and wall clock each tick — never decrement a counter). Renders nothing when no events. + +- [ ] **Step 1: RED tests** — (a) renders event name + countdown container from a mocked client (fake timers; advance 1s, assert text changes); (b) renders nothing on fetch failure; (c) unmount clears the interval (spy on clearInterval or assert no act warnings after unmount+advance). +- [ ] **Step 2: Implement (GREEN)** — inline styles; derive remaining time from dates each tick. 10/10 widget tests pristine; build clean. +- [ ] **Step 3: Commit** + +```bash +git add packages/widgets +git commit -m "feat(widgets): live event countdown component" +``` + +--- + +### Task 10: `apps/demo` — countdown integration + timed-events e2e (Sprint 3 DoD) + +**Files:** +- Modify: `apps/demo/app/promocean.tsx` (add `` between Placement and the buttons), `apps/demo/e2e/achievement-loop.spec.ts` (multiplier-aware assertion) +- Test: `apps/demo/e2e/timed-event-loop.spec.ts` + +**Interfaces:** +- Consumes the seeded live "Double Progress Weekend" (multiplier 2). Fresh dev DB required so the new seed ran (`docker compose down -v` flow from Task 4). CI unchanged (fresh DB every run). + +- [ ] **Step 1: Integrate** — one import + one JSX line. +- [ ] **Step 2: Update achievement e2e** — `1/10` → `2/10` with a comment: `// seeded "Double Progress Weekend" (multiplier 2) is live — one lesson counts double`. +- [ ] **Step 3: New e2e** (`timed-event-loop.spec.ts`): +```ts +import { expect, test } from '@playwright/test' + +test('live event shows countdown and doubles progress', async ({ page }) => { + const user = `e2e-event-${Date.now()}` + await page.goto(`/?user=${user}`) + const event = page.locator('[data-promocean-event]') + await expect(event.getByText('Double Progress Weekend')).toBeVisible() + await expect(event.getByText(/Ends in/)).toBeVisible() + await page.getByRole('button', { name: 'Complete a lesson' }).click() + await expect(page.getByRole('status')).toContainText('First Lesson') + await expect(page.getByText('2/10')).toBeVisible() +}) +``` +- [ ] **Step 4: Run the full e2e suite** (stack running, fresh-seeded DB): `pnpm --filter demo e2e` — Expected: **3 passed**. This green run is the Sprint 3 definition of done. +- [ ] **Step 5: Full workspace green, stop servers, no env files staged, commit** + +```bash +git add apps/demo +git commit -m "feat(demo): live event countdown with multiplier e2e" +``` + +--- + +## Self-Review Notes + +- **Spec coverage:** TimedEvent entity + full lifecycle incl. `ending_soon` ✓ (T2/T4); state computed on read, scheduler only for webhooks ✓ (T2/T7); multiplier effect on achievement progress ✓ (T6); offer attachment to event windows ✓ (T2/T4/T5/T6); `GET /v1/events/live` with server-computed countdown ✓ (T6); signed webhooks (HMAC, `X-Promocean-Signature`), retries, dead-letter table ✓ (T3/T7); unlock webhooks (deferred from Sprint 1) ✓ (T7); webhook endpoints as Strapi content type with secret lifecycle ✓ (T4); reserved `recurrence` field ✓ (T4). Deferred per spec: recurrence semantics, per-user timezone windows, SSE/realtime channel. +- **Cross-task break:** Task 2 → RED workspace typecheck → closed by Tasks 5–6 (same managed pattern as Sprint 2). +- **Known trade-offs encoded:** multiplier fetch failure degrades to 1 (ingestion never blocked); attached offers fail closed when event state is unavailable; scheduler backfills missed transitions in order via the claim table (restart-safe, exactly-once per transition). +- **Type consistency:** `TimedEventDefinition` fields, transition literals, `WEBHOOK_SIGNATURE_HEADER`, `getTimedEvents`/`getAllTimedEvents`/`getWebhookEndpoints`, `claimTransition`/`recordDeadLetter`, `getLiveEvents`, `data-promocean-event` verified consistent across tasks. +- **Placeholder check:** Tasks 5, 6, and 9 specify test intent + exact behavioral assertions rather than full verbatim test code (the fixtures and helper patterns they must follow are named); all production-code interfaces are fully specified. This is a deliberate compression — implementers have the Sprint 1/2 test files as executable style guides. From 858aa722767884143d1160717f799f37d39cee8f Mon Sep 17 00:00:00 2001 From: Steve Hynding Date: Mon, 6 Jul 2026 22:43:03 -0700 Subject: [PATCH 02/13] feat(contracts): live timed event and webhook message schemas Co-Authored-By: Claude Fable 5 --- packages/contracts/src/index.ts | 2 ++ packages/contracts/src/timed-events.ts | 17 +++++++++++++++ packages/contracts/src/webhooks.ts | 10 +++++++++ packages/contracts/test/timed-events.test.ts | 23 ++++++++++++++++++++ 4 files changed, 52 insertions(+) create mode 100644 packages/contracts/src/timed-events.ts create mode 100644 packages/contracts/src/webhooks.ts create mode 100644 packages/contracts/test/timed-events.test.ts diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index dd12479..904da02 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -2,3 +2,5 @@ export * from './errors.js' export * from './events.js' export * from './achievements.js' export * from './offers.js' +export * from './timed-events.js' +export * from './webhooks.js' diff --git a/packages/contracts/src/timed-events.ts b/packages/contracts/src/timed-events.ts new file mode 100644 index 0000000..7121bb8 --- /dev/null +++ b/packages/contracts/src/timed-events.ts @@ -0,0 +1,17 @@ +import { z } from 'zod' + +export const liveTimedEventSchema = z.object({ + eventId: z.string(), + name: z.string(), + description: z.string().nullable(), + state: z.enum(['scheduled', 'live', 'ending_soon']), + startsAt: z.iso.datetime(), + endsAt: z.iso.datetime(), + multiplier: z.number().int().min(1), + secondsUntilStart: z.number().int().nullable(), + secondsUntilEnd: z.number().int(), +}) +export type LiveTimedEvent = z.infer + +export const liveEventsResponseSchema = z.object({ events: z.array(liveTimedEventSchema) }) +export type LiveEventsResponse = z.infer diff --git a/packages/contracts/src/webhooks.ts b/packages/contracts/src/webhooks.ts new file mode 100644 index 0000000..dafdec3 --- /dev/null +++ b/packages/contracts/src/webhooks.ts @@ -0,0 +1,10 @@ +import { z } from 'zod' + +export const webhookMessageSchema = z.object({ + type: z.enum(['timed_event.live', 'timed_event.ending_soon', 'timed_event.ended', 'achievement.unlocked']), + data: z.record(z.string(), z.unknown()), + createdAt: z.iso.datetime(), +}) +export type WebhookMessage = z.infer + +export const WEBHOOK_SIGNATURE_HEADER = 'x-promocean-signature' diff --git a/packages/contracts/test/timed-events.test.ts b/packages/contracts/test/timed-events.test.ts new file mode 100644 index 0000000..e11b028 --- /dev/null +++ b/packages/contracts/test/timed-events.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest' +import { liveEventsResponseSchema, webhookMessageSchema, WEBHOOK_SIGNATURE_HEADER } from '../src/index.js' + +const event = { + eventId: 'e1', name: 'Double Progress Weekend', description: null, state: 'live', + startsAt: '2026-07-07T00:00:00.000Z', endsAt: '2026-07-14T00:00:00.000Z', + multiplier: 2, secondsUntilStart: null, secondsUntilEnd: 604800, +} + +describe('timed event schemas', () => { + it('round-trips a live events response', () => { + expect(liveEventsResponseSchema.parse({ events: [event] })).toEqual({ events: [event] }) + }) + it('rejects draft/ended states on the wire', () => { + for (const state of ['draft', 'ended', 'nope']) + expect(liveEventsResponseSchema.safeParse({ events: [{ ...event, state }] }).success).toBe(false) + }) + it('validates webhook messages and exports the signature header', () => { + expect(webhookMessageSchema.parse({ type: 'achievement.unlocked', data: { userId: 'u1' }, createdAt: event.startsAt }).type).toBe('achievement.unlocked') + expect(webhookMessageSchema.safeParse({ type: 'other', data: {}, createdAt: event.startsAt }).success).toBe(false) + expect(WEBHOOK_SIGNATURE_HEADER).toBe('x-promocean-signature') + }) +}) From 1de3e9bea8d0c85ba0129e165a7d0b14fa51a389 Mon Sep 17 00:00:00 2001 From: Steve Hynding Date: Mon, 6 Jul 2026 22:45:51 -0700 Subject: [PATCH 03/13] feat(core): timed event lifecycle, multiplier resolution, offer attachment, webhook ports --- packages/core/src/index.ts | 1 + packages/core/src/offers.ts | 2 ++ packages/core/src/ports.ts | 10 +++++++- packages/core/src/timed-events.ts | 23 +++++++++++++++++ packages/core/src/types.ts | 22 +++++++++++++++++ packages/core/test/offers.test.ts | 11 ++++++++- packages/core/test/timed-events.test.ts | 33 +++++++++++++++++++++++++ 7 files changed, 100 insertions(+), 2 deletions(-) create mode 100644 packages/core/src/timed-events.ts create mode 100644 packages/core/test/timed-events.test.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7e24190..473020c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -2,3 +2,4 @@ export * from './types.js' export * from './ports.js' export * from './evaluate.js' export * from './offers.js' +export * from './timed-events.js' diff --git a/packages/core/src/offers.ts b/packages/core/src/offers.ts index 16ea4db..bcd3447 100644 --- a/packages/core/src/offers.ts +++ b/packages/core/src/offers.ts @@ -4,10 +4,12 @@ export function resolveOffer( placementSlug: string, offers: OfferDefinition[], now: Date, + activeEvents?: ReadonlySet, ): OfferDefinition | null { let best: OfferDefinition | null = null for (const offer of offers) { if (offer.placementSlug !== placementSlug) continue + if (offer.timedEventId !== null && !activeEvents?.has(offer.timedEventId)) continue if (offer.startsAt && offer.startsAt > now) continue if (offer.endsAt && offer.endsAt <= now) continue if (!best || offer.priority > best.priority) best = offer diff --git a/packages/core/src/ports.ts b/packages/core/src/ports.ts index 77eb16a..92d96a9 100644 --- a/packages/core/src/ports.ts +++ b/packages/core/src/ports.ts @@ -1,8 +1,11 @@ -import type { AchievementDefinition, AuthContext, OfferDefinition, Scope } from './types.js' +import type { AchievementDefinition, AuthContext, OfferDefinition, Scope, TimedEventDefinition, WebhookEndpointDefinition } from './types.js' export interface ConfigStore { getAchievements(projectId: string): Promise getOffers(projectId: string): Promise + getTimedEvents(projectId: string): Promise + getAllTimedEvents(): Promise> + getWebhookEndpoints(projectId: string): Promise } export interface ApiKeyStore { @@ -40,3 +43,8 @@ export interface OfferMetricsStore { recordImpression(scope: Scope, offerId: string, userId: string | null, at: Date): Promise recordClick(scope: Scope, offerId: string, userId: string | null, at: Date): Promise } + +export interface WebhookDeliveryStore { + claimTransition(projectId: string, eventId: string, transition: string): Promise + recordDeadLetter(projectId: string, url: string, payload: string, error: string, at: Date): Promise +} diff --git a/packages/core/src/timed-events.ts b/packages/core/src/timed-events.ts new file mode 100644 index 0000000..cc43596 --- /dev/null +++ b/packages/core/src/timed-events.ts @@ -0,0 +1,23 @@ +import type { TimedEventDefinition, TimedEventState } from './types.js' + +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() + return msLeft <= event.endingSoonMinutes * 60_000 ? 'ending_soon' : 'live' +} + +const isActive = (s: TimedEventState) => s === 'live' || s === 'ending_soon' + +export function activeMultiplier(events: TimedEventDefinition[], now: Date): number { + let max = 1 + for (const e of events) if (isActive(timedEventState(e, now)) && e.multiplier > max) max = e.multiplier + return max +} + +export function activeEventIds(events: TimedEventDefinition[], now: Date): Set { + const ids = new Set() + for (const e of events) if (isActive(timedEventState(e, now))) ids.add(e.id) + return ids +} diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index b6b99ab..568f062 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -45,4 +45,26 @@ export interface OfferDefinition { endsAt: Date | null priority: number audience: OfferAudience + timedEventId: string | null +} + +export type TimedEventState = 'draft' | 'scheduled' | 'live' | 'ending_soon' | 'ended' +export type TimedEventTransition = 'live' | 'ending_soon' | 'ended' + +export interface TimedEventDefinition { + id: string + name: string + description: string | null + startsAt: Date + endsAt: Date + endingSoonMinutes: number + multiplier: number + enabled: boolean +} + +export interface WebhookEndpointDefinition { + id: string + url: string + secret: string + enabled: boolean } diff --git a/packages/core/test/offers.test.ts b/packages/core/test/offers.test.ts index c468f61..6e08363 100644 --- a/packages/core/test/offers.test.ts +++ b/packages/core/test/offers.test.ts @@ -3,7 +3,7 @@ import { resolveOffer, type OfferDefinition } from '../src/index.js' const base = { headline: 'x', body: null, imageUrl: null, ctaText: null, ctaUrl: null, - priority: 0, audience: { kind: 'everyone' as const }, + priority: 0, audience: { kind: 'everyone' as const }, timedEventId: null, } const now = new Date('2026-07-15T12:00:00Z') const offers: OfferDefinition[] = [ @@ -31,3 +31,12 @@ describe('resolveOffer', () => { expect(resolveOffer('nonexistent', offers, now)).toBeNull() }) }) + +describe('resolveOffer with event attachment', () => { + const attached: OfferDefinition = { ...base, id: 'event-offer', placementSlug: 'homepage-banner', startsAt: null, endsAt: null, priority: 99, timedEventId: 'e1' } + it('resolves attached offers only while their event is active', () => { + expect(resolveOffer('homepage-banner', [attached], now, new Set(['e1']))?.id).toBe('event-offer') + expect(resolveOffer('homepage-banner', [attached], now, new Set())).toBeNull() + expect(resolveOffer('homepage-banner', [attached], now)).toBeNull() + }) +}) diff --git a/packages/core/test/timed-events.test.ts b/packages/core/test/timed-events.test.ts new file mode 100644 index 0000000..8c08c63 --- /dev/null +++ b/packages/core/test/timed-events.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' +import { activeEventIds, activeMultiplier, timedEventState, type TimedEventDefinition } from '../src/index.js' + +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, +}) + +describe('timedEventState', () => { + const e = mk({}) + it('walks the full lifecycle', () => { + expect(timedEventState(mk({ enabled: false }), new Date('2026-07-12T00:00:00Z'))).toBe('draft') + expect(timedEventState(e, new Date('2026-07-09T00:00:00Z'))).toBe('scheduled') + expect(timedEventState(e, new Date('2026-07-10T00:00:00Z'))).toBe('live') // startsAt inclusive + expect(timedEventState(e, new Date('2026-07-16T00:00:00Z'))).toBe('ending_soon') // exactly 24h left + expect(timedEventState(e, new Date('2026-07-17T00:00:00Z'))).toBe('ended') // endsAt exclusive + }) +}) + +describe('activeMultiplier / activeEventIds', () => { + const now = new Date('2026-07-12T00:00:00Z') + it('takes the max across live events, floor 1', () => { + expect(activeMultiplier([], now)).toBe(1) + expect(activeMultiplier([mk({ multiplier: 2 }), mk({ id: 'e2', multiplier: 3 })], now)).toBe(3) + expect(activeMultiplier([mk({ enabled: false, multiplier: 5 })], now)).toBe(1) + expect(activeMultiplier([mk({ startsAt: new Date('2026-08-01T00:00:00Z'), multiplier: 5 })], now)).toBe(1) + }) + it('collects live and ending_soon ids only', () => { + const events = [mk({}), mk({ id: 'e2', endsAt: new Date('2026-07-12T12:00:00Z') }), mk({ id: 'e3', enabled: false })] + expect(activeEventIds(events, now)).toEqual(new Set(['e1', 'e2'])) + }) +}) From 80e60fe511785495ec3082929d30dbbe5d18d7a0 Mon Sep 17 00:00:00 2001 From: Steve Hynding Date: Mon, 6 Jul 2026 22:48:15 -0700 Subject: [PATCH 04/13] fix(core): type claimTransition transition param as TimedEventTransition Co-Authored-By: Claude Fable 5 --- packages/core/src/ports.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/src/ports.ts b/packages/core/src/ports.ts index 92d96a9..22ccd53 100644 --- a/packages/core/src/ports.ts +++ b/packages/core/src/ports.ts @@ -1,4 +1,4 @@ -import type { AchievementDefinition, AuthContext, OfferDefinition, Scope, TimedEventDefinition, WebhookEndpointDefinition } from './types.js' +import type { AchievementDefinition, AuthContext, OfferDefinition, Scope, TimedEventDefinition, TimedEventTransition, WebhookEndpointDefinition } from './types.js' export interface ConfigStore { getAchievements(projectId: string): Promise @@ -45,6 +45,6 @@ export interface OfferMetricsStore { } export interface WebhookDeliveryStore { - claimTransition(projectId: string, eventId: string, transition: string): Promise + claimTransition(projectId: string, eventId: string, transition: TimedEventTransition): Promise recordDeadLetter(projectId: string, url: string, payload: string, error: string, at: Date): Promise } From 658e04a0712aad8af82c4fd60f9523d407f2f5a7 Mon Sep 17 00:00:00 2001 From: Steve Hynding Date: Mon, 6 Jul 2026 22:50:53 -0700 Subject: [PATCH 05/13] feat(adapter-db): webhook transition claims and dead-letter store --- .../migrations/0002_good_wendigo.sql | 17 + .../migrations/meta/0002_snapshot.json | 590 ++++++++++++++++++ .../adapter-db/migrations/meta/_journal.json | 7 + packages/adapter-db/src/index.ts | 2 +- packages/adapter-db/src/schema.ts | 16 + packages/adapter-db/src/stores.ts | 18 +- .../adapter-db/test/webhook-delivery.test.ts | 29 + 7 files changed, 676 insertions(+), 3 deletions(-) create mode 100644 packages/adapter-db/migrations/0002_good_wendigo.sql create mode 100644 packages/adapter-db/migrations/meta/0002_snapshot.json create mode 100644 packages/adapter-db/test/webhook-delivery.test.ts diff --git a/packages/adapter-db/migrations/0002_good_wendigo.sql b/packages/adapter-db/migrations/0002_good_wendigo.sql new file mode 100644 index 0000000..a1e766c --- /dev/null +++ b/packages/adapter-db/migrations/0002_good_wendigo.sql @@ -0,0 +1,17 @@ +CREATE TABLE "runtime"."timed_event_notifications" ( + "project_id" text NOT NULL, + "event_id" text NOT NULL, + "transition" text NOT NULL, + "fired_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "runtime"."webhook_dead_letters" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "project_id" text NOT NULL, + "url" text NOT NULL, + "payload" text NOT NULL, + "error" text NOT NULL, + "created_at" timestamp with time zone NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX "event_notif_uq" ON "runtime"."timed_event_notifications" USING btree ("project_id","event_id","transition"); \ No newline at end of file diff --git a/packages/adapter-db/migrations/meta/0002_snapshot.json b/packages/adapter-db/migrations/meta/0002_snapshot.json new file mode 100644 index 0000000..59fc1fd --- /dev/null +++ b/packages/adapter-db/migrations/meta/0002_snapshot.json @@ -0,0 +1,590 @@ +{ + "id": "e3a23131-1d9c-418f-bffa-1b08d443729e", + "prevId": "6fa63a65-37ae-49aa-820b-166624868da4", + "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.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": {} + } + }, + "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 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "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 + }, + "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()" + } + }, + "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": "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": {} + } + }, + "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.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 5d835c8..c0b6303 100644 --- a/packages/adapter-db/migrations/meta/_journal.json +++ b/packages/adapter-db/migrations/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1783398768729, "tag": "0001_sturdy_proudstar", "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1783403411504, + "tag": "0002_good_wendigo", + "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 879bdbf..81fa6e1 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 { PgEventStore, PgOfferMetricsStore, PgProgressStore, PgUsageStore } from './stores.js' +export { PgEventStore, PgOfferMetricsStore, PgProgressStore, 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 9eed582..821d7fc 100644 --- a/packages/adapter-db/src/schema.ts +++ b/packages/adapter-db/src/schema.ts @@ -54,3 +54,19 @@ export const offerEvents = runtime.table('offer_events', { kind: text('kind').notNull(), // 'impression' | 'click' createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), }) + +export const timedEventNotifications = runtime.table('timed_event_notifications', { + projectId: text('project_id').notNull(), + eventId: text('event_id').notNull(), + transition: text('transition').notNull(), + firedAt: timestamp('fired_at', { withTimezone: true }).defaultNow().notNull(), +}, (t) => [uniqueIndex('event_notif_uq').on(t.projectId, t.eventId, t.transition)]) + +export const webhookDeadLetters = runtime.table('webhook_dead_letters', { + id: uuid('id').defaultRandom().primaryKey(), + projectId: text('project_id').notNull(), + url: text('url').notNull(), + payload: text('payload').notNull(), + error: text('error').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).notNull(), +}) diff --git a/packages/adapter-db/src/stores.ts b/packages/adapter-db/src/stores.ts index 4ea06cf..5d8617d 100644 --- a/packages/adapter-db/src/stores.ts +++ b/packages/adapter-db/src/stores.ts @@ -1,6 +1,6 @@ import { and, eq, inArray, sql } from 'drizzle-orm' -import type { EventStore, OfferMetricsStore, ProgressStore, Scope, UsageStore } from '@promocean/core' -import { achievementProgress, events, monthlyActiveUsers, offerEvents, unlocks, usageCounters } from './schema.js' +import type { EventStore, OfferMetricsStore, ProgressStore, Scope, TimedEventTransition, UsageStore, WebhookDeliveryStore } from '@promocean/core' +import { achievementProgress, events, monthlyActiveUsers, offerEvents, timedEventNotifications, unlocks, usageCounters, webhookDeadLetters } from './schema.js' import type { Db } from './index.js' const scoped = (t: { projectId: any; environment: any }, s: Scope) => @@ -79,3 +79,17 @@ export class PgOfferMetricsStore implements OfferMetricsStore { await this.db.insert(offerEvents).values({ ...scope, offerId, userId, kind: 'click', createdAt: at }) } } + +export class PgWebhookDeliveryStore implements WebhookDeliveryStore { + constructor(private db: Db) {} + async claimTransition(projectId: string, eventId: string, transition: TimedEventTransition) { + const inserted = await this.db.insert(timedEventNotifications) + .values({ projectId, eventId, transition }) + .onConflictDoNothing() + .returning({ eventId: timedEventNotifications.eventId }) + return inserted.length > 0 + } + async recordDeadLetter(projectId: string, url: string, payload: string, error: string, at: Date) { + await this.db.insert(webhookDeadLetters).values({ projectId, url, payload, error, createdAt: at }) + } +} diff --git a/packages/adapter-db/test/webhook-delivery.test.ts b/packages/adapter-db/test/webhook-delivery.test.ts new file mode 100644 index 0000000..c60db68 --- /dev/null +++ b/packages/adapter-db/test/webhook-delivery.test.ts @@ -0,0 +1,29 @@ +import { PostgreSqlContainer, type StartedPostgreSqlContainer } from '@testcontainers/postgresql' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { createDb, runMigrations, PgWebhookDeliveryStore, type Db } from '../src/index.js' + +let container: StartedPostgreSqlContainer +let db: Db + +beforeAll(async () => { + container = await new PostgreSqlContainer('postgres:17').start() + db = createDb(container.getConnectionUri()) + await runMigrations(db) +}) +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) + }) + it('records dead letters', async () => { + const store = new PgWebhookDeliveryStore(db) + await store.recordDeadLetter('p1', 'https://x.test/hook', '{"type":"t"}', 'server 500 after 4 attempts', new Date()) + const { rows } = await db.$client.query(`select url, error from runtime.webhook_dead_letters where project_id='p1'`) + expect(rows).toEqual([{ url: 'https://x.test/hook', error: 'server 500 after 4 attempts' }]) + }) +}) From 03796d7f47b367c394fbf7f4901891b69e421299 Mon Sep 17 00:00:00 2001 From: Steve Hynding Date: Mon, 6 Jul 2026 22:56:48 -0700 Subject: [PATCH 06/13] feat(cms): timed-event and webhook-endpoint types, config-plane endpoints, live demo event seed --- .../config-plane/controllers/config-plane.ts | 58 +++++++++++- .../api/config-plane/routes/config-plane.ts | 3 + .../api/offer/content-types/offer/schema.json | 3 +- .../content-types/timed-event/schema.json | 17 ++++ .../timed-event/controllers/timed-event.ts | 3 + .../src/api/timed-event/routes/timed-event.ts | 3 + .../api/timed-event/services/timed-event.ts | 3 + .../webhook-endpoint/lifecycles.ts | 15 +++ .../webhook-endpoint/schema.json | 12 +++ .../controllers/webhook-endpoint.ts | 3 + .../routes/webhook-endpoint.ts | 3 + .../services/webhook-endpoint.ts | 3 + apps/cms/src/index.ts | 12 +++ apps/cms/types/generated/contentTypes.d.ts | 94 +++++++++++++++++++ 14 files changed, 230 insertions(+), 2 deletions(-) create mode 100644 apps/cms/src/api/timed-event/content-types/timed-event/schema.json create mode 100644 apps/cms/src/api/timed-event/controllers/timed-event.ts create mode 100644 apps/cms/src/api/timed-event/routes/timed-event.ts create mode 100644 apps/cms/src/api/timed-event/services/timed-event.ts create mode 100644 apps/cms/src/api/webhook-endpoint/content-types/webhook-endpoint/lifecycles.ts create mode 100644 apps/cms/src/api/webhook-endpoint/content-types/webhook-endpoint/schema.json create mode 100644 apps/cms/src/api/webhook-endpoint/controllers/webhook-endpoint.ts create mode 100644 apps/cms/src/api/webhook-endpoint/routes/webhook-endpoint.ts create mode 100644 apps/cms/src/api/webhook-endpoint/services/webhook-endpoint.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 e68d8fd..bd07536 100644 --- a/apps/cms/src/api/config-plane/controllers/config-plane.ts +++ b/apps/cms/src/api/config-plane/controllers/config-plane.ts @@ -33,7 +33,7 @@ export default { if (!projectId) return ctx.badRequest('projectId is required') const rows = await strapi.documents('api::offer.offer').findMany({ filters: { project: { documentId: projectId } }, - populate: ['placement'], + populate: ['placement', 'timedEvent'], }) ctx.body = { offers: rows @@ -49,9 +49,65 @@ export default { startsAt: r.startsAt ?? null, endsAt: r.endsAt ?? null, priority: r.priority ?? 0, + timedEventId: r.timedEvent?.documentId ?? null, })), } }, + async timedEvents(ctx: any) { + if (!configSecretOk(ctx)) return ctx.unauthorized() + const projectId = String(ctx.query.projectId ?? '') + if (!projectId) return ctx.badRequest('projectId is required') + const rows = await strapi.documents('api::timed-event.timed-event').findMany({ + filters: { project: { documentId: projectId } }, + }) + ctx.body = { + events: rows.map((r: any) => ({ + id: r.documentId, + name: r.name, + description: r.description ?? null, + startsAt: r.startsAt, + endsAt: r.endsAt, + endingSoonMinutes: r.endingSoonMinutes, + multiplier: r.multiplier, + enabled: r.enabled, + })), + } + }, + async timedEventsAll(ctx: any) { + if (!configSecretOk(ctx)) return ctx.unauthorized() + const rows = await strapi.documents('api::timed-event.timed-event').findMany({ + populate: ['project'], + }) + ctx.body = { + events: rows.map((r: any) => ({ + id: r.documentId, + name: r.name, + description: r.description ?? null, + startsAt: r.startsAt, + endsAt: r.endsAt, + endingSoonMinutes: r.endingSoonMinutes, + multiplier: r.multiplier, + enabled: r.enabled, + projectId: r.project?.documentId ?? null, + })), + } + }, + async webhookEndpoints(ctx: any) { + if (!configSecretOk(ctx)) return ctx.unauthorized() + const projectId = String(ctx.query.projectId ?? '') + if (!projectId) return ctx.badRequest('projectId is required') + const rows = await strapi.documents('api::webhook-endpoint.webhook-endpoint').findMany({ + filters: { project: { documentId: projectId } }, + }) + ctx.body = { + endpoints: rows.map((r: any) => ({ + id: r.documentId, + url: r.url, + secret: r.secret, + enabled: r.enabled, + })), + } + }, async verifyKey(ctx: any) { if (!configSecretOk(ctx)) return ctx.unauthorized() const { keyHash } = ctx.request.body ?? {} diff --git a/apps/cms/src/api/config-plane/routes/config-plane.ts b/apps/cms/src/api/config-plane/routes/config-plane.ts index b802aac..9f9068f 100644 --- a/apps/cms/src/api/config-plane/routes/config-plane.ts +++ b/apps/cms/src/api/config-plane/routes/config-plane.ts @@ -2,6 +2,9 @@ export default { routes: [ { method: 'GET', path: '/config-plane/achievements', handler: 'config-plane.achievements', config: { auth: false } }, { method: 'GET', path: '/config-plane/offers', handler: 'config-plane.offers', config: { auth: false } }, + { method: 'GET', path: '/config-plane/timed-events/all', handler: 'config-plane.timedEventsAll', config: { auth: false } }, + { method: 'GET', path: '/config-plane/timed-events', handler: 'config-plane.timedEvents', config: { auth: false } }, + { method: 'GET', path: '/config-plane/webhook-endpoints', handler: 'config-plane.webhookEndpoints', config: { auth: false } }, { method: 'POST', path: '/config-plane/verify-key', handler: 'config-plane.verifyKey', config: { auth: false } }, ], } diff --git a/apps/cms/src/api/offer/content-types/offer/schema.json b/apps/cms/src/api/offer/content-types/offer/schema.json index e123426..845cbed 100644 --- a/apps/cms/src/api/offer/content-types/offer/schema.json +++ b/apps/cms/src/api/offer/content-types/offer/schema.json @@ -14,6 +14,7 @@ "endsAt": { "type": "datetime" }, "priority": { "type": "integer", "default": 0, "required": true }, "placement": { "type": "relation", "relation": "manyToOne", "target": "api::placement.placement" }, - "project": { "type": "relation", "relation": "manyToOne", "target": "api::project.project" } + "project": { "type": "relation", "relation": "manyToOne", "target": "api::project.project" }, + "timedEvent": { "type": "relation", "relation": "manyToOne", "target": "api::timed-event.timed-event" } } } 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 new file mode 100644 index 0000000..911b12c --- /dev/null +++ b/apps/cms/src/api/timed-event/content-types/timed-event/schema.json @@ -0,0 +1,17 @@ +{ + "kind": "collectionType", + "collectionName": "timed_events", + "info": { "singularName": "timed-event", "pluralName": "timed-events", "displayName": "Timed Event" }, + "options": { "draftAndPublish": false }, + "attributes": { + "name": { "type": "string", "required": true }, + "description": { "type": "text" }, + "startsAt": { "type": "datetime", "required": true }, + "endsAt": { "type": "datetime", "required": true }, + "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" }, + "project": { "type": "relation", "relation": "manyToOne", "target": "api::project.project" } + } +} diff --git a/apps/cms/src/api/timed-event/controllers/timed-event.ts b/apps/cms/src/api/timed-event/controllers/timed-event.ts new file mode 100644 index 0000000..6ae0d01 --- /dev/null +++ b/apps/cms/src/api/timed-event/controllers/timed-event.ts @@ -0,0 +1,3 @@ +import { factories } from '@strapi/strapi' + +export default factories.createCoreController('api::timed-event.timed-event') diff --git a/apps/cms/src/api/timed-event/routes/timed-event.ts b/apps/cms/src/api/timed-event/routes/timed-event.ts new file mode 100644 index 0000000..faf1a96 --- /dev/null +++ b/apps/cms/src/api/timed-event/routes/timed-event.ts @@ -0,0 +1,3 @@ +import { factories } from '@strapi/strapi' + +export default factories.createCoreRouter('api::timed-event.timed-event') diff --git a/apps/cms/src/api/timed-event/services/timed-event.ts b/apps/cms/src/api/timed-event/services/timed-event.ts new file mode 100644 index 0000000..d781691 --- /dev/null +++ b/apps/cms/src/api/timed-event/services/timed-event.ts @@ -0,0 +1,3 @@ +import { factories } from '@strapi/strapi' + +export default factories.createCoreService('api::timed-event.timed-event') diff --git a/apps/cms/src/api/webhook-endpoint/content-types/webhook-endpoint/lifecycles.ts b/apps/cms/src/api/webhook-endpoint/content-types/webhook-endpoint/lifecycles.ts new file mode 100644 index 0000000..e109bea --- /dev/null +++ b/apps/cms/src/api/webhook-endpoint/content-types/webhook-endpoint/lifecycles.ts @@ -0,0 +1,15 @@ +import { randomBytes } from 'node:crypto' + +export default { + beforeCreate(event: any) { + const data = event.params.data + if (data.secret) return // seeded with a precomputed secret + const raw = `whsec_${randomBytes(16).toString('hex')}` + data.secret = raw + if (process.env.LOG_PLAINTEXT_KEYS === 'true') { + strapi.log.info(`[promocean] Webhook endpoint secret created — shown ONCE: ${raw}`) + } else { + strapi.log.info(`[promocean] Webhook endpoint secret created: prefix=${raw.slice(0, 12)} (set LOG_PLAINTEXT_KEYS=true in dev to reveal at creation)`) + } + }, +} diff --git a/apps/cms/src/api/webhook-endpoint/content-types/webhook-endpoint/schema.json b/apps/cms/src/api/webhook-endpoint/content-types/webhook-endpoint/schema.json new file mode 100644 index 0000000..a955e57 --- /dev/null +++ b/apps/cms/src/api/webhook-endpoint/content-types/webhook-endpoint/schema.json @@ -0,0 +1,12 @@ +{ + "kind": "collectionType", + "collectionName": "webhook_endpoints", + "info": { "singularName": "webhook-endpoint", "pluralName": "webhook-endpoints", "displayName": "Webhook Endpoint" }, + "options": { "draftAndPublish": false }, + "attributes": { + "url": { "type": "string", "required": true }, + "secret": { "type": "string", "configurable": false }, + "enabled": { "type": "boolean", "required": true, "default": true }, + "project": { "type": "relation", "relation": "manyToOne", "target": "api::project.project" } + } +} diff --git a/apps/cms/src/api/webhook-endpoint/controllers/webhook-endpoint.ts b/apps/cms/src/api/webhook-endpoint/controllers/webhook-endpoint.ts new file mode 100644 index 0000000..b299025 --- /dev/null +++ b/apps/cms/src/api/webhook-endpoint/controllers/webhook-endpoint.ts @@ -0,0 +1,3 @@ +import { factories } from '@strapi/strapi' + +export default factories.createCoreController('api::webhook-endpoint.webhook-endpoint') diff --git a/apps/cms/src/api/webhook-endpoint/routes/webhook-endpoint.ts b/apps/cms/src/api/webhook-endpoint/routes/webhook-endpoint.ts new file mode 100644 index 0000000..c41622a --- /dev/null +++ b/apps/cms/src/api/webhook-endpoint/routes/webhook-endpoint.ts @@ -0,0 +1,3 @@ +import { factories } from '@strapi/strapi' + +export default factories.createCoreRouter('api::webhook-endpoint.webhook-endpoint') diff --git a/apps/cms/src/api/webhook-endpoint/services/webhook-endpoint.ts b/apps/cms/src/api/webhook-endpoint/services/webhook-endpoint.ts new file mode 100644 index 0000000..828d565 --- /dev/null +++ b/apps/cms/src/api/webhook-endpoint/services/webhook-endpoint.ts @@ -0,0 +1,3 @@ +import { factories } from '@strapi/strapi' + +export default factories.createCoreService('api::webhook-endpoint.webhook-endpoint') diff --git a/apps/cms/src/index.ts b/apps/cms/src/index.ts index 54cbd11..1f2046a 100644 --- a/apps/cms/src/index.ts +++ b/apps/cms/src/index.ts @@ -69,6 +69,18 @@ export default { project: project.documentId, }, }) + await strapi.documents('api::timed-event.timed-event').create({ + data: { + name: 'Double Progress Weekend', + description: 'All achievement progress counts double.', + startsAt: new Date(Date.now() - 3600_000), + endsAt: new Date(Date.now() + 7 * 24 * 3600_000), + endingSoonMinutes: 1440, + multiplier: 2, + enabled: true, + project: project.documentId, + }, + }) if (process.env.LOG_PLAINTEXT_KEYS === 'true') { strapi.log.info(`[promocean] Seeded demo project ${project.documentId} with key ${rawKey}`) } else { diff --git a/apps/cms/types/generated/contentTypes.d.ts b/apps/cms/types/generated/contentTypes.d.ts index 4a1108e..dd4819a 100644 --- a/apps/cms/types/generated/contentTypes.d.ts +++ b/apps/cms/types/generated/contentTypes.d.ts @@ -552,6 +552,10 @@ export interface ApiOfferOffer extends Struct.CollectionTypeSchema { project: Schema.Attribute.Relation<'manyToOne', 'api::project.project'>; publishedAt: Schema.Attribute.DateTime; startsAt: Schema.Attribute.DateTime; + timedEvent: Schema.Attribute.Relation< + 'manyToOne', + 'api::timed-event.timed-event' + >; updatedAt: Schema.Attribute.DateTime; updatedBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> & Schema.Attribute.Private; @@ -617,6 +621,94 @@ export interface ApiProjectProject extends Struct.CollectionTypeSchema { }; } +export interface ApiTimedEventTimedEvent extends Struct.CollectionTypeSchema { + collectionName: 'timed_events'; + info: { + displayName: 'Timed Event'; + pluralName: 'timed-events'; + singularName: 'timed-event'; + }; + options: { + draftAndPublish: false; + }; + attributes: { + createdAt: Schema.Attribute.DateTime; + createdBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> & + Schema.Attribute.Private; + description: Schema.Attribute.Text; + enabled: Schema.Attribute.Boolean & + Schema.Attribute.Required & + Schema.Attribute.DefaultTo; + endingSoonMinutes: Schema.Attribute.Integer & + Schema.Attribute.Required & + Schema.Attribute.SetMinMax< + { + min: 1; + }, + number + > & + Schema.Attribute.DefaultTo<1440>; + endsAt: Schema.Attribute.DateTime & Schema.Attribute.Required; + locale: Schema.Attribute.String & Schema.Attribute.Private; + localizations: Schema.Attribute.Relation< + 'oneToMany', + 'api::timed-event.timed-event' + > & + Schema.Attribute.Private; + multiplier: Schema.Attribute.Integer & + Schema.Attribute.Required & + Schema.Attribute.SetMinMax< + { + min: 1; + }, + number + > & + Schema.Attribute.DefaultTo<1>; + name: Schema.Attribute.String & Schema.Attribute.Required; + project: Schema.Attribute.Relation<'manyToOne', 'api::project.project'>; + publishedAt: Schema.Attribute.DateTime; + recurrence: Schema.Attribute.JSON; + startsAt: Schema.Attribute.DateTime & Schema.Attribute.Required; + updatedAt: Schema.Attribute.DateTime; + updatedBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> & + Schema.Attribute.Private; + }; +} + +export interface ApiWebhookEndpointWebhookEndpoint + extends Struct.CollectionTypeSchema { + collectionName: 'webhook_endpoints'; + info: { + displayName: 'Webhook Endpoint'; + pluralName: 'webhook-endpoints'; + singularName: 'webhook-endpoint'; + }; + options: { + draftAndPublish: false; + }; + attributes: { + createdAt: Schema.Attribute.DateTime; + createdBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> & + Schema.Attribute.Private; + enabled: Schema.Attribute.Boolean & + Schema.Attribute.Required & + Schema.Attribute.DefaultTo; + locale: Schema.Attribute.String & Schema.Attribute.Private; + localizations: Schema.Attribute.Relation< + 'oneToMany', + 'api::webhook-endpoint.webhook-endpoint' + > & + Schema.Attribute.Private; + project: Schema.Attribute.Relation<'manyToOne', 'api::project.project'>; + publishedAt: Schema.Attribute.DateTime; + secret: Schema.Attribute.String; + updatedAt: Schema.Attribute.DateTime; + updatedBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> & + Schema.Attribute.Private; + url: Schema.Attribute.String & Schema.Attribute.Required; + }; +} + export interface PluginContentReleasesRelease extends Struct.CollectionTypeSchema { collectionName: 'strapi_releases'; @@ -1133,6 +1225,8 @@ declare module '@strapi/strapi' { 'api::offer.offer': ApiOfferOffer; 'api::placement.placement': ApiPlacementPlacement; 'api::project.project': ApiProjectProject; + 'api::timed-event.timed-event': ApiTimedEventTimedEvent; + 'api::webhook-endpoint.webhook-endpoint': ApiWebhookEndpointWebhookEndpoint; 'plugin::content-releases.release': PluginContentReleasesRelease; 'plugin::content-releases.release-action': PluginContentReleasesReleaseAction; 'plugin::i18n.locale': PluginI18NLocale; From 9e7b847d69211e1683eb9b9651e17d1846b86d27 Mon Sep 17 00:00:00 2001 From: Steve Hynding Date: Mon, 6 Jul 2026 23:01:34 -0700 Subject: [PATCH 07/13] feat(adapter-strapi): timed events, webhook endpoints, and offer event attachment --- packages/adapter-strapi/src/index.ts | 95 +++++++++++++++++++- packages/adapter-strapi/test/adapter.test.ts | 65 +++++++++++++- 2 files changed, 157 insertions(+), 3 deletions(-) diff --git a/packages/adapter-strapi/src/index.ts b/packages/adapter-strapi/src/index.ts index 6b15b2b..d14e261 100644 --- a/packages/adapter-strapi/src/index.ts +++ b/packages/adapter-strapi/src/index.ts @@ -1,5 +1,13 @@ import { createHash } from 'node:crypto' -import type { AchievementDefinition, ApiKeyStore, AuthContext, ConfigStore, OfferDefinition } from '@promocean/core' +import type { + AchievementDefinition, + ApiKeyStore, + AuthContext, + ConfigStore, + OfferDefinition, + TimedEventDefinition, + WebhookEndpointDefinition, +} from '@promocean/core' export interface StrapiConfigPlaneOptions { baseUrl: string @@ -16,6 +24,9 @@ export class StrapiConfigPlane implements ConfigStore, ApiKeyStore { private achievementsCache = new Map>() private offersCache = new Map>() private authCache = new Map>() + private timedEventsCache = new Map>() + private allTimedEventsCache = new Map>>() + private webhookEndpointsCache = new Map>() constructor(private opts: StrapiConfigPlaneOptions) { this.ttl = opts.cacheTtlMs ?? 30_000 @@ -66,6 +77,7 @@ export class StrapiConfigPlane implements ConfigStore, ApiKeyStore { endsAt: o.endsAt ? new Date(String(o.endsAt)) : null, priority: Number(o.priority ?? 0), audience: { kind: 'everyone' }, + timedEventId: (o.timedEventId as string | null) ?? null, })) this.offersCache.set(projectId, { value: offers, expires: Date.now() + this.ttl }) return offers @@ -75,6 +87,87 @@ export class StrapiConfigPlane implements ConfigStore, ApiKeyStore { } } + async getTimedEvents(projectId: string): Promise { + const cached = this.timedEventsCache.get(projectId) + if (cached && cached.expires > Date.now()) return cached.value + try { + const res = await this.fetchImpl( + `${this.opts.baseUrl}/api/config-plane/timed-events?projectId=${encodeURIComponent(projectId)}`, + { headers: this.headers() }, + ) + if (!res.ok) throw new Error(`config plane responded ${res.status}`) + const body = (await res.json()) as { events: Array> } + const events: TimedEventDefinition[] = body.events.map((e) => ({ + id: String(e.id), + name: String(e.name), + description: (e.description as string | null) ?? null, + startsAt: new Date(String(e.startsAt)), + endsAt: new Date(String(e.endsAt)), + endingSoonMinutes: Number(e.endingSoonMinutes ?? 1440), + multiplier: Number(e.multiplier ?? 1), + enabled: Boolean(e.enabled), + })) + this.timedEventsCache.set(projectId, { value: events, expires: Date.now() + this.ttl }) + return events + } catch (err) { + if (cached) return cached.value + throw err + } + } + + async getAllTimedEvents(): Promise> { + const key = '*' + const cached = this.allTimedEventsCache.get(key) + if (cached && cached.expires > Date.now()) return cached.value + try { + const res = await this.fetchImpl(`${this.opts.baseUrl}/api/config-plane/timed-events/all`, { + headers: this.headers(), + }) + if (!res.ok) throw new Error(`config plane responded ${res.status}`) + const body = (await res.json()) as { events: Array> } + const events: Array = body.events.map((e) => ({ + id: String(e.id), + projectId: String(e.projectId), + name: String(e.name), + description: (e.description as string | null) ?? null, + startsAt: new Date(String(e.startsAt)), + endsAt: new Date(String(e.endsAt)), + endingSoonMinutes: Number(e.endingSoonMinutes ?? 1440), + multiplier: Number(e.multiplier ?? 1), + enabled: Boolean(e.enabled), + })) + this.allTimedEventsCache.set(key, { value: events, expires: Date.now() + this.ttl }) + return events + } catch (err) { + if (cached) return cached.value + throw err + } + } + + async getWebhookEndpoints(projectId: string): Promise { + const cached = this.webhookEndpointsCache.get(projectId) + if (cached && cached.expires > Date.now()) return cached.value + try { + const res = await this.fetchImpl( + `${this.opts.baseUrl}/api/config-plane/webhook-endpoints?projectId=${encodeURIComponent(projectId)}`, + { headers: this.headers() }, + ) + if (!res.ok) throw new Error(`config plane responded ${res.status}`) + const body = (await res.json()) as { endpoints: Array> } + const endpoints: WebhookEndpointDefinition[] = body.endpoints.map((e) => ({ + id: String(e.id), + url: String(e.url), + secret: String(e.secret), + enabled: Boolean(e.enabled), + })) + this.webhookEndpointsCache.set(projectId, { value: endpoints, expires: Date.now() + this.ttl }) + return endpoints + } catch (err) { + if (cached) return cached.value + throw err + } + } + async verifyKey(rawKey: string): Promise { const keyHash = createHash('sha256').update(rawKey).digest('hex') const cached = this.authCache.get(keyHash) diff --git a/packages/adapter-strapi/test/adapter.test.ts b/packages/adapter-strapi/test/adapter.test.ts index 6e7a8fb..cf44836 100644 --- a/packages/adapter-strapi/test/adapter.test.ts +++ b/packages/adapter-strapi/test/adapter.test.ts @@ -60,7 +60,7 @@ const offersBody = { offers: [{ id: 'o1', placementSlug: 'homepage-banner', headline: 'Welcome to Promocean', body: null, imageUrl: null, ctaText: 'Learn more', ctaUrl: 'https://example.com', - startsAt: '2026-07-01T00:00:00.000Z', endsAt: null, priority: 0, + startsAt: '2026-07-01T00:00:00.000Z', endsAt: null, priority: 0, timedEventId: null, }], } @@ -69,7 +69,7 @@ describe('StrapiConfigPlane.getOffers', () => { const fetchImpl = vi.fn().mockImplementation(() => ok(offersBody)) const offers = await makePlane(fetchImpl).getOffers('p1') expect(String(fetchImpl.mock.calls[0][0])).toBe('http://cms.test/api/config-plane/offers?projectId=p1') - expect(offers[0]).toMatchObject({ id: 'o1', placementSlug: 'homepage-banner', endsAt: null, audience: { kind: 'everyone' } }) + expect(offers[0]).toMatchObject({ id: 'o1', placementSlug: 'homepage-banner', endsAt: null, audience: { kind: 'everyone' }, timedEventId: null }) expect(offers[0].startsAt).toEqual(new Date('2026-07-01T00:00:00.000Z')) }) it('caches within TTL and serves stale on error', async () => { @@ -81,3 +81,64 @@ describe('StrapiConfigPlane.getOffers', () => { expect((await plane.getOffers('p1'))[0].id).toBe('o1') }) }) + +const timedEventsBody = { + 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, + }], +} + +describe('StrapiConfigPlane.getTimedEvents', () => { + it('fetches the correct URL and maps ISO strings to Date and enabled to boolean', async () => { + const fetchImpl = vi.fn().mockImplementation(() => ok(timedEventsBody)) + const events = await makePlane(fetchImpl).getTimedEvents('p1') + expect(String(fetchImpl.mock.calls[0][0])).toBe('http://cms.test/api/config-plane/timed-events?projectId=p1') + expect(events[0]).toMatchObject({ + id: '1', name: 'Summer Sale', description: null, + endingSoonMinutes: 60, multiplier: 2, enabled: true, + }) + 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')) + }) + it('serves stale cache when strapi errors after a successful fetch', async () => { + const fetchImpl = vi.fn() + .mockImplementationOnce(() => ok(timedEventsBody)) + .mockImplementation(() => Promise.reject(new Error('down'))) + const plane = makePlane(fetchImpl, 0) // TTL 0: always expired + await plane.getTimedEvents('p1') + const events = await plane.getTimedEvents('p1') + expect(events[0].id).toBe('1') + }) +}) + +const allTimedEventsBody = { + 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, + }], +} + +describe('StrapiConfigPlane.getAllTimedEvents', () => { + it('hits the /all endpoint and passes projectId through on each event', async () => { + 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 }) + }) +}) + +const webhookEndpointsBody = { + endpoints: [{ id: 5, url: 'https://hooks.example.com/x', secret: 'whsec_abc', enabled: true }], +} + +describe('StrapiConfigPlane.getWebhookEndpoints', () => { + it('fetches the correct URL and maps fields', async () => { + const fetchImpl = vi.fn().mockImplementation(() => ok(webhookEndpointsBody)) + const endpoints = await makePlane(fetchImpl).getWebhookEndpoints('p1') + expect(String(fetchImpl.mock.calls[0][0])).toBe('http://cms.test/api/config-plane/webhook-endpoints?projectId=p1') + expect(endpoints[0]).toEqual({ id: '5', url: 'https://hooks.example.com/x', secret: 'whsec_abc', enabled: true }) + }) +}) From f6df29bce8f321d72482237b019c3fc5f2f68a00 Mon Sep 17 00:00:00 2001 From: Steve Hynding Date: Mon, 6 Jul 2026 23:09:01 -0700 Subject: [PATCH 08/13] feat(api): timed-event multipliers, event-gated offers, and live events endpoint Wire activeMultiplier into POST /v1/events (fail-open to 1 on config-plane error), activeEventIds into GET /v1/placements/:slug/offer (fail-closed to an empty set), and add GET /v1/events/live mapping timedEventState to scheduled/live/ending_soon with countdowns. Also fixes a latent break in offers.test.ts's fixture (missing timedEventId) exposed once the offer route started passing a real active-events set. Co-Authored-By: Claude Fable 5 --- apps/api/src/app.ts | 2 + apps/api/src/routes/events.ts | 12 +++- apps/api/src/routes/live-events.ts | 30 ++++++++ apps/api/src/routes/placements.ts | 10 ++- apps/api/test/fakes.ts | 17 ++++- apps/api/test/offers.test.ts | 2 +- apps/api/test/timed-events.test.ts | 108 +++++++++++++++++++++++++++++ 7 files changed, 173 insertions(+), 8 deletions(-) create mode 100644 apps/api/src/routes/live-events.ts create mode 100644 apps/api/test/timed-events.test.ts diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 8993fc5..b938fdf 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -3,6 +3,7 @@ import { cors } from 'hono/cors' import type { ApiKeyStore, ConfigStore, EventStore, OfferMetricsStore, ProgressStore, UsageStore } from '@promocean/core' import { authMiddleware } from './auth.js' import { eventsRoute } from './routes/events.js' +import { liveEventsRoute } from './routes/live-events.js' import { offersRoute } from './routes/offers.js' import { placementsRoute } from './routes/placements.js' import { usersRoute } from './routes/users.js' @@ -22,6 +23,7 @@ export function createApp(deps: AppDeps) { app.use('/v1/*', cors()) app.use('/v1/*', authMiddleware(deps.apiKeyStore)) app.route('/v1/events', eventsRoute(deps)) + app.route('/v1/events', liveEventsRoute(deps)) app.route('/v1/users', usersRoute(deps)) app.route('/v1/placements', placementsRoute(deps)) app.route('/v1/offers', offersRoute(deps)) diff --git a/apps/api/src/routes/events.ts b/apps/api/src/routes/events.ts index c7fdc45..25f925c 100644 --- a/apps/api/src/routes/events.ts +++ b/apps/api/src/routes/events.ts @@ -1,6 +1,6 @@ import { Hono } from 'hono' import { trackEventRequestSchema, type TrackEventResponse } from '@promocean/contracts' -import { evaluateEvent, type Scope } from '@promocean/core' +import { activeMultiplier, evaluateEvent, type Scope } from '@promocean/core' import type { AppDeps } from '../app.js' export function eventsRoute(deps: AppDeps) { @@ -23,7 +23,15 @@ export function eventsRoute(deps: AppDeps) { const definitions = await deps.configStore.getAchievements(scope.projectId) const relevant = definitions.filter((d) => d.eventType === type) const counts = await deps.progressStore.getCounts(scope, userId, relevant.map((d) => d.id)) - const result = evaluateEvent({ userId, type, occurredAt }, definitions, counts) + + let multiplier = 1 + try { + multiplier = activeMultiplier(await deps.configStore.getTimedEvents(scope.projectId), occurredAt) + } catch (err) { + console.error('timed events fetch failed; defaulting multiplier to 1', err) + } + + const result = evaluateEvent({ userId, type, occurredAt }, definitions, counts, multiplier) const unlockedAt = new Date() const unlocks: TrackEventResponse['unlocks'] = [] diff --git a/apps/api/src/routes/live-events.ts b/apps/api/src/routes/live-events.ts new file mode 100644 index 0000000..1e605a5 --- /dev/null +++ b/apps/api/src/routes/live-events.ts @@ -0,0 +1,30 @@ +import { Hono } from 'hono' +import type { LiveEventsResponse } from '@promocean/contracts' +import { timedEventState, type Scope } from '@promocean/core' +import type { AppDeps } from '../app.js' + +export function liveEventsRoute(deps: AppDeps) { + const app = new Hono() + app.get('/live', async (c) => { + const auth = c.get('auth') + const scope: Scope = { projectId: auth.projectId, environment: auth.environment } + const defs = await deps.configStore.getTimedEvents(scope.projectId) + const now = new Date() + const events = defs + .map((e) => ({ e, state: timedEventState(e, now) })) + .filter(({ state }) => state === 'scheduled' || state === 'live' || state === 'ending_soon') + .map(({ e, state }) => ({ + eventId: e.id, + name: e.name, + description: e.description, + state: state as 'scheduled' | 'live' | 'ending_soon', + startsAt: e.startsAt.toISOString(), + endsAt: e.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), + })) + return c.json({ events } satisfies LiveEventsResponse) + }) + return app +} diff --git a/apps/api/src/routes/placements.ts b/apps/api/src/routes/placements.ts index 82a478f..a17660f 100644 --- a/apps/api/src/routes/placements.ts +++ b/apps/api/src/routes/placements.ts @@ -1,7 +1,7 @@ import { Hono } from 'hono' import type { PlacementOfferResponse } from '@promocean/contracts' import { PLACEMENT_SLUG_PATTERN } from '@promocean/contracts' -import { resolveOffer, type Scope } from '@promocean/core' +import { activeEventIds, resolveOffer, type Scope } from '@promocean/core' import type { AppDeps } from '../app.js' export function placementsRoute(deps: AppDeps) { @@ -19,7 +19,13 @@ export function placementsRoute(deps: AppDeps) { } const offers = await deps.configStore.getOffers(scope.projectId) const now = new Date() - const offer = resolveOffer(slug, offers, now) + let active: ReadonlySet = new Set() + try { + active = activeEventIds(await deps.configStore.getTimedEvents(scope.projectId), now) + } catch (err) { + console.error('timed events fetch failed; event-attached offers hidden', err) + } + const offer = resolveOffer(slug, offers, now, active) if (offer) { try { await deps.offerMetricsStore.recordImpression(scope, offer.id, userId, now) diff --git a/apps/api/test/fakes.ts b/apps/api/test/fakes.ts index 6055c7b..071065f 100644 --- a/apps/api/test/fakes.ts +++ b/apps/api/test/fakes.ts @@ -1,16 +1,27 @@ import type { AchievementDefinition, ApiKeyStore, AuthContext, ConfigStore, EventStore, OfferDefinition, OfferMetricsStore, - ProgressStore, Scope, UsageStore, + ProgressStore, Scope, TimedEventDefinition, UsageStore, } from '@promocean/core' const sk = (s: Scope, rest: string) => `${s.projectId}:${s.environment}:${rest}` -export function makeFakes(definitions: AchievementDefinition[], auth: AuthContext | null, offers: OfferDefinition[] = []) { +export function makeFakes( + definitions: AchievementDefinition[], + auth: AuthContext | null, + offers: OfferDefinition[] = [], + timedEvents: TimedEventDefinition[] = [], +) { const seenIdem = new Set() const progress = new Map() const unlockDates = new Map() const usage: string[] = [] - const configStore: ConfigStore = { getAchievements: async () => definitions, getOffers: async () => offers } + const configStore: ConfigStore = { + getAchievements: async () => definitions, + getOffers: async () => offers, + getTimedEvents: async () => timedEvents, + getAllTimedEvents: async () => [], + getWebhookEndpoints: async () => [], + } const apiKeyStore: ApiKeyStore = { verifyKey: async (raw) => (raw === 'pk_test_valid_key_1' ? auth : null) } const eventStore: EventStore = { insertEvent: async (s, e) => { diff --git a/apps/api/test/offers.test.ts b/apps/api/test/offers.test.ts index 7c638b5..94fe01f 100644 --- a/apps/api/test/offers.test.ts +++ b/apps/api/test/offers.test.ts @@ -5,7 +5,7 @@ import { makeFakes } from './fakes.js' const offer = { id: 'o1', placementSlug: 'homepage-banner', headline: 'Welcome to Promocean', body: null, imageUrl: null, ctaText: 'Learn more', ctaUrl: 'https://example.com', - startsAt: null, endsAt: null, priority: 0, audience: { kind: 'everyone' as const }, + startsAt: null, endsAt: null, priority: 0, audience: { kind: 'everyone' as const }, timedEventId: null, } const auth = { projectId: 'p1', environment: 'test' as const, keyType: 'publishable' as const } const headers = { authorization: 'Bearer pk_test_valid_key_1', 'content-type': 'application/json' } diff --git a/apps/api/test/timed-events.test.ts b/apps/api/test/timed-events.test.ts new file mode 100644 index 0000000..4be54d9 --- /dev/null +++ b/apps/api/test/timed-events.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from 'vitest' +import type { OfferDefinition, TimedEventDefinition } from '@promocean/core' +import { createApp } from '../src/app.js' +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, +}) + +const defs = [ + { id: 'a1', name: 'First Lesson', description: null, artworkUrl: null, eventType: 'lesson_completed', targetCount: 2 }, +] +const auth = { projectId: 'p1', environment: 'test' as const, keyType: 'publishable' as const } +const headers = { authorization: 'Bearer pk_test_valid_key_1', 'content-type': 'application/json' } +const body = (idem: string) => JSON.stringify({ userId: 'u1', type: 'lesson_completed', idempotencyKey: idem, occurredAt: '2026-07-15T00:00:00Z' }) + +describe('POST /v1/events — timed-event multiplier wiring', () => { + it('applies the active multiplier: one event yields progress current 2 and unlocks a target-2 achievement', async () => { + const fakes = makeFakes(defs, auth, [], [mk({ multiplier: 2 })]) + const app = createApp(fakes) + const res = await app.request('/v1/events', { method: 'POST', headers, body: body('key_0001') }) + expect(res.status).toBe(200) + const json = await res.json() + expect(json.progress).toContainEqual({ achievementId: 'a1', current: 2, target: 2 }) + expect(json.unlocks).toEqual([{ achievementId: 'a1', name: 'First Lesson', unlockedAt: expect.any(String) }]) + }) + + it('with no timed events, multiplier stays 1', async () => { + const fakes = makeFakes(defs, auth, [], []) + const app = createApp(fakes) + const res = await app.request('/v1/events', { method: 'POST', headers, body: body('key_0002') }) + expect(res.status).toBe(200) + const json = await res.json() + expect(json.progress).toContainEqual({ achievementId: 'a1', current: 1, target: 2 }) + expect(json.unlocks).toEqual([]) + }) + + it('ingests at multiplier 1 when getTimedEvents throws', async () => { + const fakes = makeFakes(defs, auth, [], []) + fakes.configStore.getTimedEvents = async () => { throw new Error('config plane down') } + const app = createApp(fakes) + const res = await app.request('/v1/events', { method: 'POST', headers, body: body('key_0003') }) + expect(res.status).toBe(200) + const json = await res.json() + expect(json.progress).toContainEqual({ achievementId: 'a1', current: 1, target: 2 }) + expect(json.unlocks).toEqual([]) + }) +}) + +describe('GET /v1/events/live', () => { + it('maps live and scheduled events, excluding disabled and ended ones', async () => { + const live = mk({ id: 'live1', name: 'Live Now', startsAt: new Date('2026-07-01T00:00:00Z'), endsAt: new Date('2026-07-20T00:00:00Z'), endingSoonMinutes: 60 }) + const scheduled = mk({ id: 'sched1', name: 'Coming Soon', startsAt: new Date('2026-08-01T00:00:00Z'), endsAt: new Date('2026-08-10T00:00:00Z') }) + const disabled = mk({ id: 'disabled1', enabled: false }) + const ended = mk({ id: 'ended1', startsAt: new Date('2026-01-01T00:00:00Z'), endsAt: new Date('2026-01-10T00:00:00Z') }) + const fakes = makeFakes([], auth, [], [live, scheduled, disabled, ended]) + const app = createApp(fakes) + const res = await app.request('/v1/events/live', { headers }) + expect(res.status).toBe(200) + const json = await res.json() + const ids = json.events.map((e: { eventId: string }) => e.eventId) + expect(ids).toEqual(expect.arrayContaining(['live1', 'sched1'])) + expect(ids).not.toContain('disabled1') + expect(ids).not.toContain('ended1') + + const liveEvent = json.events.find((e: { eventId: string }) => e.eventId === 'live1') + expect(liveEvent.state).toBe('live') + expect(liveEvent.secondsUntilStart).toBeNull() + expect(typeof liveEvent.secondsUntilEnd).toBe('number') + expect(liveEvent.secondsUntilEnd).toBeGreaterThan(0) + + const scheduledEvent = json.events.find((e: { eventId: string }) => e.eventId === 'sched1') + expect(scheduledEvent.state).toBe('scheduled') + expect(typeof scheduledEvent.secondsUntilStart).toBe('number') + expect(scheduledEvent.secondsUntilStart).toBeGreaterThan(0) + expect(typeof scheduledEvent.secondsUntilEnd).toBe('number') + expect(scheduledEvent.secondsUntilEnd).toBeGreaterThan(0) + }) +}) + +describe('GET /v1/placements/:slug/offer — event-gated offers', () => { + const baseOffer: OfferDefinition = { + id: 'o1', placementSlug: 'homepage-banner', headline: 'Sale!', body: null, imageUrl: null, + ctaText: null, ctaUrl: null, startsAt: null, endsAt: null, priority: 0, + audience: { kind: 'everyone' }, timedEventId: 'e1', + } + + it('resolves an offer attached to a currently-live event', async () => { + const fakes = makeFakes([], auth, [baseOffer], [mk({ id: 'e1' })]) + const app = createApp(fakes) + const res = await app.request('/v1/placements/homepage-banner/offer', { headers }) + expect(res.status).toBe(200) + const json = await res.json() + expect(json.offer).toMatchObject({ offerId: 'o1', headline: 'Sale!' }) + }) + + it('does not resolve an offer attached to an event that is no longer active', async () => { + const inactive = mk({ id: 'e1', startsAt: new Date('2020-01-01T00:00:00Z'), endsAt: new Date('2020-01-10T00:00:00Z') }) + const fakes = makeFakes([], auth, [baseOffer], [inactive]) + const app = createApp(fakes) + const res = await app.request('/v1/placements/homepage-banner/offer', { headers }) + expect(res.status).toBe(200) + const json = await res.json() + expect(json.offer).toBeNull() + }) +}) From efd8e982388f695d0a83e3806c5227ecfc6750cd Mon Sep 17 00:00:00 2001 From: Steve Hynding Date: Mon, 6 Jul 2026 23:18:18 -0700 Subject: [PATCH 09/13] feat(api): signed webhook dispatcher, lifecycle scheduler, and unlock webhooks Co-Authored-By: Claude Fable 5 --- apps/api/src/app.ts | 2 + apps/api/src/index.ts | 7 +- apps/api/src/routes/events.ts | 10 ++ apps/api/src/webhooks.ts | 130 +++++++++++++++ apps/api/test/webhooks.test.ts | 288 +++++++++++++++++++++++++++++++++ 5 files changed, 436 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/webhooks.ts create mode 100644 apps/api/test/webhooks.test.ts diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index b938fdf..d888a94 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -7,6 +7,7 @@ import { liveEventsRoute } from './routes/live-events.js' import { offersRoute } from './routes/offers.js' import { placementsRoute } from './routes/placements.js' import { usersRoute } from './routes/users.js' +import type { WebhookDispatcher } from './webhooks.js' export interface AppDeps { configStore: ConfigStore @@ -15,6 +16,7 @@ export interface AppDeps { progressStore: ProgressStore usageStore: UsageStore offerMetricsStore: OfferMetricsStore + webhooks?: WebhookDispatcher } export function createApp(deps: AppDeps) { diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index c0111b7..8f55933 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -1,7 +1,8 @@ import { serve } from '@hono/node-server' -import { createDb, runMigrations, PgEventStore, PgOfferMetricsStore, PgProgressStore, PgUsageStore } from '@promocean/adapter-db' +import { createDb, runMigrations, PgEventStore, PgOfferMetricsStore, PgProgressStore, PgUsageStore, PgWebhookDeliveryStore } from '@promocean/adapter-db' import { StrapiConfigPlane } from '@promocean/adapter-strapi' import { createApp } from './app.js' +import { WebhookDispatcher, startLifecycleScheduler } from './webhooks.js' const db = createDb(process.env.DATABASE_URL!) await runMigrations(db) @@ -9,6 +10,9 @@ const plane = new StrapiConfigPlane({ baseUrl: process.env.STRAPI_URL ?? 'http://localhost:1337', configSecret: process.env.CONFIG_PLANE_SECRET!, }) +const webhookDeliveryStore = new PgWebhookDeliveryStore(db) +const webhooks = new WebhookDispatcher({ configStore: plane, deliveryStore: webhookDeliveryStore }) +startLifecycleScheduler({ configStore: plane, deliveryStore: webhookDeliveryStore, dispatcher: webhooks }) const app = createApp({ configStore: plane, apiKeyStore: plane, @@ -16,6 +20,7 @@ const app = createApp({ progressStore: new PgProgressStore(db), usageStore: new PgUsageStore(db), offerMetricsStore: new PgOfferMetricsStore(db), + webhooks, }) const port = Number(process.env.API_PORT ?? 3001) serve({ fetch: app.fetch, port }) diff --git a/apps/api/src/routes/events.ts b/apps/api/src/routes/events.ts index 25f925c..11c0bb8 100644 --- a/apps/api/src/routes/events.ts +++ b/apps/api/src/routes/events.ts @@ -44,6 +44,16 @@ export function eventsRoute(deps: AppDeps) { } await deps.usageStore.recordUsage(scope, userId, new Date().toISOString().slice(0, 7)) + if (unlocks.length > 0 && deps.webhooks) { + void deps.webhooks + .deliver(scope.projectId, { + type: 'achievement.unlocked', + data: { userId, environment: scope.environment, unlocks }, + createdAt: unlockedAt.toISOString(), + }) + .catch(() => {}) + } + return c.json({ deduped: false, unlocks, progress: result.progressUpdates } satisfies TrackEventResponse) }) return app diff --git a/apps/api/src/webhooks.ts b/apps/api/src/webhooks.ts new file mode 100644 index 0000000..f1c2d4b --- /dev/null +++ b/apps/api/src/webhooks.ts @@ -0,0 +1,130 @@ +import { createHmac } from 'node:crypto' +import { WEBHOOK_SIGNATURE_HEADER, type WebhookMessage } from '@promocean/contracts' +import { timedEventState, type ConfigStore, type TimedEventTransition, type WebhookDeliveryStore, type WebhookEndpointDefinition } from '@promocean/core' + +const BASE_BACKOFF_MS = 250 + +export class WebhookDispatcher { + private configStore: ConfigStore + private deliveryStore: WebhookDeliveryStore + private fetchImpl: typeof fetch + private maxRetries: number + + constructor(opts: { + configStore: ConfigStore + deliveryStore: WebhookDeliveryStore + fetchImpl?: typeof fetch + maxRetries?: number + }) { + this.configStore = opts.configStore + this.deliveryStore = opts.deliveryStore + this.fetchImpl = opts.fetchImpl ?? ((...a) => globalThis.fetch(...a)) + this.maxRetries = opts.maxRetries ?? 3 + } + + /** Delivers a signed webhook message to every enabled endpoint for the project. Never throws. */ + async deliver(projectId: string, message: WebhookMessage): Promise { + let endpoints: WebhookEndpointDefinition[] + try { + endpoints = await this.configStore.getWebhookEndpoints(projectId) + } catch (err) { + console.error('webhook: failed to load endpoints', err) + return + } + const rawBody = JSON.stringify(message) + await Promise.allSettled( + endpoints.filter((e) => e.enabled).map((endpoint) => this.deliverToEndpoint(projectId, endpoint, rawBody)), + ) + } + + private async deliverToEndpoint(projectId: string, endpoint: WebhookEndpointDefinition, rawBody: string): Promise { + const signature = createHmac('sha256', endpoint.secret).update(rawBody).digest('hex') + let lastError: unknown + + for (let attempt = 0; attempt <= this.maxRetries; attempt++) { + if (attempt > 0) await new Promise((resolve) => setTimeout(resolve, BASE_BACKOFF_MS * 2 ** (attempt - 1))) + try { + const res = await this.fetchImpl(endpoint.url, { + method: 'POST', + headers: { 'content-type': 'application/json', [WEBHOOK_SIGNATURE_HEADER]: signature }, + body: rawBody, + }) + if (res.status >= 500) { + lastError = new Error(`webhook endpoint responded ${res.status}`) + continue + } + if (!res.ok) { + // 4xx: permanent client-side failure, do not retry. + await this.deadLetter(projectId, endpoint.url, rawBody, `webhook endpoint responded ${res.status}`) + return + } + return + } catch (err) { + lastError = err + } + } + const errorMessage = lastError instanceof Error ? lastError.message : String(lastError) + await this.deadLetter(projectId, endpoint.url, rawBody, errorMessage) + } + + private async deadLetter(projectId: string, url: string, payload: string, error: string): Promise { + try { + await this.deliveryStore.recordDeadLetter(projectId, url, payload, error, new Date()) + } catch (err) { + console.error('webhook: failed to record dead letter', err) + } + } +} + +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 [] + } +} + +export function startLifecycleScheduler(opts: { + configStore: ConfigStore + deliveryStore: WebhookDeliveryStore + dispatcher: WebhookDispatcher + intervalMs?: number +}): () => void { + const { configStore, deliveryStore, dispatcher, intervalMs = 30_000 } = opts + + const tick = async () => { + try { + const events = await configStore.getAllTimedEvents() + const now = new Date() + for (const event of events) { + const state = timedEventState(event, now) + const transitions = reachedTransitions(state) + for (const transition of transitions) { + const claimed = await deliveryStore.claimTransition(event.projectId, event.id, transition) + if (!claimed) continue + await dispatcher.deliver(event.projectId, { + type: `timed_event.${transition}`, + data: { + eventId: event.id, + name: event.name, + startsAt: event.startsAt.toISOString(), + endsAt: event.endsAt.toISOString(), + multiplier: event.multiplier, + }, + createdAt: now.toISOString(), + }) + } + } + } catch (err) { + console.error('lifecycle scheduler: tick failed', err) + } + } + + const timer = setInterval(() => { void tick() }, intervalMs) + return () => clearInterval(timer) +} diff --git a/apps/api/test/webhooks.test.ts b/apps/api/test/webhooks.test.ts new file mode 100644 index 0000000..f0c7792 --- /dev/null +++ b/apps/api/test/webhooks.test.ts @@ -0,0 +1,288 @@ +import { createHmac } from 'node:crypto' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { WEBHOOK_SIGNATURE_HEADER, type WebhookMessage } from '@promocean/contracts' +import type { ConfigStore, TimedEventDefinition, WebhookDeliveryStore, WebhookEndpointDefinition } from '@promocean/core' +import { WebhookDispatcher, startLifecycleScheduler } from '../src/webhooks.js' +import { createApp } from '../src/app.js' +import { makeFakes } from './fakes.js' + +function makeDeliveryStore() { + const claimed = new Set() + 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}` + if (claimed.has(key)) return false + claimed.add(key) + return true + }, + recordDeadLetter: async (projectId, url, payload, error, at) => { + deadLetters.push({ projectId, url, payload, error, at }) + }, + } + return { deliveryStore, deadLetters } +} + +function makeConfigStore(opts: { + endpoints?: WebhookEndpointDefinition[] + allTimedEvents?: Array +} = {}): ConfigStore { + return { + getAchievements: async () => [], + getOffers: async () => [], + getTimedEvents: async () => [], + getAllTimedEvents: async () => opts.allTimedEvents ?? [], + getWebhookEndpoints: async () => opts.endpoints ?? [], + } +} + +const message: WebhookMessage = { + type: 'achievement.unlocked', + data: { userId: 'u1', environment: 'test', unlocks: [] }, + createdAt: '2026-07-06T00:00:00.000Z', +} + +const endpointA: WebhookEndpointDefinition = { id: 'ep1', url: 'https://hooks.test/a', secret: 'secret-a', enabled: true } +const endpointB: WebhookEndpointDefinition = { id: 'ep2', url: 'https://hooks.test/b', secret: 'secret-b', enabled: true } +const disabledEndpoint: WebhookEndpointDefinition = { id: 'ep3', url: 'https://hooks.test/c', secret: 'secret-c', enabled: false } + +describe('WebhookDispatcher.deliver — group A (happy path + signing)', () => { + it('posts to both enabled endpoints with a correct per-secret HMAC signature, skipping disabled endpoints', async () => { + const { deliveryStore } = makeDeliveryStore() + const configStore = makeConfigStore({ endpoints: [endpointA, endpointB, disabledEndpoint] }) + const fetchImpl = vi.fn().mockImplementation(() => Promise.resolve(new Response('', { status: 200 }))) + const dispatcher = new WebhookDispatcher({ configStore, deliveryStore, fetchImpl }) + + await dispatcher.deliver('p1', message) + + expect(fetchImpl).toHaveBeenCalledTimes(2) + const calls = fetchImpl.mock.calls as unknown as Array<[string, RequestInit]> + const byUrl = new Map(calls.map(([url, init]) => [url, init])) + expect([...byUrl.keys()].sort()).toEqual([endpointA.url, endpointB.url].sort()) + + for (const [endpoint, url] of [[endpointA, endpointA.url], [endpointB, endpointB.url]] as const) { + const init = byUrl.get(url)! + expect(init.method).toBe('POST') + const rawBody = init.body as string + const expectedSig = createHmac('sha256', endpoint.secret).update(rawBody).digest('hex') + const headers = init.headers as Record + expect(headers[WEBHOOK_SIGNATURE_HEADER]).toBe(expectedSig) + expect(JSON.parse(rawBody)).toEqual(message) + } + }) + + it('never throws even when getWebhookEndpoints rejects', async () => { + const { deliveryStore } = makeDeliveryStore() + const configStore = makeConfigStore() + configStore.getWebhookEndpoints = async () => { throw new Error('config plane down') } + const fetchImpl = vi.fn() + const dispatcher = new WebhookDispatcher({ configStore, deliveryStore, fetchImpl }) + await expect(dispatcher.deliver('p1', message)).resolves.toBeUndefined() + expect(fetchImpl).not.toHaveBeenCalled() + }) +}) + +describe('WebhookDispatcher.deliver — group B (failure handling)', () => { + it('retries a 5xx then succeeds, without recording a dead letter', async () => { + const { deliveryStore, deadLetters } = makeDeliveryStore() + const configStore = makeConfigStore({ endpoints: [endpointA] }) + const fetchImpl = vi.fn() + .mockImplementationOnce(() => Promise.resolve(new Response('', { status: 503 }))) + .mockImplementation(() => Promise.resolve(new Response('', { status: 200 }))) + const dispatcher = new WebhookDispatcher({ configStore, deliveryStore, fetchImpl, maxRetries: 1 }) + + await dispatcher.deliver('p1', message) + + expect(fetchImpl).toHaveBeenCalledTimes(2) + expect(deadLetters).toEqual([]) + }) + + it('persistent 5xx dead-letters that endpoint while the other endpoint still delivers', async () => { + const { deliveryStore, deadLetters } = makeDeliveryStore() + const configStore = makeConfigStore({ endpoints: [endpointA, endpointB] }) + const fetchImpl = vi.fn().mockImplementation((url: string) => { + if (url === endpointA.url) return Promise.resolve(new Response('', { status: 500 })) + return Promise.resolve(new Response('', { status: 200 })) + }) + const dispatcher = new WebhookDispatcher({ configStore, deliveryStore, fetchImpl, maxRetries: 1 }) + + await dispatcher.deliver('p1', message) + + expect(deadLetters).toHaveLength(1) + expect(deadLetters[0].url).toBe(endpointA.url) + expect(JSON.parse(deadLetters[0].payload)).toEqual(message) + expect(typeof deadLetters[0].error).toBe('string') + expect(deadLetters[0].at).toBeInstanceOf(Date) + + const bCalls = fetchImpl.mock.calls.filter(([url]) => url === endpointB.url) + expect(bCalls).toHaveLength(1) + const aCalls = fetchImpl.mock.calls.filter(([url]) => url === endpointA.url) + expect(aCalls).toHaveLength(2) // initial attempt + 1 retry + }) + + it('a 4xx response dead-letters immediately with exactly one fetch call, no retry', async () => { + const { deliveryStore, deadLetters } = makeDeliveryStore() + const configStore = makeConfigStore({ endpoints: [endpointA] }) + const fetchImpl = vi.fn().mockImplementation(() => Promise.resolve(new Response('', { status: 400 }))) + const dispatcher = new WebhookDispatcher({ configStore, deliveryStore, fetchImpl, maxRetries: 3 }) + + await dispatcher.deliver('p1', message) + + expect(fetchImpl).toHaveBeenCalledTimes(1) + expect(deadLetters).toHaveLength(1) + expect(deadLetters[0].url).toBe(endpointA.url) + }) +}) + +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, +}) + +describe('startLifecycleScheduler — group C', () => { + beforeEach(() => { vi.useFakeTimers() }) + afterEach(() => { vi.useRealTimers() }) + + function fakeDispatcher() { + return { deliver: vi.fn(async () => {}) } as unknown as { deliver: ReturnType } & WebhookDispatcher + } + + it('claims and fires the live transition exactly once across two ticks', async () => { + vi.setSystemTime(new Date('2026-07-15T00:00:00Z')) // well inside live window, not ending soon + const event = mkEvent() + const configStore = makeConfigStore({ allTimedEvents: [event] }) + const { deliveryStore } = makeDeliveryStore() + const dispatcher = fakeDispatcher() + + const stop = startLifecycleScheduler({ configStore, deliveryStore, dispatcher, intervalMs: 1000 }) + await vi.advanceTimersByTimeAsync(1000) + expect(dispatcher.deliver).toHaveBeenCalledTimes(1) + expect(dispatcher.deliver.mock.calls[0][0]).toBe('p1') + expect(dispatcher.deliver.mock.calls[0][1]).toMatchObject({ type: 'timed_event.live' }) + + await vi.advanceTimersByTimeAsync(1000) + expect(dispatcher.deliver).toHaveBeenCalledTimes(1) // already claimed, no re-fire + + stop() + }) + + it('fires live then ending_soon in order on one tick for an event first observed ending_soon', async () => { + // endsAt is 30 minutes away, endingSoonMinutes is 60 -> ending_soon on first observation + vi.setSystemTime(new Date('2026-07-30T23:30:00Z')) + const event = mkEvent({ endingSoonMinutes: 60 }) + const configStore = makeConfigStore({ allTimedEvents: [event] }) + const { deliveryStore } = makeDeliveryStore() + const dispatcher = fakeDispatcher() + + const stop = startLifecycleScheduler({ configStore, deliveryStore, dispatcher, intervalMs: 1000 }) + await vi.advanceTimersByTimeAsync(1000) + + expect(dispatcher.deliver).toHaveBeenCalledTimes(2) + expect(dispatcher.deliver.mock.calls[0][1]).toMatchObject({ type: 'timed_event.live' }) + expect(dispatcher.deliver.mock.calls[1][1]).toMatchObject({ type: 'timed_event.ending_soon' }) + + stop() + }) + + it('fires nothing for a disabled (draft) event', async () => { + vi.setSystemTime(new Date('2026-07-15T00:00:00Z')) + const event = mkEvent({ enabled: false }) + const configStore = makeConfigStore({ allTimedEvents: [event] }) + const { deliveryStore } = makeDeliveryStore() + const dispatcher = fakeDispatcher() + + const stop = startLifecycleScheduler({ configStore, deliveryStore, dispatcher, intervalMs: 1000 }) + await vi.advanceTimersByTimeAsync(1000) + + expect(dispatcher.deliver).not.toHaveBeenCalled() + stop() + }) + + it('stop() halts ticking', async () => { + vi.setSystemTime(new Date('2026-07-15T00:00:00Z')) + const event = mkEvent() + const configStore = makeConfigStore({ allTimedEvents: [event] }) + const { deliveryStore } = makeDeliveryStore() + const dispatcher = fakeDispatcher() + + const stop = startLifecycleScheduler({ configStore, deliveryStore, dispatcher, intervalMs: 1000 }) + await vi.advanceTimersByTimeAsync(1000) + expect(dispatcher.deliver).toHaveBeenCalledTimes(1) + + stop() + await vi.advanceTimersByTimeAsync(10_000) + expect(dispatcher.deliver).toHaveBeenCalledTimes(1) // no further ticks after stop + }) + + it('tick failures never throw out of the interval (catch-all)', async () => { + vi.setSystemTime(new Date('2026-07-15T00:00:00Z')) + const configStore = makeConfigStore() + configStore.getAllTimedEvents = async () => { throw new Error('config plane down') } + const { deliveryStore } = makeDeliveryStore() + const dispatcher = fakeDispatcher() + + const stop = startLifecycleScheduler({ configStore, deliveryStore, dispatcher, intervalMs: 1000 }) + await vi.advanceTimersByTimeAsync(1000) // must not throw / reject + expect(dispatcher.deliver).not.toHaveBeenCalled() + stop() + }) +}) + +describe('POST /v1/events — group D (unlock webhook wiring)', () => { + const defs = [ + { id: 'a1', name: 'First Lesson', description: null, artworkUrl: null, eventType: 'lesson_completed', targetCount: 1 }, + ] + const auth = { projectId: 'p1', environment: 'test' as const, keyType: 'publishable' as const } + const headers = { authorization: 'Bearer pk_test_valid_key_1', 'content-type': 'application/json' } + const body = (idem: string) => JSON.stringify({ userId: 'u1', type: 'lesson_completed', idempotencyKey: idem }) + + function fakeDispatcher(deliverImpl?: () => Promise) { + return { deliver: vi.fn(deliverImpl ?? (async () => {})) } as unknown as { deliver: ReturnType } & WebhookDispatcher + } + + it('fires an achievement.unlocked webhook after an unlocking track', async () => { + const fakes = makeFakes(defs, auth) + const webhooks = fakeDispatcher() + const app = createApp({ ...fakes, webhooks }) + const res = await app.request('/v1/events', { method: 'POST', headers, body: body('key_aaaaa') }) + expect(res.status).toBe(200) + const json = await res.json() + expect(json.unlocks).toEqual([{ achievementId: 'a1', name: 'First Lesson', unlockedAt: expect.any(String) }]) + + // allow the fire-and-forget microtask to run + await Promise.resolve() + await Promise.resolve() + expect(webhooks.deliver).toHaveBeenCalledTimes(1) + expect(webhooks.deliver.mock.calls[0][0]).toBe('p1') + expect(webhooks.deliver.mock.calls[0][1]).toMatchObject({ + type: 'achievement.unlocked', + data: { userId: 'u1', environment: 'test', unlocks: json.unlocks }, + }) + }) + + it('response is unaffected when the webhook dispatch rejects', async () => { + const fakes = makeFakes(defs, auth) + const webhooks = fakeDispatcher(async () => { throw new Error('dispatcher exploded') }) + const app = createApp({ ...fakes, webhooks }) + const res = await app.request('/v1/events', { method: 'POST', headers, body: body('key_bbbbb') }) + expect(res.status).toBe(200) + const json = await res.json() + expect(json.unlocks).toEqual([{ achievementId: 'a1', name: 'First Lesson', unlockedAt: expect.any(String) }]) + await Promise.resolve() + await Promise.resolve() + }) + + it('does not fire a webhook when there are no unlocks', async () => { + const fakes = makeFakes(defs, auth) + const webhooks = fakeDispatcher() + const app = createApp({ ...fakes, webhooks }) + // second identical event: nothing new unlocked since target is 1 and already unlocked would need a prior track; + // instead use an achievement that isn't reached (unrelated event type) so unlocks stays empty + const res = await app.request('/v1/events', { method: 'POST', headers, body: JSON.stringify({ userId: 'u1', type: 'other_event', idempotencyKey: 'key_ccccc' }) }) + expect(res.status).toBe(200) + await Promise.resolve() + await Promise.resolve() + expect(webhooks.deliver).not.toHaveBeenCalled() + }) +}) From a9ec187c3ef2761db8c6c4c7548ba0ede0437525 Mon Sep 17 00:00:00 2001 From: Steve Hynding Date: Mon, 6 Jul 2026 23:22:13 -0700 Subject: [PATCH 10/13] feat(sdk): live timed events query --- packages/sdk/src/index.ts | 8 +++++++- packages/sdk/test/sdk.test.ts | 23 +++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 96b16e9..66ae1f3 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -1,6 +1,7 @@ import { trackEventResponseSchema, userAchievementsResponseSchema, placementOfferResponseSchema, - type AchievementStatus, type TrackEventResponse, type UnlockPayload, type OfferCreative, + liveEventsResponseSchema, + type AchievementStatus, type TrackEventResponse, type UnlockPayload, type OfferCreative, type LiveTimedEvent, } from '@promocean/contracts' export interface PromoceanOptions { @@ -93,6 +94,11 @@ export class Promocean { return placementOfferResponseSchema.parse(await res.json()).offer } + async getLiveEvents(): Promise { + const res = await this.request('/v1/events/live') + return liveEventsResponseSchema.parse(await res.json()).events + } + async clickOffer(offerId: string): Promise { try { await this.request(`/v1/offers/${encodeURIComponent(offerId)}/click`, { diff --git a/packages/sdk/test/sdk.test.ts b/packages/sdk/test/sdk.test.ts index c713940..6fe8ca6 100644 --- a/packages/sdk/test/sdk.test.ts +++ b/packages/sdk/test/sdk.test.ts @@ -86,3 +86,26 @@ describe('offers', () => { expect(c.isOfferDismissed('o1')).toBe(true) }) }) + +describe('getLiveEvents', () => { + it('fetches and returns the live events array', async () => { + const liveEventBody = { + events: [{ + eventId: 'evt_live_1', + name: 'Flash 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(String(fetchImpl.mock.calls[0][0])).toBe('http://api.test/v1/events/live') + expect(events).toEqual(liveEventBody.events) + }) +}) From 1f6ca498bc0b0d899abcc585cf66e07474c6d480 Mon Sep 17 00:00:00 2001 From: Steve Hynding Date: Tue, 7 Jul 2026 08:15:41 -0700 Subject: [PATCH 11/13] feat(widgets): live event countdown component --- packages/widgets/src/event-countdown.tsx | 52 ++++++++++++++++ packages/widgets/src/index.ts | 1 + packages/widgets/test/widgets.test.tsx | 76 +++++++++++++++++++++++- 3 files changed, 128 insertions(+), 1 deletion(-) create mode 100644 packages/widgets/src/event-countdown.tsx diff --git a/packages/widgets/src/event-countdown.tsx b/packages/widgets/src/event-countdown.tsx new file mode 100644 index 0000000..b9681f3 --- /dev/null +++ b/packages/widgets/src/event-countdown.tsx @@ -0,0 +1,52 @@ +import { useEffect, useState } from 'react' +import type { LiveTimedEvent } from '@promocean/contracts' +import { usePromocean } from './provider.js' + +function formatDuration(ms: number): string { + const totalSeconds = Math.max(0, Math.floor(ms / 1000)) + const h = Math.floor(totalSeconds / 3600) + const m = Math.floor((totalSeconds % 3600) / 60) + const s = totalSeconds % 60 + return `${h}h ${m}m ${s}s` +} + +export function EventCountdown() { + const client = usePromocean() + const [events, setEvents] = useState([]) + const [, setTick] = useState(0) + + useEffect(() => { + let cancelled = false + client.getLiveEvents() + .then((es) => { if (!cancelled) setEvents(es) }) + .catch(() => {}) // fail silent-to-empty + return () => { cancelled = true } + }, [client]) + + useEffect(() => { + const id = setInterval(() => { setTick((t) => t + 1) }, 1000) + return () => { clearInterval(id) } + }, []) + + if (events.length === 0) return null + + return ( +
+ {events.map((event) => { + const isScheduled = event.state === 'scheduled' + const targetDate = isScheduled ? new Date(event.startsAt) : new Date(event.endsAt) + const remainingMs = targetDate.getTime() - Date.now() + const label = isScheduled ? 'Starts in' : 'Ends in' + return ( +
+
{event.name}
+
+ {label} {formatDuration(remainingMs)} +
+
+ ) + })} +
+ ) +} diff --git a/packages/widgets/src/index.ts b/packages/widgets/src/index.ts index 49bf3de..1bb65f8 100644 --- a/packages/widgets/src/index.ts +++ b/packages/widgets/src/index.ts @@ -2,3 +2,4 @@ export { PromoceanProvider, usePromocean } from './provider.js' export { UnlockToast } from './unlock-toast.js' export { BadgeCabinet } from './badge-cabinet.js' export { Placement } from './placement.js' +export { EventCountdown } from './event-countdown.js' diff --git a/packages/widgets/test/widgets.test.tsx b/packages/widgets/test/widgets.test.tsx index 99c329a..446ee8c 100644 --- a/packages/widgets/test/widgets.test.tsx +++ b/packages/widgets/test/widgets.test.tsx @@ -1,7 +1,7 @@ import { act, cleanup, render, screen, waitFor } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import type { UnlockPayload } from '@promocean/contracts' -import { BadgeCabinet, Placement, PromoceanProvider, UnlockToast } from '../src/index.js' +import { BadgeCabinet, EventCountdown, Placement, PromoceanProvider, UnlockToast } from '../src/index.js' // RTL's automatic afterEach cleanup only registers when `afterEach` exists as a // global; this project's vitest config doesn't set `test.globals: true`, so @@ -16,6 +16,7 @@ function fakeClient(achievements: unknown[] = [], offer: unknown = null) { onUnlock: (cb: (u: UnlockPayload) => void) => { listeners.add(cb); return () => listeners.delete(cb) }, getAchievements: vi.fn().mockResolvedValue(achievements), getPlacementOffer: vi.fn().mockResolvedValue(offer), + getLiveEvents: vi.fn().mockResolvedValue([]), clickOffer: vi.fn().mockResolvedValue(undefined), dismissOffer: vi.fn(), isOfferDismissed: vi.fn().mockReturnValue(false), @@ -99,3 +100,76 @@ describe('Placement', () => { expect(document.querySelector('img')).toBeNull() }) }) + +describe('EventCountdown', () => { + it('renders event name and a countdown that changes after a second passes', async () => { + vi.useFakeTimers() + try { + vi.setSystemTime(new Date('2026-07-06T00:00:00.000Z')) + const { client } = fakeClient() + client.getLiveEvents = vi.fn().mockResolvedValue([ + { + eventId: 'e1', + name: 'Summer Sale', + description: null, + state: 'live', + startsAt: '2026-07-05T00:00:00.000Z', + endsAt: '2026-07-06T02:00:00.000Z', + multiplier: 2, + secondsUntilStart: null, + secondsUntilEnd: 7200, + }, + ]) + const { container } = render() + await act(async () => { await Promise.resolve() }) + const row = container.querySelector('[data-promocean-event="e1"]') + expect(row).not.toBeNull() + expect(row?.textContent).toContain('Summer Sale') + expect(row?.textContent).toContain('Ends in') + const before = row?.textContent + expect(before).toContain('2h 0m 0s') + act(() => { vi.advanceTimersByTime(1000) }) + const after = row?.textContent + expect(after).not.toBe(before) + expect(after).toContain('1h 59m 59s') + } finally { + vi.useRealTimers() + } + }) + + it('renders nothing when getLiveEvents rejects', async () => { + const { client } = fakeClient() + client.getLiveEvents = vi.fn().mockRejectedValue(new Error('down')) + const { container } = render() + await waitFor(() => expect(client.getLiveEvents).toHaveBeenCalled()) + expect(container.querySelector('[data-promocean-event]')).toBeNull() + }) + + it('clears the interval on unmount', async () => { + vi.useFakeTimers() + try { + vi.setSystemTime(new Date('2026-07-06T00:00:00.000Z')) + const { client } = fakeClient() + client.getLiveEvents = vi.fn().mockResolvedValue([ + { + eventId: 'e1', + name: 'Summer Sale', + description: null, + state: 'scheduled', + startsAt: '2026-07-06T01:00:00.000Z', + endsAt: '2026-07-06T02:00:00.000Z', + multiplier: 2, + secondsUntilStart: 3600, + secondsUntilEnd: 7200, + }, + ]) + const { unmount } = render() + await act(async () => { await Promise.resolve() }) + unmount() + expect(() => { act(() => { vi.advanceTimersByTime(2000) }) }).not.toThrow() + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + }) +}) From 25e2d7dac7534c498730f196f85686b0c45f608d Mon Sep 17 00:00:00 2001 From: Steve Hynding Date: Tue, 7 Jul 2026 08:21:18 -0700 Subject: [PATCH 12/13] feat(demo): live event countdown with multiplier e2e Co-Authored-By: Claude Fable 5 --- apps/demo/app/promocean.tsx | 3 ++- apps/demo/e2e/achievement-loop.spec.ts | 3 ++- apps/demo/e2e/timed-event-loop.spec.ts | 12 ++++++++++++ 3 files changed, 16 insertions(+), 2 deletions(-) create mode 100644 apps/demo/e2e/timed-event-loop.spec.ts diff --git a/apps/demo/app/promocean.tsx b/apps/demo/app/promocean.tsx index 4c0747e..7d0d8ed 100644 --- a/apps/demo/app/promocean.tsx +++ b/apps/demo/app/promocean.tsx @@ -1,7 +1,7 @@ 'use client' import { useMemo, useState } from 'react' import { Promocean } from '@promocean/sdk' -import { BadgeCabinet, Placement, PromoceanProvider, UnlockToast } from '@promocean/widgets' +import { BadgeCabinet, EventCountdown, Placement, PromoceanProvider, UnlockToast } from '@promocean/widgets' export function Demo({ userId }: { userId: string }) { const client = useMemo(() => new Promocean({ @@ -22,6 +22,7 @@ export function Demo({ userId }: { userId: string }) {

Promocean Demo

User: {userId}

+
diff --git a/apps/demo/e2e/achievement-loop.spec.ts b/apps/demo/e2e/achievement-loop.spec.ts index 32869f3..617dc8f 100644 --- a/apps/demo/e2e/achievement-loop.spec.ts +++ b/apps/demo/e2e/achievement-loop.spec.ts @@ -7,6 +7,7 @@ test('track → unlock toast → badge cabinet', async ({ page }) => { await expect(page.getByRole('status')).toContainText('First Lesson') const cabinet = page.getByRole('list') await expect(cabinet.getByText('First Lesson', { exact: true })).toBeVisible() - await expect(cabinet.getByText('1/10')).toBeVisible() + // seeded "Double Progress Weekend" (multiplier 2) is live — one lesson counts double + await expect(cabinet.getByText('2/10')).toBeVisible() await expect(cabinet.locator('[data-locked="false"]').getByText('First Lesson', { exact: true })).toBeVisible() }) diff --git a/apps/demo/e2e/timed-event-loop.spec.ts b/apps/demo/e2e/timed-event-loop.spec.ts new file mode 100644 index 0000000..cfdbd52 --- /dev/null +++ b/apps/demo/e2e/timed-event-loop.spec.ts @@ -0,0 +1,12 @@ +import { expect, test } from '@playwright/test' + +test('live event shows countdown and doubles progress', async ({ page }) => { + const user = `e2e-event-${Date.now()}` + await page.goto(`/?user=${user}`) + const event = page.locator('[data-promocean-event]') + await expect(event.getByText('Double Progress Weekend')).toBeVisible() + await expect(event.getByText(/Ends in/)).toBeVisible() + await page.getByRole('button', { name: 'Complete a lesson' }).click() + await expect(page.getByRole('status')).toContainText('First Lesson') + await expect(page.getByText('2/10')).toBeVisible() +}) From 3f6e1b579c66bc0e141c98bcf9efd9c62d12c355 Mon Sep 17 00:00:00 2001 From: Steve Hynding Date: Tue, 7 Jul 2026 08:31:50 -0700 Subject: [PATCH 13/13] fix: S3 final-review fixes (webhook timeout, orphan event guard, private secret, multiplier docs) Co-Authored-By: Claude Fable 5 --- README.md | 8 +++++++ apps/api/src/webhooks.ts | 1 + apps/api/test/webhooks.test.ts | 1 + .../config-plane/controllers/config-plane.ts | 24 ++++++++++--------- .../webhook-endpoint/schema.json | 2 +- 5 files changed, 24 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 71a2ddb..0e27f60 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,14 @@ Achievements, offers, and live promotional events for any website or app — one Monorepo: pnpm + Turborepo. See `docs/superpowers/specs/` for the design spec. +### Timed events + +Timed events apply an achievement-progress multiplier while an event is live +or ending soon. When multiple events are live at once, the **highest** +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. + ## Quickstart (dev) corepack enable && pnpm install diff --git a/apps/api/src/webhooks.ts b/apps/api/src/webhooks.ts index f1c2d4b..7177d4a 100644 --- a/apps/api/src/webhooks.ts +++ b/apps/api/src/webhooks.ts @@ -48,6 +48,7 @@ export class WebhookDispatcher { method: 'POST', headers: { 'content-type': 'application/json', [WEBHOOK_SIGNATURE_HEADER]: signature }, body: rawBody, + signal: AbortSignal.timeout(10_000), }) if (res.status >= 500) { lastError = new Error(`webhook endpoint responded ${res.status}`) diff --git a/apps/api/test/webhooks.test.ts b/apps/api/test/webhooks.test.ts index f0c7792..077f607 100644 --- a/apps/api/test/webhooks.test.ts +++ b/apps/api/test/webhooks.test.ts @@ -68,6 +68,7 @@ describe('WebhookDispatcher.deliver — group A (happy path + signing)', () => { const headers = init.headers as Record expect(headers[WEBHOOK_SIGNATURE_HEADER]).toBe(expectedSig) expect(JSON.parse(rawBody)).toEqual(message) + expect(init.signal).toBeInstanceOf(AbortSignal) } }) 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 bd07536..33d0f9e 100644 --- a/apps/cms/src/api/config-plane/controllers/config-plane.ts +++ b/apps/cms/src/api/config-plane/controllers/config-plane.ts @@ -79,17 +79,19 @@ export default { populate: ['project'], }) ctx.body = { - events: rows.map((r: any) => ({ - id: r.documentId, - name: r.name, - description: r.description ?? null, - startsAt: r.startsAt, - endsAt: r.endsAt, - endingSoonMinutes: r.endingSoonMinutes, - multiplier: r.multiplier, - enabled: r.enabled, - projectId: r.project?.documentId ?? null, - })), + events: rows + .filter((r: any) => r.project?.documentId) + .map((r: any) => ({ + id: r.documentId, + name: r.name, + description: r.description ?? null, + startsAt: r.startsAt, + endsAt: r.endsAt, + endingSoonMinutes: r.endingSoonMinutes, + multiplier: r.multiplier, + enabled: r.enabled, + projectId: r.project.documentId, + })), } }, async webhookEndpoints(ctx: any) { diff --git a/apps/cms/src/api/webhook-endpoint/content-types/webhook-endpoint/schema.json b/apps/cms/src/api/webhook-endpoint/content-types/webhook-endpoint/schema.json index a955e57..7676d27 100644 --- a/apps/cms/src/api/webhook-endpoint/content-types/webhook-endpoint/schema.json +++ b/apps/cms/src/api/webhook-endpoint/content-types/webhook-endpoint/schema.json @@ -5,7 +5,7 @@ "options": { "draftAndPublish": false }, "attributes": { "url": { "type": "string", "required": true }, - "secret": { "type": "string", "configurable": false }, + "secret": { "type": "string", "configurable": false, "private": true }, "enabled": { "type": "boolean", "required": true, "default": true }, "project": { "type": "relation", "relation": "manyToOne", "target": "api::project.project" } }