fix club images

This commit is contained in:
Devin Zuczek
2026-07-21 16:28:53 -04:00
parent c80a25bd24
commit 5df06ea168
6 changed files with 142 additions and 67 deletions
+47 -5
View File
@@ -118,12 +118,16 @@ export async function createImage(db: D1Database, input: NewImage): Promise<Save
*/
async function syncImageCheerCount(db: D1Database, savedImageId: number): Promise<number> {
const row = await db
.prepare('SELECT COUNT(*) AS n FROM image_interaction WHERE saved_image_id = ?1 AND cheered = 1')
.prepare(
'SELECT COUNT(*) AS n FROM image_interaction WHERE saved_image_id = ?1 AND cheered = 1'
)
.bind(savedImageId)
.first<{ n: number }>()
const count = row?.n ?? 0
await db
.prepare("UPDATE image SET data = json_set(data, '$.CheerCount', CAST(?2 AS INTEGER)) WHERE id = ?1")
.prepare(
"UPDATE image SET data = json_set(data, '$.CheerCount', CAST(?2 AS INTEGER)) WHERE id = ?1"
)
.bind(savedImageId, count)
.run()
return count
@@ -223,9 +227,7 @@ export async function getImagesByRoom(
if (filter > 0) images = images.filter((img) => img.Type === filter)
images.sort(
sort === 1 ? (a, b) => b.CheerCount - a.CheerCount || newestFirst(a, b) : newestFirst
)
images.sort(sort === 1 ? (a, b) => b.CheerCount - a.CheerCount || newestFirst(a, b) : newestFirst)
return images.slice(skip, skip + take)
}
@@ -257,6 +259,46 @@ export async function getImagesByPlayer(
.slice(skip, skip + take)
}
/**
* The client-facing projection of a saved image for the player photo lists (the
* reference's `ImagesPlayer`). Same data as the stored record, but the id and type
* are renamed — `Id` → `SavedImageId`, `Type` → `SavedImageType` — and the tagged
* player ids aren't part of it. The client deserializes into this shape, so a raw
* SavedImage leaves it without an image id and its thumbnails come up blank.
*/
export interface ImagesPlayer {
Accessibility: number
AccessibilityLocked: boolean
CheerCount: number
CommentCount: number
CreatedAt: string
Description: string | null
ImageName: string
PlayerEventId: number | null
PlayerId: number
RoomId: number | null
SavedImageId: number
SavedImageType: number
}
/** Project a stored image to the client's ImagesPlayer shape. */
export function toImagesPlayer(img: SavedImage): ImagesPlayer {
return {
Accessibility: img.Accessibility,
AccessibilityLocked: img.AccessibilityLocked,
CheerCount: img.CheerCount,
CommentCount: img.CommentCount,
CreatedAt: img.CreatedAt,
Description: img.Description,
ImageName: img.ImageName,
PlayerEventId: img.PlayerEventId,
PlayerId: img.PlayerId,
RoomId: img.RoomId,
SavedImageId: img.Id,
SavedImageType: img.Type,
}
}
/** Default number of recent images the slideshow feed returns. */
export const SLIDESHOW_LIMIT = 130
+15 -7
View File
@@ -1,5 +1,6 @@
import { Hono } from 'hono'
import { authedId, unauthorized } from '../http'
import {
createImage,
deleteImage,
@@ -11,8 +12,8 @@ import {
getSlideshowImages,
SavedImageType,
setImageCheer,
toImagesPlayer,
} from '../images-db'
import { authedId, unauthorized } from '../http'
import type { App } from '../context'
@@ -139,12 +140,14 @@ export const imageRoutes = new Hono<App>({ strict: false })
})
// 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.
// Paginated via skip/take (take defaults to 100). Returns a bare array of the
// client's ImagesPlayer projection (SavedImageId/SavedImageType, not Id/Type).
.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, 0, skip, take))
const images = await getImagesByPlayer(c.env.DB, playerId, 0, skip, take)
return c.json(images.map(toImagesPlayer))
})
// A player's photos with a sort option. `sort` orders the list (1 = most
@@ -154,16 +157,19 @@ export const imageRoutes = new Hono<App>({ strict: false })
const sort = Number.parseInt(c.req.query('sort') ?? '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 getImagesByPlayer(c.env.DB, playerId, sort, skip, take))
const images = await getImagesByPlayer(c.env.DB, playerId, sort, skip, take)
return c.json(images.map(toImagesPlayer))
})
// 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.
// in, newest first. Paginated via skip/take (take defaults to 100). Bare array of
// the same ImagesPlayer projection the player photo lists use.
.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))
const images = await getPlayerFeed(c.env.DB, playerId, skip, take)
return c.json(images.map(toImagesPlayer))
})
// Global slideshow feed — the most recent publicly-listable ShareCamera photos
@@ -214,5 +220,7 @@ export const imageRoutes = new Hono<App>({ strict: false })
.map((raw) => Number.parseInt(raw.trim(), 10))
.filter((imageId) => !Number.isNaN(imageId)) ?? []
const cheered = await getCheeredImageIds(c.env.DB, id, ids)
return c.json(ids.map((imageId) => ({ SavedImageId: imageId, IsCheered: cheered.has(imageId) })))
return c.json(
ids.map((imageId) => ({ SavedImageId: imageId, IsCheered: cheered.has(imageId) }))
)
})
+32 -7
View File
@@ -1140,7 +1140,8 @@ describe('images', () => {
test('POST /api/images/v1/cheer persists, syncs CheerCount, and the bulk lookup reflects it', async () => {
// Seed an image to cheer.
const img = await createImage(env.DB, { imageName: 'cheerme.jpg', playerId: 700 })
// Its own player id: 700's photos are asserted on exactly in the player-list test.
const img = await createImage(env.DB, { imageName: 'cheerme.jpg', playerId: 7001 })
const cheerBody = JSON.stringify({ SavedImageId: img.Id, Cheer: true })
// No token → 401.
@@ -1465,23 +1466,47 @@ describe('images', () => {
seed({ Id: 205, PlayerId: 999, TaggedPlayerIds: [111] }),
])
// The lists serve the client's ImagesPlayer projection: the id and type are
// SavedImageId/SavedImageType, and TaggedPlayerIds isn't part of it.
type ImagesPlayer = { SavedImageId: number; SavedImageType: number; ImageName: string }
// 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])
).json()) as ImagesPlayer[]
expect(mine.map((i) => i.SavedImageId)).toEqual([202, 201])
expect(mine[0]).toEqual({
Accessibility: 1,
AccessibilityLocked: false,
CheerCount: 0,
CommentCount: 0,
CreatedAt: '2026-04-01T00:00:00.000Z',
Description: null,
ImageName: 'p202.jpg',
PlayerEventId: null,
PlayerId: 700,
RoomId: null,
SavedImageId: 202,
SavedImageType: 1,
})
// 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])
).json()) as ImagesPlayer[]
expect(one.map((i) => i.SavedImageId)).toEqual([202])
// v5/player is the same list with a sort option (0 = newest first).
const sorted = (await (
await exports.default.fetch(`${ORIGIN}/api/images/v5/player/700?sort=0`)
).json()) as ImagesPlayer[]
expect(sorted.map((i) => i.SavedImageId)).toEqual([202, 201])
// 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])
).json()) as ImagesPlayer[]
expect(feed.map((i) => i.SavedImageId)).toEqual([204, 202, 201])
// A player with no photos → empty array on both.
expect(