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
7 changes: 6 additions & 1 deletion apps/api/src/app.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { Hono } from 'hono'
import { cors } from 'hono/cors'
import type { ApiKeyStore, ConfigStore, EventStore, ProgressStore, UsageStore } from '@promocean/core'
import type { ApiKeyStore, ConfigStore, EventStore, OfferMetricsStore, ProgressStore, UsageStore } from '@promocean/core'
import { authMiddleware } from './auth.js'
import { eventsRoute } from './routes/events.js'
import { offersRoute } from './routes/offers.js'
import { placementsRoute } from './routes/placements.js'
import { usersRoute } from './routes/users.js'

export interface AppDeps {
Expand All @@ -11,6 +13,7 @@ export interface AppDeps {
eventStore: EventStore
progressStore: ProgressStore
usageStore: UsageStore
offerMetricsStore: OfferMetricsStore
}

export function createApp(deps: AppDeps) {
Expand All @@ -20,6 +23,8 @@ export function createApp(deps: AppDeps) {
app.use('/v1/*', authMiddleware(deps.apiKeyStore))
app.route('/v1/events', eventsRoute(deps))
app.route('/v1/users', usersRoute(deps))
app.route('/v1/placements', placementsRoute(deps))
app.route('/v1/offers', offersRoute(deps))
app.onError((err, c) => {
console.error(err)
return c.json({ error: { code: 'internal_error', message: 'Internal error.' } }, 500)
Expand Down
3 changes: 2 additions & 1 deletion apps/api/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { serve } from '@hono/node-server'
import { createDb, runMigrations, PgEventStore, PgProgressStore, PgUsageStore } from '@promocean/adapter-db'
import { createDb, runMigrations, PgEventStore, PgOfferMetricsStore, PgProgressStore, PgUsageStore } from '@promocean/adapter-db'
import { StrapiConfigPlane } from '@promocean/adapter-strapi'
import { createApp } from './app.js'

Expand All @@ -15,6 +15,7 @@ const app = createApp({
eventStore: new PgEventStore(db),
progressStore: new PgProgressStore(db),
usageStore: new PgUsageStore(db),
offerMetricsStore: new PgOfferMetricsStore(db),
})
const port = Number(process.env.API_PORT ?? 3001)
serve({ fetch: app.fetch, port })
Expand Down
23 changes: 23 additions & 0 deletions apps/api/src/routes/offers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { Hono } from 'hono'
import { offerClickRequestSchema, type OfferClickResponse } from '@promocean/contracts'
import type { Scope } from '@promocean/core'
import type { AppDeps } from '../app.js'

export function offersRoute(deps: AppDeps) {
const app = new Hono()
app.post('/:id/click', async (c) => {
const offerId = c.req.param('id')
if (offerId.length < 1 || offerId.length > 128) {
return c.json({ error: { code: 'invalid_payload', message: 'Invalid offer id.' } }, 400)
}
const parsed = offerClickRequestSchema.safeParse(await c.req.json().catch(() => ({})))
if (!parsed.success) {
return c.json({ error: { code: 'invalid_payload', message: 'Invalid click payload.', details: parsed.error.issues } }, 400)
}
const auth = c.get('auth')
const scope: Scope = { projectId: auth.projectId, environment: auth.environment }
await deps.offerMetricsStore.recordClick(scope, offerId, parsed.data.userId ?? null, new Date())
return c.json({ recorded: true } satisfies OfferClickResponse)
})
return app
}
37 changes: 37 additions & 0 deletions apps/api/src/routes/placements.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
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 type { AppDeps } from '../app.js'

export function placementsRoute(deps: AppDeps) {
const app = new Hono()
app.get('/:slug/offer', async (c) => {
const auth = c.get('auth')
const scope: Scope = { projectId: auth.projectId, environment: auth.environment }
const slug = c.req.param('slug')
const userId = c.req.query('userId') ?? null
if (!PLACEMENT_SLUG_PATTERN.test(slug) || slug.length > 64) {
return c.json({ error: { code: 'invalid_payload', message: 'Invalid placement slug.' } }, 400)
}
if (userId !== null && (userId.length < 1 || userId.length > 128)) {
return c.json({ error: { code: 'invalid_payload', message: 'Invalid userId.' } }, 400)
}
const offers = await deps.configStore.getOffers(scope.projectId)
const now = new Date()
const offer = resolveOffer(slug, offers, now)
if (offer) {
try {
await deps.offerMetricsStore.recordImpression(scope, offer.id, userId, now)
} catch (err) {
console.error('impression recording failed', err)
}
}
return c.json({
offer: offer
? { offerId: offer.id, headline: offer.headline, body: offer.body, imageUrl: offer.imageUrl, ctaText: offer.ctaText, ctaUrl: offer.ctaUrl }
: null,
} satisfies PlacementOfferResponse)
})
return app
}
14 changes: 10 additions & 4 deletions apps/api/test/fakes.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
import type {
AchievementDefinition, ApiKeyStore, AuthContext, ConfigStore, EventStore, ProgressStore, Scope, UsageStore,
AchievementDefinition, ApiKeyStore, AuthContext, ConfigStore, EventStore, OfferDefinition, OfferMetricsStore,
ProgressStore, Scope, UsageStore,
} from '@promocean/core'

const sk = (s: Scope, rest: string) => `${s.projectId}:${s.environment}:${rest}`

export function makeFakes(definitions: AchievementDefinition[], auth: AuthContext | null) {
export function makeFakes(definitions: AchievementDefinition[], auth: AuthContext | null, offers: OfferDefinition[] = []) {
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 }
const configStore: ConfigStore = { getAchievements: async () => definitions, getOffers: async () => offers }
const apiKeyStore: ApiKeyStore = { verifyKey: async (raw) => (raw === 'pk_test_valid_key_1' ? auth : null) }
const eventStore: EventStore = {
insertEvent: async (s, e) => {
Expand Down Expand Up @@ -38,5 +39,10 @@ export function makeFakes(definitions: AchievementDefinition[], auth: AuthContex
}),
}
const usageStore: UsageStore = { recordUsage: async (_s, u, m) => { usage.push(`${u}:${m}`) } }
return { configStore, apiKeyStore, eventStore, progressStore, usageStore, usage }
const metrics: { impressions: Array<{ offerId: string; userId: string | null }>; clicks: Array<{ offerId: string; userId: string | null }> } = { impressions: [], clicks: [] }
const offerMetricsStore: OfferMetricsStore = {
recordImpression: async (_s, offerId, userId) => { metrics.impressions.push({ offerId, userId }) },
recordClick: async (_s, offerId, userId) => { metrics.clicks.push({ offerId, userId }) },
}
return { configStore, apiKeyStore, eventStore, progressStore, usageStore, usage, offerMetricsStore, metrics }
}
72 changes: 72 additions & 0 deletions apps/api/test/offers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { describe, expect, it } from 'vitest'
import { createApp } from '../src/app.js'
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 },
}
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' }

function setup() {
const fakes = makeFakes([], auth, [offer])
return { app: createApp(fakes), fakes }
}

describe('GET /v1/placements/:slug/offer', () => {
it('resolves the active offer and records an attributed impression', async () => {
const { app, fakes } = setup()
const res = await app.request('/v1/placements/homepage-banner/offer?userId=u1', { headers })
expect(res.status).toBe(200)
expect((await res.json()).offer).toMatchObject({ offerId: 'o1', headline: 'Welcome to Promocean' })
expect(fakes.metrics.impressions).toEqual([{ offerId: 'o1', userId: 'u1' }])
})
it('returns null offer for an empty placement and records nothing', async () => {
const { app, fakes } = setup()
const res = await app.request('/v1/placements/sidebar/offer', { headers })
expect((await res.json()).offer).toBeNull()
expect(fakes.metrics.impressions).toEqual([])
})
it('still returns the offer if impression recording throws', async () => {
const { app, fakes } = setup()
fakes.offerMetricsStore.recordImpression = async () => { throw new Error('db down') }
const res = await app.request('/v1/placements/homepage-banner/offer', { headers })
expect(res.status).toBe(200)
expect((await res.json()).offer?.offerId).toBe('o1')
})
it('rejects an invalid placement slug', async () => {
const { app, fakes } = setup()
const res = await app.request('/v1/placements/Bad_Slug!/offer', { headers })
expect(res.status).toBe(400)
expect((await res.json()).error.code).toBe('invalid_payload')
expect(fakes.metrics.impressions).toEqual([])
})
it('rejects an oversized userId query param', async () => {
const { app } = setup()
const res = await app.request(`/v1/placements/homepage-banner/offer?userId=${'x'.repeat(129)}`, { headers })
expect(res.status).toBe(400)
})
})

describe('POST /v1/offers/:id/click', () => {
it('records a click with optional user attribution', async () => {
const { app, fakes } = setup()
const res = await app.request('/v1/offers/o1/click', { method: 'POST', headers, body: JSON.stringify({ userId: 'u1' }) })
expect((await res.json())).toEqual({ recorded: true })
expect(fakes.metrics.clicks).toEqual([{ offerId: 'o1', userId: 'u1' }])
})
it('rejects an invalid body', async () => {
const { app } = setup()
const res = await app.request('/v1/offers/o1/click', { method: 'POST', headers, body: JSON.stringify({ userId: '' }) })
expect(res.status).toBe(400)
expect((await res.json()).error.code).toBe('invalid_payload')
})
it('rejects an oversized offer id', async () => {
const { app, fakes } = setup()
const res = await app.request(`/v1/offers/${'x'.repeat(129)}/click`, { method: 'POST', headers, body: JSON.stringify({}) })
expect(res.status).toBe(400)
expect(fakes.metrics.clicks).toEqual([])
})
})
25 changes: 25 additions & 0 deletions apps/cms/src/api/config-plane/controllers/config-plane.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,31 @@ export default {
})),
}
},
async offers(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::offer.offer').findMany({
filters: { project: { documentId: projectId } },
populate: ['placement'],
})
ctx.body = {
offers: rows
.filter((r: any) => r.placement?.slug)
.map((r: any) => ({
id: r.documentId,
placementSlug: r.placement.slug,
headline: r.headline,
body: r.body ?? null,
imageUrl: r.imageUrl ?? null,
ctaText: r.ctaText ?? null,
ctaUrl: r.ctaUrl ?? null,
startsAt: r.startsAt ?? null,
endsAt: r.endsAt ?? null,
priority: r.priority ?? 0,
})),
}
},
async verifyKey(ctx: any) {
if (!configSecretOk(ctx)) return ctx.unauthorized()
const { keyHash } = ctx.request.body ?? {}
Expand Down
1 change: 1 addition & 0 deletions apps/cms/src/api/config-plane/routes/config-plane.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
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: 'POST', path: '/config-plane/verify-key', handler: 'config-plane.verifyKey', config: { auth: false } },
],
}
19 changes: 19 additions & 0 deletions apps/cms/src/api/offer/content-types/offer/schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"kind": "collectionType",
"collectionName": "offers",
"info": { "singularName": "offer", "pluralName": "offers", "displayName": "Offer" },
"options": { "draftAndPublish": false },
"attributes": {
"name": { "type": "string", "required": true },
"headline": { "type": "string", "required": true },
"body": { "type": "text" },
"imageUrl": { "type": "string" },
"ctaText": { "type": "string" },
"ctaUrl": { "type": "string" },
"startsAt": { "type": "datetime" },
"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" }
}
}
3 changes: 3 additions & 0 deletions apps/cms/src/api/offer/controllers/offer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { factories } from '@strapi/strapi'

export default factories.createCoreController('api::offer.offer')
3 changes: 3 additions & 0 deletions apps/cms/src/api/offer/routes/offer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { factories } from '@strapi/strapi'

export default factories.createCoreRouter('api::offer.offer')
3 changes: 3 additions & 0 deletions apps/cms/src/api/offer/services/offer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { factories } from '@strapi/strapi'

export default factories.createCoreService('api::offer.offer')
11 changes: 11 additions & 0 deletions apps/cms/src/api/placement/content-types/placement/schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"kind": "collectionType",
"collectionName": "placements",
"info": { "singularName": "placement", "pluralName": "placements", "displayName": "Placement" },
"options": { "draftAndPublish": false },
"attributes": {
"name": { "type": "string", "required": true },
"slug": { "type": "string", "required": true, "regex": "^[a-z][a-z0-9-]*$" },
"project": { "type": "relation", "relation": "manyToOne", "target": "api::project.project" }
}
}
3 changes: 3 additions & 0 deletions apps/cms/src/api/placement/controllers/placement.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { factories } from '@strapi/strapi'

export default factories.createCoreController('api::placement.placement')
3 changes: 3 additions & 0 deletions apps/cms/src/api/placement/routes/placement.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { factories } from '@strapi/strapi'

export default factories.createCoreRouter('api::placement.placement')
3 changes: 3 additions & 0 deletions apps/cms/src/api/placement/services/placement.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { factories } from '@strapi/strapi'

export default factories.createCoreService('api::placement.placement')
15 changes: 15 additions & 0 deletions apps/cms/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,21 @@ export default {
data: { ...a, artworkUrl: null, project: project.documentId },
})
}
const placement = await strapi.documents('api::placement.placement').create({
data: { name: 'Homepage Banner', slug: 'homepage-banner', project: project.documentId },
})
await strapi.documents('api::offer.offer').create({
data: {
name: 'Welcome offer',
headline: 'Welcome to Promocean',
body: 'Track achievements and run promos from one API.',
ctaText: 'Learn more',
ctaUrl: 'https://github.com/hynding/promocean',
priority: 0,
placement: placement.documentId,
project: project.documentId,
},
})
if (process.env.LOG_PLAINTEXT_KEYS === 'true') {
strapi.log.info(`[promocean] Seeded demo project ${project.documentId} with key ${rawKey}`)
} else {
Expand Down
Loading
Loading