[api] add influencer badges

This commit is contained in:
Devin Zuczek
2026-10-08 14:34:04 -04:00
parent 6b8c908771
commit cebeafeb40
12 changed files with 238 additions and 34 deletions
+24 -3
View File
@@ -18,7 +18,11 @@
* into the caller's own list, and to rank the "top today" feed by what players actually
* picked up today. See @repo/domain's inventory-invention-db.ts.
*/
import { getInventionAcquisitionCounts, getOwnedInventionIds } from '@repo/domain'
import {
getAccountByUsername,
getInventionAcquisitionCounts,
getOwnedInventionIds,
} from '@repo/domain'
/**
* Schema DDL (mirror of migrations/0002_invention.sql + 0003_invention_featured.sql +
@@ -727,6 +731,14 @@ export async function ownsAllInventions(
* and a tag appears in no name or description, so those searches find nothing. Matching tags
* needs them out of the JSON blob and into something indexable first; until then this stays a
* text search rather than one that scans every row to look at its tags.
*
* A value starting with `@` IS special: `@djdevin` is a search for that player's inventions —
* the public ones, the same set `v1/fromcreators` lists for their portfolio — resolved by
* username, case-insensitively, through the indexed `username_lower`. Any terms after the
* `@name` narrow that player's inventions as text, so `@djdevin sofa` works. A username nobody
* has matches nothing rather than falling back to a text search for "@djdevin": a player who
* typed an `@` meant a person, and a list of inventions whose descriptions mention them is not
* what they asked for.
*/
export async function searchInventions(
db: D1Database,
@@ -743,11 +755,20 @@ export async function searchInventions(
/** Bind a value and get its placeholder, so the numbering can't drift as terms are added. */
const bind = (v: string | number): string => `?${binds.push(v)}`
for (const term of value
const terms = value
.trim()
.toLowerCase()
.split(/[\s+]+/)
.filter(Boolean)) {
.filter(Boolean)
if (terms[0]?.startsWith('@')) {
const username = terms.shift()!.slice(1)
const creator = username ? await getAccountByUsername(db, username) : null
if (!creator) return []
where.push(`creator_player_id = ${bind(creator.accountId)}`)
}
for (const term of terms) {
const escaped = term.replace(/[\\%_]/g, (ch) => `\\${ch}`)
const pattern = bind(`%${escaped}%`)
where.push(
+12 -5
View File
@@ -1732,8 +1732,9 @@ export const avatarRoutes = new Hono<App>({ strict: false })
)
// Invention search/browse: published inventions matching `value` (matched against
// name + description; absent → browse everything published), newest first.
// Paginated via skip/take (take defaults to 100). Returns a bare array.
// name + description; absent → browse everything published; `@username` → that
// player's published inventions), newest first. Paginated via skip/take (take
// defaults to 100). Returns a bare array.
//
// Filtered, ordered and paged in SQL — it must not read the catalogue into memory to
// answer one page.
@@ -1746,10 +1747,16 @@ export const avatarRoutes = new Hono<App>({ strict: false })
'Published inventions matching `value`, newest first. `value` is split into terms ' +
'and every term must match, each against the name and the description. An absent ' +
'`value` browses everything published — that is the browse screen’s initial ' +
'request. Tags are NOT searched: a `#tag` term from the browse screen’s filter ' +
'chips is treated as text and matches nothing.',
'request. A `value` starting with `@` names a player: `@djdevin` lists that ' +
'player’s PUBLISHED inventions (the same set `v1/fromcreators` serves), resolved by ' +
'username case-insensitively, with any further terms narrowing them as text; an ' +
'unknown username is an empty array. Tags are NOT searched: a `#tag` term from the ' +
'browse screen’s filter chips is treated as text and matches nothing.',
parameters: [
stringQuery('value', 'Search text; absent browses everything'),
stringQuery(
'value',
'Search text; absent browses everything; `@username` is a player’s inventions'
),
...pageParams(100),
],
responses: { 200: json(InventionDto.array(), 'The matching inventions') },
+49
View File
@@ -3671,6 +3671,55 @@ describe('public endpoints', () => {
expect(await miss.json()).toEqual([])
})
test('GET /api/inventions/v2/search with `@username` lists that player’s published inventions', async () => {
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(JSON.stringify({ accountId: 8082, username: 'DJDevin' }))
.run()
const invention = (id: number, creator: number, name: string): SavedInvention =>
({
InventionId: id,
ReplicationId: crypto.randomUUID(),
CreatorPlayerId: creator,
Name: name,
Description: '',
ImageName: '',
CurrentVersionNumber: 1,
CurrentVersion: { InventionId: id, VersionNumber: 1, BlobName: '' },
IsPublished: true,
HideFromPlayer: false,
CreatedAt: `2026-07-${String(id - 100).padStart(2, '0')}T00:00:00Z`,
}) as unknown as SavedInvention
for (const inv of [
invention(111, 8082, 'Devin Sofa'),
invention(112, 8082, 'Devin Lamp'),
// Another creator mentioning the name is NOT this player's invention.
{ ...invention(113, 8083, 'Tribute to @djdevin'), Description: 'by @djdevin' },
// Unpublished and hidden ones stay private, even to a search for the creator.
{ ...invention(114, 8082, 'Devin Draft'), IsPublished: false },
{ ...invention(115, 8082, 'Devin Hidden'), HideFromPlayer: true },
]) {
await env.DB.prepare('INSERT INTO invention (data) VALUES (?1)')
.bind(JSON.stringify(inv))
.run()
}
const ids = async (value: string): Promise<number[]> => {
const res = await exports.default.fetch(
`${ORIGIN}/api/inventions/v2/search?value=${encodeURIComponent(value)}&skip=0&take=100`
)
expect(res.status).toBe(200)
return ((await res.json()) as SavedInvention[]).map((i) => i.InventionId)
}
// The player's published inventions, newest first; the username is case-insensitive.
expect(await ids('@djdevin')).toEqual([112, 111])
expect(await ids('@DJDevin')).toEqual([112, 111])
// Further terms narrow within that player's inventions.
expect(await ids('@djdevin lamp')).toEqual([112])
// An unknown player is nothing — not a text search for the literal "@nobody".
expect(await ids('@nobody')).toEqual([])
expect(await ids('@')).toEqual([])
})
test('GET /api/inventions/v2/search filters and pages in SQL, and does not search tags', async () => {
const published = (id: number, name: string, description: string, tags: string[]) =>
({
@@ -0,0 +1,15 @@
-- Expose the account's influencer flag (`isInfluencer` in the JSON blob) as an indexed
-- generated column, so "every influencer" is an index walk rather than a scan of every
-- account's JSON. The reader is econ's `GET /api/influencerpartnerprogram/influencers`,
-- which the client asks for the whole list on every launch so it can badge influencers
-- wherever they turn up.
--
-- Same construction as `has_plus` (0009): a PARTIAL index holding only the rows where the
-- flag is set, so it is as small as the influencer list. A query must say exactly
-- `is_influencer = 1` to use it. `json_extract` yields 1 for JSON true, 0 for false and
-- NULL when the key is absent, so neither a revoked nor a never-granted account is in it.
-- Kept in sync with SCHEMA_DDL in @repo/domain's accounts-db.ts (and its mirror in
-- apps/econ/src/avatar-db.ts).
ALTER TABLE account ADD COLUMN is_influencer INTEGER GENERATED ALWAYS AS (json_extract(data, '$.isInfluencer')) VIRTUAL;
CREATE INDEX IF NOT EXISTS idx_account_is_influencer ON account (is_influencer) WHERE is_influencer = 1;
+9 -3
View File
@@ -295,11 +295,16 @@ async function roleFilterAnswer(c: Context<App>, role: 'developer' | 'moderator'
*
* `betastudio` rides on the account's `hasStudio` flag, and developer does not imply it.
* RecFlare Studio treats that exact claim as Full access, which is what lets the editor
* upload; staff set the flag from the website. Order is stable so tokens are
* deterministic: screenshare, developer, moderator, junior, then betastudio.
* upload; staff set the flag from the website. `influencer` rides on `isInfluencer`
* (`runx admin grant-influencer`) and lives only in the token — no `/role/*` lookup
* answers it. Order is stable so tokens are deterministic: screenshare, developer,
* moderator, junior, betastudio, then influencer.
*/
function accountRoles(
account: Pick<Account, 'isDeveloper' | 'isModerator' | 'isJunior' | 'hasStudio'> | null
account: Pick<
Account,
'isDeveloper' | 'isModerator' | 'isJunior' | 'hasStudio' | 'isInfluencer'
> | null
): string[] {
const roles = ['screenshare']
if (!account) return roles
@@ -307,6 +312,7 @@ function accountRoles(
if (account.isModerator) roles.push('moderator')
if (account.isJunior) roles.push('junior')
if (account.hasStudio) roles.push('betastudio')
if (account.isInfluencer) roles.push('influencer')
return roles
}
@@ -895,6 +895,29 @@ describe('auth worker routes', () => {
expect(payload.scope).not.toContain('rn.privilege')
})
test('POST /connect/token stamps the influencer role for an isInfluencer account', async () => {
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(
JSON.stringify({
accountId: 95,
username: 'InfluencerPlayer',
passwordHash: await hashPassword(LOGIN_PASSWORD),
isInfluencer: true,
})
)
.run()
const payload = await tokenFor(`account_id=95&password=${LOGIN_PASSWORD}`)
expect(payload.role).toEqual(
expect.arrayContaining(['gameClient', 'screenshare', 'influencer'])
)
expect(payload.role).not.toContain('developer')
// Off the flag, off the token — no other flag implies it.
await updateAccount(env.DB, 95, { isInfluencer: false })
const revoked = await tokenFor(`account_id=95&password=${LOGIN_PASSWORD}`)
expect(revoked.role).not.toContain('influencer')
})
test('POST /connect/token 400s when no account_id is posted (never defaults to 1)', async () => {
const res = await exports.default.fetch(`${ORIGIN}/connect/token`, { method: 'POST' })
expect(res.status).toBe(400)
+3 -1
View File
@@ -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
View File
@@ -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 })
}
)
+6 -2
View File
@@ -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'),
})
/**
+37 -3
View File
@@ -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 () => {