mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 22:51:30 -07:00
more image endpoints, rooms
This commit is contained in:
+37
-1
@@ -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,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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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([])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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,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)
|
||||||
|
|||||||
Reference in New Issue
Block a user