From dbfc11680817b78d9a840808775c432bc9d2ce6c Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Wed, 26 Aug 2026 10:53:56 -0400 Subject: [PATCH] [api] update hot avatar custom endpoint --- apps/api/src/custom-avatar-items-db.ts | 20 +++++++++++ apps/api/src/routes/avatar.ts | 14 +++++--- apps/api/src/test/integration/api.test.ts | 43 +++++++++++++++++++++-- 3 files changed, 71 insertions(+), 6 deletions(-) diff --git a/apps/api/src/custom-avatar-items-db.ts b/apps/api/src/custom-avatar-items-db.ts index d4ff42e..828cd8b 100644 --- a/apps/api/src/custom-avatar-items-db.ts +++ b/apps/api/src/custom-avatar-items-db.ts @@ -229,6 +229,26 @@ export async function listFeaturedCustomAvatarItems( return results.map(toDto) } +/** + * The "hot" (trending) feed (`GET /api/customAvatarItems/v1/hot`): every PUBLISHED item — + * `Accessibility` 0 is the unpublished state and is the only thing held back. Newest + * first, standing in for a trend ranking there is nothing to compute one from yet (no + * purchase or wear counts are recorded). + */ +export async function listHotCustomAvatarItems( + db: D1Database, + limit = 50 +): Promise { + const { results } = await db + .prepare( + `SELECT * FROM custom_avatar_item WHERE accessibility != 0 + ORDER BY created_at DESC, custom_avatar_item_id LIMIT ?1` + ) + .bind(limit) + .all() + return results.map(toDto) +} + /** * What an account has authored (`GET /api/customAvatarItems/v2/fromCreator/:id`), newest * first, with the total for the client's paginated envelope. `includeUnpublished` is for diff --git a/apps/api/src/routes/avatar.ts b/apps/api/src/routes/avatar.ts index c9c27ff..35202fd 100644 --- a/apps/api/src/routes/avatar.ts +++ b/apps/api/src/routes/avatar.ts @@ -18,6 +18,7 @@ import { getCustomAvatarItem, listCustomAvatarItemsByCreator, listFeaturedCustomAvatarItems, + listHotCustomAvatarItems, updateCustomAvatarItem, } from '../custom-avatar-items-db' import { authedId, unauthorized } from '../http' @@ -446,16 +447,21 @@ export const avatarRoutes = new Hono({ strict: false }) async (c) => c.json(await listFeaturedCustomAvatarItems(c.env.DB)) ) - // The "hot" (trending) custom-avatar-item feed. No items yet → an empty list. + // The "hot" (trending) custom-avatar-item feed: every published (`Accessibility` != 0) + // item from the `custom_avatar_item` table. There is nothing to rank a trend from yet, + // so it is the accessible items, newest first. .get( '/api/customAvatarItems/v1/hot', describeRoute({ tags: ['Avatar'], summary: 'Trending custom avatar items', - description: 'The “hot” feed. No custom items exist yet, so it is empty.', - responses: { 200: json(JsonArray, 'An empty list') }, + description: + 'The “hot” feed: the published items (`Accessibility` 0 is unpublished and is left ' + + 'out), newest first, up to 50. No purchase or wear counts are recorded, so there is ' + + 'no trend to rank by and recency stands in for one.', + responses: { 200: json(CustomAvatarItemList, 'The items, newest first') }, }), - (c) => c.json([]) + async (c) => c.json(await listHotCustomAvatarItems(c.env.DB)) ) // A batch lookup of custom avatar items by id. The reference filters a static catalog diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index 4796316..d399c8a 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -1007,10 +1007,49 @@ describe('public endpoints', () => { expect(await res.json()).toEqual([]) }) - test('GET /api/customAvatarItems/v1/hot returns []', async () => { + test('GET /api/customAvatarItems/v1/hot lists the published items, newest first', async () => { + await env.DB.prepare('DELETE FROM custom_avatar_item').run() + const empty = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1/hot`) + expect(empty.status).toBe(200) + expect(await empty.json()).toEqual([]) + + const older = await createCustomAvatarItem( + env.DB, + item('Older', 1), + new Date('2026-08-01T00:00:00Z') + ) + const newer = await createCustomAvatarItem( + env.DB, + item('Newer', 1), + new Date('2026-08-02T00:00:00Z') + ) + const unpublished = await createCustomAvatarItem(env.DB, item('Unpublished', 0)) + const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1/hot`) expect(res.status).toBe(200) - expect(await res.json()).toEqual([]) + const got = (await res.json()) as Array<{ CustomAvatarItemId: string; Name: string }> + // Unfeatured but published: the hot feed does not care about the featured flag. + expect(got.map((i) => i.CustomAvatarItemId)).toEqual([ + newer.CustomAvatarItemId, + older.CustomAvatarItemId, + ]) + expect(got.map((i) => i.CustomAvatarItemId)).not.toContain(unpublished.CustomAvatarItemId) + expect(got[0]).toMatchObject({ Name: 'Newer', IsFeatured: false, CurrentSaves: [] }) + + function item(name: string, accessibility: number) { + return { + customAvatarItemId: crypto.randomUUID(), + creatorAccountId: 205, + name, + description: '', + price: 0, + baseAvatarItemId: 1, + baseAvatarItemColor: '#fff', + accessibility, + designFilename: 'design_x.bin', + thumbnailImageFilename: 'thumb_x.png', + } + } }) test('GET /api/customAvatarItems/v2/fromCreator/:id shows unpublished items only to the creator', async () => {