more image endpoints, rooms

This commit is contained in:
Devin Zuczek
2026-07-05 16:29:56 -04:00
parent 0e8bf114e0
commit bc7e4d718c
6 changed files with 298 additions and 1 deletions
+37 -1
View File
@@ -9,7 +9,13 @@ import storefrontGiftDrop2 from '../static/storefronts-v3-giftdropstore-2.json'
import storefrontGiftDrop3 from '../static/storefronts-v3-giftdropstore-3.json' import storefrontGiftDrop3 from '../static/storefronts-v3-giftdropstore-3.json'
import storefrontGiftDrop300 from '../static/storefronts-v3-giftdropstore-300.json' import storefrontGiftDrop300 from '../static/storefronts-v3-giftdropstore-300.json'
import { defaultSettings } from './default-settings' 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 { validateAndGetAccountId } from './jwt'
import { getRoomById, getRoomByName, getRoomsByCreator, getRoomsByIds } from './rooms-db' import { getRoomById, getRoomByName, getRoomsByCreator, getRoomsByIds } from './rooms-db'
@@ -482,6 +488,36 @@ const app = new Hono<App>({ strict: false })
return c.json({ ImageName: name }) 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 // Image metadata by filename. Returns the stored SavedImage record, or 404 when
// there's no metadata row for that name. // there's no metadata row for that name.
.get('/api/images/v6', async (c) => { .get('/api/images/v6', async (c) => {
+89
View File
@@ -89,3 +89,92 @@ export async function getImageByName(db: D1Database, name: string): Promise<Save
.first<ImageRow>() .first<ImageRow>()
return row ? (JSON.parse(row.data) as SavedImage) : null 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<SavedImage[]> {
const { results } = await db
.prepare('SELECT data FROM image WHERE room_id = ?1')
.bind(roomId)
.all<ImageRow>()
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<SavedImage[]> {
const { results } = await db
.prepare('SELECT data FROM image WHERE player_id = ?1')
.bind(playerId)
.all<ImageRow>()
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<SavedImage[]> {
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<ImageRow>()
return results
.map((r) => JSON.parse(r.data) as SavedImage)
.filter((img) => img.Accessibility === 1)
.sort(newestFirst)
.slice(skip, skip + take)
}
+116
View File
@@ -7,6 +7,7 @@ import '../../api.app'
import { SCHEMA_DDL as IMAGES_SCHEMA_DDL } from '../../images-db' import { SCHEMA_DDL as IMAGES_SCHEMA_DDL } from '../../images-db'
import type { Env } from '../../context' import type { Env } from '../../context'
import type { SavedImage } from '../../images-db'
declare module 'cloudflare:test' { declare module 'cloudflare:test' {
interface ProvidedEnv extends Env {} interface ProvidedEnv extends Env {}
@@ -493,4 +494,119 @@ describe('images', () => {
}) })
expect(res.status).toBe(400) 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<SavedImage> & { 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<SavedImage> & { 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([])
})
}) })
+21
View File
@@ -366,6 +366,27 @@ export async function getHotRooms(
return { Results: rooms.slice(skip, skip + take), TotalResults: rooms.length } 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<Room[]> {
const { results } = await db.prepare('SELECT data FROM rooms').all<RoomRow>()
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) * 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 * that share at least one tag with it, ranked by shared-tag count then
+11
View File
@@ -11,6 +11,7 @@ import {
getHotRooms, getHotRooms,
getInteraction, getInteraction,
getPublicRoomsByCreator, getPublicRoomsByCreator,
getRecommendedRooms,
getRoomById, getRoomById,
getRoomByName, getRoomByName,
getRoomsByCreator, getRoomsByCreator,
@@ -185,6 +186,16 @@ const app = new Hono<App>()
return c.json(await getBaseRooms(c.env.DB, skip, take)) 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 // 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 // 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. // from the result; the client treats an empty result as NoSuchRoom.
@@ -316,6 +316,30 @@ describe('rooms endpoints', () => {
expect(body.length).toBeLessThanOrEqual(5) 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 () => { it('GET /rooms/:id/similar returns { Results, TotalResults } of tag-sharing rooms (excluding self)', async () => {
const res = await SELF.fetch(`${ORIGIN}/rooms/2/similar`) const res = await SELF.fetch(`${ORIGIN}/rooms/2/similar`)
expect(res.status).toBe(200) expect(res.status).toBe(200)