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:
@@ -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(
|
||||
|
||||
@@ -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') },
|
||||
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
|
||||
@@ -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