[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[]) =>
({