mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -07:00
[api] add influencer badges
This commit is contained in:
@@ -16,10 +16,12 @@ export const SCHEMA_DDL: string[] = [
|
||||
avatar TEXT,
|
||||
account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.accountId')) VIRTUAL,
|
||||
username_lower TEXT GENERATED ALWAYS AS (lower(json_extract(data, '$.username'))) VIRTUAL,
|
||||
has_plus INTEGER GENERATED ALWAYS AS (json_extract(data, '$.hasPlus')) VIRTUAL
|
||||
has_plus INTEGER GENERATED ALWAYS AS (json_extract(data, '$.hasPlus')) VIRTUAL,
|
||||
is_influencer INTEGER GENERATED ALWAYS AS (json_extract(data, '$.isInfluencer')) VIRTUAL
|
||||
)`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_accounts_account_id ON account (account_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_account_has_plus ON account (has_plus) WHERE has_plus = 1`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_account_is_influencer ON account (is_influencer) WHERE is_influencer = 1`,
|
||||
]
|
||||
|
||||
/** The stored avatar payload — opaque JSON the client sets and reads back. */
|
||||
|
||||
+16
-12
@@ -15,6 +15,7 @@ import {
|
||||
grantInvention,
|
||||
levelReward,
|
||||
levelsReached,
|
||||
listInfluencerIds,
|
||||
ownsInvention,
|
||||
setOutfit,
|
||||
} from '@repo/domain'
|
||||
@@ -6703,22 +6704,24 @@ const app = new Hono<App>({ strict: false })
|
||||
|
||||
// Everyone in the influencer partner program, by account id — the list the client keeps
|
||||
// so it can badge an influencer wherever they turn up, rather than asking per player.
|
||||
// Membership is the account's `isInfluencer` flag (`runx admin grant-influencer`), read
|
||||
// through the `is_influencer` generated column's partial index so the account table is
|
||||
// never scanned.
|
||||
//
|
||||
// Empty: no programme runs here, so there is nobody to list. Note this is the LIST
|
||||
// counterpart of the single-account check below, and the two answer very differently —
|
||||
// that one 404s to say "not an influencer", this one is a 200 carrying an empty list,
|
||||
// because "nobody is" is a complete answer to "who is?".
|
||||
//
|
||||
// `take` is accepted and ignored; there is nothing to page through.
|
||||
// Paged in shape only: the client asks for `take=1000` and this server will not have
|
||||
// that many, so the whole list is one page and `ContinuationToken` is always null. `take`
|
||||
// is accepted and ignored. Note this is the LIST counterpart of the single-account tier
|
||||
// check below, which still answers 0 for everyone.
|
||||
.get(
|
||||
'/api/influencerpartnerprogram/influencers',
|
||||
describeRoute({
|
||||
tags: ['Econ'],
|
||||
summary: 'Every influencer in the partner program',
|
||||
description: [
|
||||
'The account ids in the influencer partner program, as `{ InfluencerIds }` — an object',
|
||||
'around the list, not a bare array. Always empty here: no programme runs on this',
|
||||
'server. `take` is accepted and ignored, there being nothing to page.',
|
||||
'The account ids in the influencer partner program — every account flagged',
|
||||
'`isInfluencer` — as `{ InfluencerIds, ContinuationToken }`: an object around the',
|
||||
'list, not a bare array. Served whole: `take` is accepted and ignored, and',
|
||||
'`ContinuationToken` is always null.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
parameters: [
|
||||
@@ -6726,19 +6729,20 @@ const app = new Hono<App>({ strict: false })
|
||||
name: 'take',
|
||||
in: 'query',
|
||||
required: false,
|
||||
description: 'How many ids to return. Accepted and ignored.',
|
||||
description: 'How many ids to return. Accepted and ignored — the list is served whole.',
|
||||
schema: { type: 'integer' },
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: json(InfluencerIdsResponse, 'The influencer ids — always empty'),
|
||||
200: json(InfluencerIdsResponse, 'The influencer ids, lowest first'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
return c.json({ InfluencerIds: [] })
|
||||
const InfluencerIds = await listInfluencerIds(c.env.DB)
|
||||
return c.json({ InfluencerIds, ContinuationToken: null })
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -222,12 +222,16 @@ export const RRPlusSignUpBonus = z.object({
|
||||
/**
|
||||
* `GET /api/influencerpartnerprogram/influencers` — the ids of every influencer in the
|
||||
* partner program, which the client uses to badge them wherever they appear. An object
|
||||
* around the list, not a bare array.
|
||||
* around the list, not a bare array. `ContinuationToken` is the paging cursor; always null
|
||||
* here, the whole list fitting in one page.
|
||||
*/
|
||||
export const InfluencerIdsResponse = z.object({
|
||||
InfluencerIds: z
|
||||
.array(z.int())
|
||||
.describe('Account ids in the partner program. Empty — no programme runs here'),
|
||||
.describe('Account ids in the partner program — every account flagged `isInfluencer`'),
|
||||
ContinuationToken: z
|
||||
.null()
|
||||
.describe('Paging cursor for the next page. Always null — the list is served whole'),
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
PROGRESSION_SCHEMA_DDL,
|
||||
RECEIVED_GIFT_SCHEMA_DDL,
|
||||
ROOM_SCHEMA_DDL,
|
||||
updateAccount,
|
||||
} from '@repo/domain'
|
||||
import { PlatformType } from '@repo/domain/src/enums'
|
||||
|
||||
@@ -6894,15 +6895,48 @@ describe('econ endpoints', () => {
|
||||
expect(await res.text()).toBe('')
|
||||
})
|
||||
|
||||
test('GET /api/influencerpartnerprogram/influencers lists nobody', async () => {
|
||||
test('GET /api/influencerpartnerprogram/influencers lists every isInfluencer account', async () => {
|
||||
const seed = async (accountId: number, isInfluencer: boolean | undefined) => {
|
||||
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
|
||||
.bind(
|
||||
JSON.stringify({
|
||||
accountId,
|
||||
username: `Influencer${accountId}`,
|
||||
...(isInfluencer === undefined ? {} : { isInfluencer }),
|
||||
})
|
||||
)
|
||||
.run()
|
||||
}
|
||||
await seed(8103, true)
|
||||
await seed(8101, true)
|
||||
await seed(8102, false) // explicitly not one
|
||||
await seed(8104, undefined) // flag never set
|
||||
|
||||
const res = await exports.default.fetch(
|
||||
`${ORIGIN}/api/influencerpartnerprogram/influencers?take=1000`,
|
||||
{ headers: await bearer('207') }
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
// An object around the list, not a bare array — unlike its single-account siblings
|
||||
// below, whose whole body is a bare tier number.
|
||||
expect(await res.json()).toEqual({ InfluencerIds: [] })
|
||||
// below, whose whole body is a bare tier number. Lowest id first; the whole list is
|
||||
// one page, so the continuation token is null rather than absent.
|
||||
expect(await res.json()).toEqual({ InfluencerIds: [8101, 8103], ContinuationToken: null })
|
||||
|
||||
// The list is read through the `is_influencer` generated column's partial index
|
||||
// (auth migration 0012), so the mirrored schema has to carry it — and the planner
|
||||
// has to pick it over a scan of the account table.
|
||||
const plan = await env.DB.prepare(
|
||||
'EXPLAIN QUERY PLAN SELECT account_id FROM account WHERE is_influencer = 1 ORDER BY account_id'
|
||||
).all<{ detail: string }>()
|
||||
expect(plan.results.map((r) => r.detail).join('\n')).toContain('idx_account_is_influencer')
|
||||
|
||||
// Revoking drops the account from the list on the next read — no cache in between.
|
||||
await updateAccount(env.DB, 8103, { isInfluencer: false })
|
||||
const after = await exports.default.fetch(
|
||||
`${ORIGIN}/api/influencerpartnerprogram/influencers?take=1000`,
|
||||
{ headers: await bearer('207') }
|
||||
)
|
||||
expect(await after.json()).toEqual({ InfluencerIds: [8101], ContinuationToken: null })
|
||||
})
|
||||
|
||||
test('GET /api/influencerpartnerprogram/influencers 401s without a bearer token', async () => {
|
||||
|
||||
Reference in New Issue
Block a user