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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions apps/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@ 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'
import type { WebhookDispatcher } from './webhooks.js'

export interface AppDeps {
configStore: ConfigStore
Expand All @@ -14,6 +16,7 @@ export interface AppDeps {
progressStore: ProgressStore
usageStore: UsageStore
offerMetricsStore: OfferMetricsStore
webhooks?: WebhookDispatcher
}

export function createApp(deps: AppDeps) {
Expand All @@ -22,6 +25,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))
Expand Down
7 changes: 6 additions & 1 deletion apps/api/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,26 @@
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)
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,
eventStore: new PgEventStore(db),
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 })
Expand Down
22 changes: 20 additions & 2 deletions apps/api/src/routes/events.ts
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -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'] = []
Expand All @@ -36,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
Expand Down
30 changes: 30 additions & 0 deletions apps/api/src/routes/live-events.ts
Original file line number Diff line number Diff line change
@@ -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
}
10 changes: 8 additions & 2 deletions apps/api/src/routes/placements.ts
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -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<string> = new Set<string>()
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)
Expand Down
131 changes: 131 additions & 0 deletions apps/api/src/webhooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
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<void> {
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<void> {
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,
signal: AbortSignal.timeout(10_000),
})
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<void> {
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<typeof timedEventState>): 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)
}
17 changes: 14 additions & 3 deletions apps/api/test/fakes.ts
Original file line number Diff line number Diff line change
@@ -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<string>()
const progress = new Map<string, number>()
const unlockDates = new Map<string, Date>()
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) => {
Expand Down
2 changes: 1 addition & 1 deletion apps/api/test/offers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }
Expand Down
Loading
Loading