From 3916e40c2efdd87a35fbeca7fa9466b4e51b4893 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Thu, 8 Oct 2026 14:46:08 -0400 Subject: [PATCH] [econ] influencer endpoints for fun --- apps/econ/src/econ.app.ts | 153 +++++++++++++++---- apps/econ/src/openapi.ts | 42 +++++- apps/econ/src/test/integration/api.test.ts | 168 ++++++++++++++++++--- packages/domain/src/accounts-db.ts | 9 ++ 4 files changed, 312 insertions(+), 60 deletions(-) diff --git a/apps/econ/src/econ.app.ts b/apps/econ/src/econ.app.ts index 87873a5..fcb0425 100644 --- a/apps/econ/src/econ.app.ts +++ b/apps/econ/src/econ.app.ts @@ -18,6 +18,7 @@ import { listInfluencerIds, ownsInvention, setOutfit, + updateAccount, } from '@repo/domain' import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers' import { validateAndGetAccountId, validateAndGetPlus, validateAndGetVersion } from '@repo/jwt' @@ -113,7 +114,6 @@ import { form, GameRewardRequest, InfluencerIdsResponse, - InfluencerTierResponse, IsOnWishlistBulkRequest, IsOnWishlistBulkResponse, ItemPurchaseInfoList, @@ -133,6 +133,7 @@ import { PurchaseRoomKeyWithCurrencyRequest, PurchaseRoomKeyWithCurrencyResponse, ReferralProgressResponse, + RemoveInfluencerBody, RoomConsumableDto, RoomConsumableEnvelope, RoomCurrencyDto, @@ -148,6 +149,9 @@ import { SaveOutfitRequest, SaveOutfitV4Response, SubscriptionResponse, + SupportedInfluencerResponse, + SupportInfluencerBody, + SupportInfluencerResponse, UgcPurchasableBulkRequest, UgcPurchasableItemList, UNAUTHORIZED_RESPONSE, @@ -732,11 +736,17 @@ async function pushBalancePurchase( } /** - * The influencer partner tier every account has here — the "not an influencer" one. It is - * the whole body of both `/api/influencerpartnerprogram/influencer` and `…/myinfluencer`, - * served as a bare number rather than wrapped in anything. + * The influencer an account supports in the partner program, as its reads serve it — the + * `supportedInfluencerId` on the account, or 0 for nobody (and for an account that does not + * exist, which supports nobody too). The whole body of `/api/influencerpartnerprogram/influencer`, + * `…/myinfluencer`, served as a bare number rather than wrapped in anything; the `…/support` + * and `…/remove` writes answer a status envelope instead. The stored id is served as-is: it was an influencer when picked, and a later + * revoke of their flag drops them from `…/influencers` but not from their supporters. */ -const NOT_AN_INFLUENCER = 0 +async function supportedInfluencer(db: D1Database, accountId: number): Promise { + const account = await getAccount(db, accountId) + return account?.supportedInfluencerId ?? 0 +} /** * Whether the caller holds a Rec Room Plus subscription — the ONE definition, shared by @@ -6746,74 +6756,157 @@ const app = new Hono({ strict: false }) } ) - // One account's standing in the influencer partner program. NOBODY here has one: this - // server runs no such program, so the answer is the literal `0` — the "not an influencer" - // tier — for every account. + // The influencer one account SUPPORTS in the partner program, by account id — every + // player picks one (`…/support` below), and this is how anyone reads anyone's pick. // // A BARE NUMBER is the whole body, like `…/makerai/checkfreetrialeligibility`'s bare - // `false`, not a number wrapped in an object. This used to answer 404 with an empty body; - // the tier is what the client actually reads. - // - // `accountId` names the account being asked about. It makes no difference to the answer - // while nobody is an influencer, but it is read rather than ignored so this stays the - // question it looks like — the caller's own standing is `…/myinfluencer` below. + // `false`, not a number wrapped in an object; 0 is "supports nobody", which is also what + // an account that does not exist answers. The caller's own pick is `…/myinfluencer`. .get( '/api/influencerpartnerprogram/influencer', describeRoute({ tags: ['Econ'], - summary: 'An account’s influencer partner program tier', + summary: 'The influencer an account supports', description: [ - 'The partner tier of the account named by `accountId`, as a BARE NUMBER — the whole', - 'body is `0`, not an object around it. Always 0: this server runs no partner program,', - 'so no account is an influencer. Auth-gated; a missing or invalid token is a 401.', + 'The account id of the influencer the account named by `accountId` supports, as a', + 'BARE NUMBER — the whole body is the id, not an object around it; `0` when they', + 'support nobody (or no such account exists). Auth-gated; a missing or invalid token', + 'is a 401. A missing or non-numeric `accountId` is a 400.', ].join(' '), security: AUTHED, parameters: [ { name: 'accountId', in: 'query', - required: false, - description: 'The account being asked about. Every account answers 0.', + required: true, + description: 'The account being asked about.', schema: { type: 'integer' }, }, ], responses: { - 200: json(InfluencerTierResponse, 'The account’s tier — always 0'), + 200: json(SupportedInfluencerResponse, 'The supported influencer’s id, or 0'), + 400: { description: '`accountId` missing or not an integer (empty body)' }, 401: UNAUTHORIZED_RESPONSE, }, }), async (c) => { const id = await authedId(c) if (id === null) return unauthorized(c) - return c.json(NOT_AN_INFLUENCER) + const accountId = Number.parseInt(c.req.query('accountId') ?? '', 10) + if (!Number.isInteger(accountId)) return c.body(null, 400) + return c.json(await supportedInfluencer(c.env.DB, accountId)) } ) // The same question about the CALLER — the `my` form, which names no account because the - // token already does. Same bare `0`, for the same reason: nobody here is an influencer. + // token already does. Same bare number, same 0 for nobody. // // Its own route rather than an alias of the one above, because the two differ in who they - // are about; they agree today only because the answer is currently the same for everyone. + // are about. .get( '/api/influencerpartnerprogram/myinfluencer', describeRoute({ tags: ['Econ'], - summary: 'The caller’s influencer partner program tier', + summary: 'The influencer the caller supports', description: [ - 'The caller’s own partner tier — the `my` form of the route above, taking the account', - 'from the token rather than a query parameter. A BARE NUMBER, always `0`: this server', - 'runs no partner program. Auth-gated; a missing or invalid token is a 401.', + 'The account id of the influencer the caller supports — the `my` form of the route', + 'above, taking the account from the token rather than a query parameter. A BARE', + 'NUMBER; `0` when they support nobody. Auth-gated; a missing or invalid token is a 401.', ].join(' '), security: AUTHED, responses: { - 200: json(InfluencerTierResponse, 'The caller’s tier — always 0'), + 200: json(SupportedInfluencerResponse, 'The supported influencer’s id, or 0'), 401: UNAUTHORIZED_RESPONSE, }, }), async (c) => { const id = await authedId(c) if (id === null) return unauthorized(c) - return c.json(NOT_AN_INFLUENCER) + return c.json(await supportedInfluencer(c.env.DB, id)) + } + ) + + // Pick the influencer the caller supports: `influencerAccountId=`, form-encoded. ONE + // per player, stored on the account as `supportedInfluencerId`; posting again replaces + // the pick. The id has to name an account currently flagged `isInfluencer` — anything + // else, the caller's own id included, is a 400 and changes nothing. The reply is a bare + // `{ Success, Error, error_id }` status envelope — no `Value`: the client reads the pick + // back from `…/myinfluencer` rather than from this reply. + .post( + '/api/influencerpartnerprogram/support', + describeRoute({ + tags: ['Econ'], + summary: 'Pick the influencer the caller supports', + description: [ + 'Set the ONE influencer the caller supports in the partner program, replacing any', + 'earlier pick. `influencerAccountId` must name an account that is currently an', + 'influencer (listed by `…/influencers`); the caller cannot support themselves. The', + 'reply is a `{ Success, Error, error_id }` status envelope with no `Value`; read the', + 'new pick back from `…/myinfluencer`.', + ].join(' '), + security: AUTHED, + requestBody: form(SupportInfluencerBody, 'The influencer to support'), + responses: { + 200: json(SupportInfluencerResponse, 'The pick was recorded'), + 400: { + description: + '`influencerAccountId` missing, not an integer, the caller’s own id, or not an influencer (empty body)', + }, + 401: UNAUTHORIZED_RESPONSE, + }, + }), + async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) + const body = (await c.req.parseBody().catch(() => ({}))) as Record + const raw = body.influencerAccountId + const influencerId = Number.parseInt(typeof raw === 'string' ? raw : '', 10) + if (!Number.isInteger(influencerId) || influencerId === id) return c.body(null, 400) + const influencer = await getAccount(c.env.DB, influencerId) + if (influencer?.isInfluencer !== true) return c.body(null, 400) + await updateAccount(c.env.DB, id, { supportedInfluencerId: influencerId }) + return c.json({ Success: true, Error: null, error_id: null }) + } + ) + + // Stop supporting an influencer: `influencerAccountId=`, form-encoded, the same body + // as `…/support`. The field is UNSET on the account (dropped from the JSON, not written + // as 0), so `…/myinfluencer` goes back to 0. The id has to be an integer (else 400), but + // it only has to NAME THE CURRENT PICK to do anything: naming anyone else — a stale pick + // from before a `…/support` that replaced it, or nobody at all — is a no-op that still + // answers success, like a wishlist remove of something not listed. Guarding on the match + // keeps a stale client from clearing a pick it never saw. + .post( + '/api/influencerpartnerprogram/remove', + describeRoute({ + tags: ['Econ'], + summary: 'Stop supporting an influencer', + description: [ + 'Clear the influencer the caller supports in the partner program. `influencerAccountId`', + 'names the current pick; the field is unset and `…/myinfluencer` answers 0 again.', + 'An id that is not the current pick changes nothing and still answers success. The', + 'reply is the same `{ Success, Error, error_id }` status envelope as `…/support`.', + ].join(' '), + security: AUTHED, + requestBody: form(RemoveInfluencerBody, 'The influencer to stop supporting'), + responses: { + 200: json(SupportInfluencerResponse, 'The pick was cleared (or was not this one)'), + 400: { description: '`influencerAccountId` missing or not an integer (empty body)' }, + 401: UNAUTHORIZED_RESPONSE, + }, + }), + async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) + const body = (await c.req.parseBody().catch(() => ({}))) as Record + const raw = body.influencerAccountId + const influencerId = Number.parseInt(typeof raw === 'string' ? raw : '', 10) + if (!Number.isInteger(influencerId)) return c.body(null, 400) + if ((await supportedInfluencer(c.env.DB, id)) === influencerId) { + // `undefined` wins the spread in updateAccount and JSON.stringify drops the key. + await updateAccount(c.env.DB, id, { supportedInfluencerId: undefined }) + } + return c.json({ Success: true, Error: null, error_id: null }) } ) diff --git a/apps/econ/src/openapi.ts b/apps/econ/src/openapi.ts index a25634a..8e2f6d8 100644 --- a/apps/econ/src/openapi.ts +++ b/apps/econ/src/openapi.ts @@ -235,16 +235,42 @@ export const InfluencerIdsResponse = z.object({ }) /** - * `GET /api/influencerpartnerprogram/influencer` and `…/myinfluencer` — one account's - * standing in the partner program. + * `GET /api/influencerpartnerprogram/influencer` and `…/myinfluencer` — the account id of + * the influencer an account supports. * - * A BARE NUMBER, not an object: the body is the literal `0`, which is the "not an - * influencer" tier. Nobody on this server is one, so 0 is the answer for every account, the - * caller's own included. + * A BARE NUMBER, not an object: the body is the literal id, or `0` when the account + * supports nobody. The write (`POST …/support`) does NOT answer in this shape; see + * `SupportInfluencerResponse`. */ -export const InfluencerTierResponse = z - .literal(0) - .describe('The account’s partner tier. Always 0 — nobody here is an influencer') +export const SupportedInfluencerResponse = z + .int() + .describe('The account id of the influencer this account supports; 0 for nobody') + +/** `POST /api/influencerpartnerprogram/support` — the influencer the caller picks. */ +export const SupportInfluencerBody = z.object({ + influencerAccountId: z + .int() + .describe('The account id of the influencer to support. Must be flagged `isInfluencer`'), +}) + +/** `POST /api/influencerpartnerprogram/remove` — the influencer the caller stops supporting. */ +export const RemoveInfluencerBody = z.object({ + influencerAccountId: z + .int() + .describe('The account id of the influencer to stop supporting — the caller’s current pick'), +}) + +/** + * The reply to `POST /api/influencerpartnerprogram/support` and `…/remove` — a bare + * `{ Success, Error, error_id }` status envelope with NO `Value`: the pick is read back from + * `…/myinfluencer`, not from this reply. The same mixed casing as `RoomCurrencyEnvelope` + * (PascalCase `Success`/`Error` beside a lowercase `error_id`), minus the payload. + */ +export const SupportInfluencerResponse = z.object({ + Success: z.boolean().describe('Always true — every refusal is an empty 400 instead'), + Error: z.null().describe('Always null'), + error_id: z.null().describe('Always null. Present as a key, and lowercase'), +}) /** * `GET /api/incentivizedreferrals/progress` — how far the caller has got with the diff --git a/apps/econ/src/test/integration/api.test.ts b/apps/econ/src/test/integration/api.test.ts index c0e6354..c97c904 100644 --- a/apps/econ/src/test/integration/api.test.ts +++ b/apps/econ/src/test/integration/api.test.ts @@ -9,6 +9,7 @@ import { exports } from 'cloudflare:workers' import { beforeAll, describe, expect, test } from 'vitest' import { + getAccount, getOwnedInventionIds, getPendingGifts, getProgression, @@ -6944,43 +6945,164 @@ describe('econ endpoints', () => { expect(res.status).toBe(401) }) - test('GET /api/influencerpartnerprogram/influencer answers a bare 0', async () => { - const res = await exports.default.fetch( - `${ORIGIN}/api/influencerpartnerprogram/influencer?accountId=220`, - { headers: await bearer('206') } - ) + test('POST /api/influencerpartnerprogram/support picks the ONE influencer the caller supports', async () => { + const seed = async (accountId: number, isInfluencer: boolean) => { + await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)') + .bind(JSON.stringify({ accountId, username: `Support${accountId}`, isInfluencer })) + .run() + } + await seed(8201, true) + await seed(8202, true) + await seed(8203, false) // a player, not an influencer + await seed(8204, false) // the supporter + const support = async (body: string, sub = '8204') => + exports.default.fetch(`${ORIGIN}/api/influencerpartnerprogram/support`, { + method: 'POST', + headers: { ...(await bearer(sub)), 'content-type': 'application/x-www-form-urlencoded' }, + body, + }) + const my = async (sub = '8204') => { + const res = await exports.default.fetch( + `${ORIGIN}/api/influencerpartnerprogram/myinfluencer`, + { + headers: await bearer(sub), + } + ) + expect(res.status).toBe(200) + return res.text() + } + + // Nobody picked yet: a bare 0, the whole body. + expect(await my()).toBe('0') + + // The raw form body the client posts. The reply is a bare status envelope — no `Value`; + // the pick is read back from `…/myinfluencer`. + const res = await support('influencerAccountId=8201') expect(res.status).toBe(200) - // The tier is the WHOLE body — a bare number, not `{ Tier: 0 }` or a string. 0 is - // "not an influencer", which every account is here. expect(res.headers.get('content-type')).toContain('application/json') - expect(await res.text()).toBe('0') + expect(await res.json()).toEqual({ Success: true, Error: null, error_id: null }) + expect(await my()).toBe('8201') + expect((await getAccount(env.DB, 8204))?.supportedInfluencerId).toBe(8201) - // Any account, the caller's own included, gets the same answer. - const self = await exports.default.fetch( - `${ORIGIN}/api/influencerpartnerprogram/influencer?accountId=206`, + // One pick per player: posting again REPLACES it. + expect((await support('influencerAccountId=8202')).status).toBe(200) + expect(await my()).toBe('8202') + + // Only an influencer can be supported, and not oneself. Each refusal is a 400 that + // leaves the pick alone. + expect((await support('influencerAccountId=8203')).status).toBe(400) // not an influencer + expect((await support('influencerAccountId=99999999')).status).toBe(400) // no such account + expect((await support('influencerAccountId=8204')).status).toBe(400) // oneself + expect((await support('influencerAccountId=8201', '8201')).status).toBe(400) // an influencer, oneself + expect((await support('influencerAccountId=nope')).status).toBe(400) + expect((await support('')).status).toBe(400) + expect(await my()).toBe('8202') + + // Anyone can read anyone's pick, as the same bare number. + const theirs = await exports.default.fetch( + `${ORIGIN}/api/influencerpartnerprogram/influencer?accountId=8204`, { headers: await bearer('206') } ) - expect(await self.json()).toBe(0) + expect(theirs.status).toBe(200) + expect(await theirs.text()).toBe('8202') }) - test('GET /api/influencerpartnerprogram/myinfluencer answers a bare 0', async () => { - // The `my` form takes the account from the token instead of a query parameter, and - // answers the same tier in the same shape. - const res = await exports.default.fetch(`${ORIGIN}/api/influencerpartnerprogram/myinfluencer`, { - headers: await bearer('206'), - }) + test('POST /api/influencerpartnerprogram/remove unsets the pick it names', async () => { + for (const [accountId, isInfluencer] of [ + [8211, true], + [8212, true], + [8213, false], + ] as const) { + await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)') + .bind(JSON.stringify({ accountId, username: `Remove${accountId}`, isInfluencer })) + .run() + } + const post = async (path: 'support' | 'remove', body: string, sub = '8213') => + exports.default.fetch(`${ORIGIN}/api/influencerpartnerprogram/${path}`, { + method: 'POST', + headers: { ...(await bearer(sub)), 'content-type': 'application/x-www-form-urlencoded' }, + body, + }) + const my = async () => { + const res = await exports.default.fetch( + `${ORIGIN}/api/influencerpartnerprogram/myinfluencer`, + { headers: await bearer('8213') } + ) + return res.text() + } + + expect((await post('support', 'influencerAccountId=8211')).status).toBe(200) + expect(await my()).toBe('8211') + + // Naming someone other than the current pick is a no-op success: the pick stays. + const other = await post('remove', 'influencerAccountId=8212') + expect(other.status).toBe(200) + expect(await other.json()).toEqual({ Success: true, Error: null, error_id: null }) + expect(await my()).toBe('8211') + + // Naming the current pick clears it: the same envelope, and `…/myinfluencer` is 0 + // again. The field is DROPPED from the account, not written as 0. + const res = await post('remove', 'influencerAccountId=8211') expect(res.status).toBe(200) - expect(await res.text()).toBe('0') + expect(res.headers.get('content-type')).toContain('application/json') + expect(await res.json()).toEqual({ Success: true, Error: null, error_id: null }) + expect(await my()).toBe('0') + const account = await getAccount(env.DB, 8213) + expect(account).not.toBeNull() + expect('supportedInfluencerId' in account!).toBe(false) + + // Removing with nothing picked is also a no-op success. + expect((await post('remove', 'influencerAccountId=8211')).status).toBe(200) + expect(await my()).toBe('0') + + // A body that names no integer is a 400. + expect((await post('remove', 'influencerAccountId=nope')).status).toBe(400) + expect((await post('remove', '')).status).toBe(400) + + // Unauthenticated is a 401. + const anon = await exports.default.fetch(`${ORIGIN}/api/influencerpartnerprogram/remove`, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: 'influencerAccountId=8211', + }) + expect(anon.status).toBe(401) }) - test('the influencer tier routes 401 without a bearer token', async () => { + test('GET /api/influencerpartnerprogram/influencer answers 0 for nobody, 400 for no accountId', async () => { + // An account that has picked nobody, and one that does not exist, both support nobody. + for (const accountId of ['206', '99999999']) { + const res = await exports.default.fetch( + `${ORIGIN}/api/influencerpartnerprogram/influencer?accountId=${accountId}`, + { headers: await bearer('206') } + ) + expect(res.status, accountId).toBe(200) + // The id is the WHOLE body — a bare number, not `{ Id: 0 }` or a string. + expect(res.headers.get('content-type')).toContain('application/json') + expect(await res.text()).toBe('0') + } + for (const query of ['', '?accountId=', '?accountId=abc']) { + const res = await exports.default.fetch( + `${ORIGIN}/api/influencerpartnerprogram/influencer${query}`, + { headers: await bearer('206') } + ) + expect(res.status, query).toBe(400) + } + }) + + test('the influencer support routes 401 without a bearer token', async () => { // Auth is checked before anything is answered, so an unauthenticated caller is told - // that rather than handed a tier. - for (const path of ['influencer', 'myinfluencer']) { + // that rather than handed an id. + for (const path of ['influencer?accountId=206', 'myinfluencer']) { const res = await exports.default.fetch(`${ORIGIN}/api/influencerpartnerprogram/${path}`) expect(res.status, path).toBe(401) expect(await res.text()).toBe('') } + const post = await exports.default.fetch(`${ORIGIN}/api/influencerpartnerprogram/support`, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: 'influencerAccountId=8201', + }) + expect(post.status).toBe(401) }) test('GET /api/makerai/checkfreetrialeligibility answers a bare false', async () => { @@ -7187,6 +7309,8 @@ describe('econ endpoints', () => { 'POST /api/consumables/v1/updateActive', 'POST /api/equipment/v1/update', 'POST /api/gamerewards/v1/request', + 'POST /api/influencerpartnerprogram/remove', + 'POST /api/influencerpartnerprogram/support', 'POST /api/itemWishlists/v1/isonwishlist/bulk', 'POST /api/itemWishlists/v1/wishlist/add', 'POST /api/itemWishlists/v1/wishlist/remove', diff --git a/packages/domain/src/accounts-db.ts b/packages/domain/src/accounts-db.ts index 1fcd3f5..16fb9c6 100644 --- a/packages/domain/src/accounts-db.ts +++ b/packages/domain/src/accounts-db.ts @@ -138,6 +138,15 @@ export interface Account { * `runx admin grant-influencer`. Absent/false means no role. */ isInfluencer?: boolean + /** + * The ONE influencer this account supports in the partner program — the `accountId` of + * an account flagged `isInfluencer`, chosen by the player via econ's + * `POST /api/influencerpartnerprogram/support` and read back by `…/myinfluencer` (their + * own) and `…/influencer?accountId=` (anyone's). Absent means nobody, which those routes + * serve as 0. Re-posting replaces the pick; nothing here records the history. Not in any + * account DTO: the two econ reads are the only surface. + */ + supportedInfluencerId?: number /** * Whether this account is a SANDBOX account — served out of the sandbox Tachyon pool * rather than the live one (`match` `GET /player/connection-info`). Operator-set only;