diff --git a/apps/api/src/routes/progression.ts b/apps/api/src/routes/progression.ts index acd8f77..e49f33d 100644 --- a/apps/api/src/routes/progression.ts +++ b/apps/api/src/routes/progression.ts @@ -297,6 +297,28 @@ export const progressionRoutes = new Hono({ strict: false }) }), async (c) => c.json(await getReputation(c.env.DB, Number.parseInt(c.req.param('id'), 10))) ) + // How much XP the caller has earned today — what the client measures against its daily + // XP cap. A BARE INTEGER (the client decodes a plain `int`), not a progression record. + // + // Registered BEFORE `/progression/:id` below, which otherwise takes `xpEarnedToday` as + // an account id and answers `{ PlayerId: null, Level: 1, XP: 0 }` — the client's decoder + // threw on that (`expected:'Number Token', actual:'{'`). + // + // STUB: always 0. Nothing here records when XP was earned, only the running total. No + // auth while the answer is the same for everybody; it will need the caller once it is + // real, since the path names no player. + .get( + '/api/players/v1/progression/xpEarnedToday', + describeRoute({ + tags: ['Progression'], + summary: 'XP the caller earned today (stub)', + description: + 'The XP earned so far today, as a bare integer. Always `0` — XP is banked as a ' + + 'running total and nothing records when it was earned.', + responses: { 200: json(BareInteger, 'A bare `0`') }, + }), + (c) => c.json(0) + ) .get( '/api/players/v1/progression/:id', describeRoute({ diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index 3a0783c..fcb6e7d 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -2081,6 +2081,14 @@ describe('public endpoints', () => { expect(res.status).toBe(400) }) + test('GET /api/players/v1/progression/xpEarnedToday is a bare 0', async () => { + // A plain int on the client. It must not fall through to `/progression/:id`, which + // would read the path segment as an account id and answer a progression OBJECT. + const res = await exports.default.fetch(`${ORIGIN}/api/players/v1/progression/xpEarnedToday`) + expect(res.status).toBe(200) + expect(await res.json()).toBe(0) + }) + test('GET /api/progressionEvents/active is a bare 18 (no auth)', async () => { // A hardcoded stub: the id of an archived event (0 would mean "no active event"). // A 404 would stall the client's load instead. @@ -10208,6 +10216,7 @@ describe('openapi', () => { 'GET /api/playerevents/v1/{eventId}/responses', 'GET /api/playerevents/v2/{eventId}', 'GET /api/players/v1/playerPhotoTaggingSetting', + 'GET /api/players/v1/progression/xpEarnedToday', 'GET /api/players/v1/progression/{id}', 'GET /api/players/v2/progression/bulk', 'GET /api/progressionEvents/active', diff --git a/apps/datacollection/src/datacollection.app.ts b/apps/datacollection/src/datacollection.app.ts index dde6c55..05eebf3 100644 --- a/apps/datacollection/src/datacollection.app.ts +++ b/apps/datacollection/src/datacollection.app.ts @@ -48,12 +48,13 @@ const app = new Hono() return c.json([]) }) - // Sampling configuration, asked for once per session (`?sessionId=`). An empty - // object carries no per-event overrides, which the client reads as "sample everything - // at the built-in default rates". Since the events are discarded anyway, the rate it - // picks makes no difference here. + // Sampling configuration, asked for once per session (`?sessionId=`). A BARE ARRAY + // of `{ EventType, ShouldBeSampled }` entries (the client decodes a `List`), + // e.g. `[{ "EventType": "player_join", "ShouldBeSampled": true }]` — not an object: the + // `{}` this once served made the client's decoder throw. Empty says "sample nothing", + // which is the right answer since the events are discarded anyway. .get('/sampling', async (c) => { - return c.json({}) + return c.json([]) }) export default app diff --git a/apps/datacollection/src/test/integration/api.test.ts b/apps/datacollection/src/test/integration/api.test.ts index 9e008e8..c6eee57 100644 --- a/apps/datacollection/src/test/integration/api.test.ts +++ b/apps/datacollection/src/test/integration/api.test.ts @@ -49,7 +49,7 @@ it('acknowledges an unreadable body on both event paths', async () => { it('serves an empty sampling configuration', async () => { const res = await SELF.fetch(`${ORIGIN}/sampling?sessionId=e9a899ce-ce46-447d-bb22-11e82ed68f8d`) expect(res.status).toBe(200) - expect(await res.json()).toEqual({}) + expect(await res.json()).toEqual([]) }) // The client always sends `sessionId`, but nothing here reads it, so a missing one must @@ -57,5 +57,5 @@ it('serves an empty sampling configuration', async () => { it('serves the sampling configuration without a sessionId', async () => { const res = await SELF.fetch(`${ORIGIN}/sampling`) expect(res.status).toBe(200) - expect(await res.json()).toEqual({}) + expect(await res.json()).toEqual([]) }) diff --git a/apps/platformnotifications/src/platformnotifications.app.ts b/apps/platformnotifications/src/platformnotifications.app.ts index a707445..bf26e6b 100644 --- a/apps/platformnotifications/src/platformnotifications.app.ts +++ b/apps/platformnotifications/src/platformnotifications.app.ts @@ -96,14 +96,24 @@ const app = new Hono() }) }) - // The caller's CRM configuration — the campaign/messaging settings the client fetches on - // startup before it will ask about anything else here. An object, not an envelope. + // The caller's CRM configuration — which CRM (Braze, Rengage) and push service the + // client is assigned, fetched on startup before it will ask about anything else here. // - // STUB: there is no CRM behind this, so the config is EMPTY rather than invented. An - // empty object is the honest answer and the client reads it as "nothing configured"; - // made-up keys would switch on messaging paths that have nothing to serve them. Unlike - // `/config/categories` there is no visible text to mark as a stub — nothing here is - // rendered — so the marker stays in this comment. + // ENVELOPED: the client decodes a `RecNetResultObject` and reads + // `Value` off it itself, so the `{ Value, Success, Error, error_id }` wrapper is on the + // wire — this once served a bare `{}`, which leaves `Value` null. Note the casing: + // PascalCase `Success`/`Error` beside a snake_case `error_id`. + // + // `Value` has exactly two keys. `UserConfig.AssignedCrmType` is the `CrmType` enum (None + // 0, Braze 1, Rengage 2) and `AllowPush` a bool — both NON-nullable on the client, so + // never null. `AssignedPushNotificationService` is a nullable `PushNotificationServices` + // (APNS 0, Firebase 1, Mock 2, Braze 3, Rengage 4). `BrazeConfig` is `{ AppId, + // SdkEndpoint }` or null. + // + // STUB: there is no CRM behind this, so the answer is "none assigned" — type 0, no push + // service, push off, no Braze config — rather than an invented assignment that would + // switch on messaging paths with nothing to serve them. Nothing here is rendered, so + // the stub marker stays in this comment. // // Auth-gated because the route is `me`-scoped, like `/preferences` below: the config // belongs to the caller even while the answer is the same for everyone. @@ -111,7 +121,19 @@ const app = new Hono() const accountId = await authedId(c) if (accountId === null) return unauthorized(c) - return c.json({}) + return c.json({ + Value: { + UserConfig: { + AssignedCrmType: 0, + AssignedPushNotificationService: null, + AllowPush: false, + }, + BrazeConfig: null, + }, + Success: true, + Error: null, + error_id: null, + }) }) // The caller's own notification preferences — which categories they have muted, by diff --git a/apps/platformnotifications/src/test/integration/api.test.ts b/apps/platformnotifications/src/test/integration/api.test.ts index b6b33b5..12f3bf5 100644 --- a/apps/platformnotifications/src/test/integration/api.test.ts +++ b/apps/platformnotifications/src/test/integration/api.test.ts @@ -123,9 +123,22 @@ it('serves the caller’s CRM config', async () => { const res = await SELF.fetch(`${ORIGIN}/crm/me/config/v3`, { headers: await bearer('205') }) expect(res.status).toBe(200) expect(res.headers.get('content-type')).toContain('application/json') - // Empty rather than invented: there is no CRM behind this, and made-up keys would switch - // on client messaging paths with nothing to serve them. - expect(await res.json()).toEqual({}) + // A RecNetResultObject envelope — the client reads `Value` off it. "No CRM assigned" + // rather than invented: there is no CRM behind this. `AssignedCrmType` and `AllowPush` + // are non-nullable on the client, so they must never be null. + expect(await res.json()).toEqual({ + Value: { + UserConfig: { + AssignedCrmType: 0, + AssignedPushNotificationService: null, + AllowPush: false, + }, + BrazeConfig: null, + }, + Success: true, + Error: null, + error_id: null, + }) }) it('401s the CRM config read without a bearer token', async () => {