From bc7e4d718c728d72a736ca51dc6adecfd55def1f Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Sun, 5 Jul 2026 16:29:56 -0400 Subject: [PATCH] more image endpoints, rooms --- apps/api/src/api.app.ts | 38 ++++++- apps/api/src/images-db.ts | 89 +++++++++++++++ apps/api/src/test/integration/api.test.ts | 116 ++++++++++++++++++++ apps/rooms/src/rooms-db.ts | 21 ++++ apps/rooms/src/rooms.app.ts | 11 ++ apps/rooms/src/test/integration/api.test.ts | 24 ++++ 6 files changed, 298 insertions(+), 1 deletion(-) diff --git a/apps/api/src/api.app.ts b/apps/api/src/api.app.ts index 0e5fc0d..5f1d22c 100644 --- a/apps/api/src/api.app.ts +++ b/apps/api/src/api.app.ts @@ -9,7 +9,13 @@ import storefrontGiftDrop2 from '../static/storefronts-v3-giftdropstore-2.json' import storefrontGiftDrop3 from '../static/storefronts-v3-giftdropstore-3.json' import storefrontGiftDrop300 from '../static/storefronts-v3-giftdropstore-300.json' import { defaultSettings } from './default-settings' -import { createImage, getImageByName } from './images-db' +import { + createImage, + getImageByName, + getImagesByPlayer, + getImagesByRoom, + getPlayerFeed, +} from './images-db' import { validateAndGetAccountId } from './jwt' import { getRoomById, getRoomByName, getRoomsByCreator, getRoomsByIds } from './rooms-db' @@ -482,6 +488,36 @@ const app = new Hono({ strict: false }) return c.json({ ImageName: name }) }) + // A room's photo feed — the public images taken in that room. `sort` orders the + // feed (1 = most cheered, else newest) and `filter` narrows by SavedImageType + // (0 = all). Paginated via skip/take (take defaults to 100). Returns a bare array. + .get('/api/images/v4/room/:roomId{[0-9]+}', async (c) => { + const roomId = Number.parseInt(c.req.param('roomId'), 10) + const sort = Number.parseInt(c.req.query('sort') ?? '0', 10) || 0 + const filter = Number.parseInt(c.req.query('filter') ?? '0', 10) || 0 + const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0 + const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100 + return c.json(await getImagesByRoom(c.env.DB, roomId, sort, filter, skip, take)) + }) + + // A player's photos — the public images that player has taken, newest first. + // Paginated via skip/take (take defaults to 100). Returns a bare array. + .get('/api/images/v4/player/:playerId{[0-9]+}', async (c) => { + const playerId = Number.parseInt(c.req.param('playerId'), 10) + const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0 + const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100 + return c.json(await getImagesByPlayer(c.env.DB, playerId, skip, take)) + }) + + // A player's photo feed — the public images they took plus ones they're tagged + // in, newest first. Paginated via skip/take (take defaults to 100). Bare array. + .get('/api/images/v3/feed/player/:playerId{[0-9]+}', async (c) => { + const playerId = Number.parseInt(c.req.param('playerId'), 10) + const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0 + const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100 + return c.json(await getPlayerFeed(c.env.DB, playerId, skip, take)) + }) + // Image metadata by filename. Returns the stored SavedImage record, or 404 when // there's no metadata row for that name. .get('/api/images/v6', async (c) => { diff --git a/apps/api/src/images-db.ts b/apps/api/src/images-db.ts index e5dd497..40d470f 100644 --- a/apps/api/src/images-db.ts +++ b/apps/api/src/images-db.ts @@ -89,3 +89,92 @@ export async function getImageByName(db: D1Database, name: string): Promise() return row ? (JSON.parse(row.data) as SavedImage) : null } + +/** + * The public images taken in a room, for the room's photo feed. Only publicly + * accessible images (Accessibility === 1) are returned. `filter` narrows by + * `SavedImageType` (0 = all types); `sort` orders the feed — `1` puts the most + * cheered first (ties broken by newest), anything else is newest-first. Paginated + * via skip/take; returns a bare array of SavedImage. The per-room set is small, so + * the room_id index does the lookup and filtering/sorting happens in memory. + * + * NOTE: the exact `sort`/`filter` enum values are best guesses — the client sends + * `sort=1&filter=1`, and this treats them as most-cheered / ShareCamera. + */ +export async function getImagesByRoom( + db: D1Database, + roomId: number, + sort: number, + filter: number, + skip: number, + take: number +): Promise { + const { results } = await db + .prepare('SELECT data FROM image WHERE room_id = ?1') + .bind(roomId) + .all() + let images = results + .map((r) => JSON.parse(r.data) as SavedImage) + .filter((img) => img.Accessibility === 1) + + if (filter > 0) images = images.filter((img) => img.Type === filter) + + images.sort( + sort === 1 ? (a, b) => b.CheerCount - a.CheerCount || newestFirst(a, b) : newestFirst + ) + + return images.slice(skip, skip + take) +} + +/** Newest-first order: most recent CreatedAt, ties broken by higher Id. */ +const newestFirst = (a: SavedImage, b: SavedImage) => + b.CreatedAt.localeCompare(a.CreatedAt) || b.Id - a.Id + +/** + * The public images a player has taken — their photo list, newest first. + * Paginated via skip/take; returns a bare array of SavedImage. Uses the + * player_id index; the per-player set is small, so filtering/sorting is in memory. + */ +export async function getImagesByPlayer( + db: D1Database, + playerId: number, + skip: number, + take: number +): Promise { + const { results } = await db + .prepare('SELECT data FROM image WHERE player_id = ?1') + .bind(playerId) + .all() + return results + .map((r) => JSON.parse(r.data) as SavedImage) + .filter((img) => img.Accessibility === 1) + .sort(newestFirst) + .slice(skip, skip + take) +} + +/** + * A player's photo feed — the public images they took plus the ones they're + * tagged in (TaggedPlayerIds). Newest first, paginated via skip/take; returns a + * bare array of SavedImage. The tagged-in match uses json_each over the stored + * TaggedPlayerIds array (there's no index for it). + */ +export async function getPlayerFeed( + db: D1Database, + playerId: number, + skip: number, + take: number +): Promise { + const { results } = await db + .prepare( + `SELECT data FROM image + WHERE player_id = ?1 + OR EXISTS (SELECT 1 FROM json_each(image.data, '$.TaggedPlayerIds') WHERE value = ?1)` + ) + .bind(playerId) + .all() + return results + .map((r) => JSON.parse(r.data) as SavedImage) + .filter((img) => img.Accessibility === 1) + .sort(newestFirst) + .slice(skip, skip + take) +} diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index 8464701..55a8178 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -7,6 +7,7 @@ import '../../api.app' import { SCHEMA_DDL as IMAGES_SCHEMA_DDL } from '../../images-db' import type { Env } from '../../context' +import type { SavedImage } from '../../images-db' declare module 'cloudflare:test' { interface ProvidedEnv extends Env {} @@ -493,4 +494,119 @@ describe('images', () => { }) expect(res.status).toBe(400) }) + + test('GET /api/images/v4/room/:id returns a public room feed, filtered/sorted/paginated', async () => { + // Seed images in room 54: two public (one with more cheers, of different + // types), one private (hidden), and one in another room (excluded). + const seed = (img: Partial & { Id: number }) => + env.DB.prepare('INSERT INTO image (data) VALUES (?1)').bind( + JSON.stringify({ + Type: 1, + Accessibility: 1, + AccessibilityLocked: false, + ImageName: `img${img.Id}.jpg`, + Description: null, + PlayerId: 42, + TaggedPlayerIds: [], + RoomId: 54, + PlayerEventId: null, + CreatedAt: '2026-01-01T00:00:00.000Z', + CheerCount: 0, + CommentCount: 0, + ...img, + }) + ) + await env.DB.batch([ + seed({ Id: 101, CheerCount: 5, CreatedAt: '2026-02-01T00:00:00.000Z' }), + seed({ Id: 102, CheerCount: 9, CreatedAt: '2026-01-15T00:00:00.000Z', Type: 3 }), + seed({ Id: 103, Accessibility: 0 }), // private → hidden from the public feed + seed({ Id: 104, RoomId: 99 }), // different room → excluded + ]) + + // sort=1 → most cheered first (102 has 9, 101 has 5). + const top = (await ( + await exports.default.fetch(`${ORIGIN}/api/images/v4/room/54?sort=1&filter=0&take=100&skip=0`) + ).json()) as SavedImage[] + expect(top.map((i) => i.Id)).toEqual([102, 101]) + + // sort=0 → newest first (101 is more recent than 102). + const newest = (await ( + await exports.default.fetch(`${ORIGIN}/api/images/v4/room/54?sort=0`) + ).json()) as SavedImage[] + expect(newest.map((i) => i.Id)).toEqual([101, 102]) + + // filter=1 (ShareCamera) drops the Type-3 image (102). + const filtered = (await ( + await exports.default.fetch(`${ORIGIN}/api/images/v4/room/54?filter=1`) + ).json()) as SavedImage[] + expect(filtered.map((i) => i.Id)).toEqual([101]) + + // take/skip paginate. + const page = (await ( + await exports.default.fetch(`${ORIGIN}/api/images/v4/room/54?sort=1&take=1&skip=1`) + ).json()) as SavedImage[] + expect(page.map((i) => i.Id)).toEqual([101]) + + // A room with no images → empty array. + expect( + await (await exports.default.fetch(`${ORIGIN}/api/images/v4/room/12345`)).json() + ).toEqual([]) + }) + + test('GET /api/images/v4/player/:id and v3/feed/player/:id return the player photos + feed', async () => { + const seed = (img: Partial & { Id: number }) => + env.DB.prepare('INSERT INTO image (data) VALUES (?1)').bind( + JSON.stringify({ + Type: 1, + Accessibility: 1, + AccessibilityLocked: false, + ImageName: `p${img.Id}.jpg`, + Description: null, + PlayerId: 700, + TaggedPlayerIds: [], + RoomId: null, + PlayerEventId: null, + CreatedAt: '2026-01-01T00:00:00.000Z', + CheerCount: 0, + CommentCount: 0, + ...img, + }) + ) + await env.DB.batch([ + // Player 700's own photos (newest last so ordering is exercised). + seed({ Id: 201, PlayerId: 700, CreatedAt: '2026-03-01T00:00:00.000Z' }), + seed({ Id: 202, PlayerId: 700, CreatedAt: '2026-04-01T00:00:00.000Z' }), + seed({ Id: 203, PlayerId: 700, Accessibility: 0 }), // private → hidden + // Taken by someone else, but player 700 is tagged in it → feed only. + seed({ Id: 204, PlayerId: 999, TaggedPlayerIds: [700], CreatedAt: '2026-05-01T00:00:00.000Z' }), + // Unrelated to 700 → in neither. + seed({ Id: 205, PlayerId: 999, TaggedPlayerIds: [111] }), + ]) + + // v4/player → only photos 700 *took*, public, newest first. + const mine = (await ( + await exports.default.fetch(`${ORIGIN}/api/images/v4/player/700`) + ).json()) as SavedImage[] + expect(mine.map((i) => i.Id)).toEqual([202, 201]) + + // take paginates. + const one = (await ( + await exports.default.fetch(`${ORIGIN}/api/images/v4/player/700?take=1`) + ).json()) as SavedImage[] + expect(one.map((i) => i.Id)).toEqual([202]) + + // v3/feed/player → photos taken *or* tagged in, newest first (204 is newest). + const feed = (await ( + await exports.default.fetch(`${ORIGIN}/api/images/v3/feed/player/700?take=100`) + ).json()) as SavedImage[] + expect(feed.map((i) => i.Id)).toEqual([204, 202, 201]) + + // A player with no photos → empty array on both. + expect(await (await exports.default.fetch(`${ORIGIN}/api/images/v4/player/424242`)).json()).toEqual( + [] + ) + expect( + await (await exports.default.fetch(`${ORIGIN}/api/images/v3/feed/player/424242`)).json() + ).toEqual([]) + }) }) diff --git a/apps/rooms/src/rooms-db.ts b/apps/rooms/src/rooms-db.ts index 442361d..ca0920f 100644 --- a/apps/rooms/src/rooms-db.ts +++ b/apps/rooms/src/rooms-db.ts @@ -366,6 +366,27 @@ export async function getHotRooms( return { Results: rooms.slice(skip, skip + take), TotalResults: rooms.length } } +/** + * Recommended rooms feed: public, non-dorm rooms not excluded from lists, ranked + * by engagement (same score as the hot feed). Unlike the hot feed this returns a + * bare array — the client's recommendation room-source loader expects a plain + * list, like the other `*by/me`/base sources. The `splitTest*` A/B params the + * client passes don't change the result. Paginated via skip/take; the dataset is + * small, so this filters/sorts in memory rather than in SQL. + */ +export async function getRecommendedRooms( + db: D1Database, + skip: number, + take: number +): Promise { + const { results } = await db.prepare('SELECT data FROM rooms').all() + const roomId = (r: Room): number => (typeof r.RoomId === 'number' ? r.RoomId : 0) + return parseAll(results) + .filter((r) => r.IsDorm !== true && r.Accessibility === 1 && r.ExcludeFromLists !== true) + .sort((a, b) => hotScore(b) - hotScore(a) || roomId(a) - roomId(b)) + .slice(skip, skip + take) +} + /** * Rooms similar to a target room: public, non-dorm rooms (excluding the target) * that share at least one tag with it, ranked by shared-tag count then diff --git a/apps/rooms/src/rooms.app.ts b/apps/rooms/src/rooms.app.ts index 39ea190..7e03436 100644 --- a/apps/rooms/src/rooms.app.ts +++ b/apps/rooms/src/rooms.app.ts @@ -11,6 +11,7 @@ import { getHotRooms, getInteraction, getPublicRoomsByCreator, + getRecommendedRooms, getRoomById, getRoomByName, getRoomsByCreator, @@ -185,6 +186,16 @@ const app = new Hono() return c.json(await getBaseRooms(c.env.DB, skip, take)) }) + // Recommended rooms feed — public, non-dorm rooms ranked by engagement, returned + // as a bare array (the client's recommendation room-source expects a plain list). + // The `splitTestId`/`splitTestValue` A/B params are accepted and ignored. + // Paginated via skip/take (take defaults to 100). + .get('/rooms/recommendations', async (c) => { + const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0 + const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100 + return c.json(await getRecommendedRooms(c.env.DB, skip, take)) + }) + // Bulk room lookup by `id` or `name` — returns an array of matched rooms (the // client calls this bare on the rooms host). Rooms not in D1 are simply absent // from the result; the client treats an empty result as NoSuchRoom. diff --git a/apps/rooms/src/test/integration/api.test.ts b/apps/rooms/src/test/integration/api.test.ts index e449301..24f6ddb 100644 --- a/apps/rooms/src/test/integration/api.test.ts +++ b/apps/rooms/src/test/integration/api.test.ts @@ -316,6 +316,30 @@ describe('rooms endpoints', () => { expect(body.length).toBeLessThanOrEqual(5) }) + it('GET /rooms/recommendations returns a bare array of public rooms (split-test params ignored)', async () => { + const res = await SELF.fetch( + `${ORIGIN}/rooms/recommendations?splitTestId=1&splitTestValue=5` + ) + expect(res.status).toBe(200) + const body = (await res.json()) as Array<{ RoomId: number; IsDorm?: boolean }> + expect(Array.isArray(body)).toBe(true) + expect(body.length).toBeGreaterThan(0) + // The dorm (RoomId 1) is non-public, so it's never recommended. + expect(body.some((r) => r.RoomId === 1 || r.IsDorm === true)).toBe(false) + + // The split-test params don't change the result. + const plain = (await ( + await SELF.fetch(`${ORIGIN}/rooms/recommendations`) + ).json()) as Array<{ RoomId: number }> + expect(plain.map((r) => r.RoomId)).toEqual(body.map((r) => r.RoomId)) + }) + + it('GET /rooms/recommendations respects take pagination', async () => { + const res = await SELF.fetch(`${ORIGIN}/rooms/recommendations?skip=0&take=3`) + const body = (await res.json()) as unknown[] + expect(body.length).toBeLessThanOrEqual(3) + }) + it('GET /rooms/:id/similar returns { Results, TotalResults } of tag-sharing rooms (excluding self)', async () => { const res = await SELF.fetch(`${ORIGIN}/rooms/2/similar`) expect(res.status).toBe(200)