[api] adjust to match images v5 derived shape

This commit is contained in:
Devin Zuczek
2026-10-22 13:08:29 -04:00
parent 76db89c30c
commit 1d0e71d651
4 changed files with 86 additions and 18 deletions
+8
View File
@@ -1664,6 +1664,14 @@ export const ImagesPlayerDto = z.object({
RoomId: z.int().nullable(),
})
/**
* The bulk lookup's projection (`GET /api/images/v5/bulk`): {@link ImagesPlayerDto} plus a
* `ClubId`, always null.
*/
export const ImagesBulkDto = ImagesPlayerDto.extend({
ClubId: z.int().nullable().describe('Always null — images are not filed under clubs'),
})
/** One entry in the anonymous slideshow feed, joined to its creator and room. */
export const SlideshowImageDto = z.object({
SavedImageId: z.int(),
+13 -10
View File
@@ -20,6 +20,7 @@ import {
setImageDescription,
SLIDESHOW_LIMIT,
SLIDESHOW_MAX_LIMIT,
toImagesBulk,
toImagesPlayer,
} from '@repo/domain'
import { SNIFFED_IMAGE_FORMATS, sniffImage } from '@repo/hono-helpers'
@@ -34,6 +35,7 @@ import {
ErrorResponse,
form,
idParam,
ImagesBulkDto,
ImagesPlayerDto,
intQuery,
json,
@@ -615,9 +617,9 @@ export const imageRoutes = new Hono<App>({ strict: false })
// with what it asked for; an id with no record (or one that isn't public) is simply
// absent, which is why this answers 200 with a short list rather than 404ing the lot.
//
// Serves the RAW `SavedImage`, like `v6` (metadata by filename) and the room feed — NOT
// the `ImagesPlayer` projection the player photo LISTS use. Those are a rendered grid,
// where the raw record comes up blank; this is a metadata lookup.
// Serves the `ImagesBulk` projection: the player lists' `ImagesPlayer` shape
// (`SavedImageId`/`SavedImageType`, no `TaggedPlayerIds`) plus a `ClubId`, always null.
// NOT the raw `SavedImage` that `v6` and the room feed serve.
//
// Public-only, as every image read here is: ids are sequential, so honouring whatever
// id is named would hand out private photos to anyone who counts.
@@ -630,12 +632,13 @@ export const imageRoutes = new Hono<App>({ strict: false })
'The stored `SavedImage` records for the given ids (`?ids=207&ids=106`), as a bare ' +
'array in request order. An id with no record, or one that is not public, is absent ' +
'from the answer rather than an error — the list can be shorter than the request. ' +
'Serves the raw `SavedImage` (as `v6` does), not the `ImagesPlayer` projection the ' +
'player photo lists use.',
'Serves the `ImagesPlayer` projection (`SavedImageId`/`SavedImageType`, no ' +
'`TaggedPlayerIds`) plus a `ClubId` that is always null — not the raw `SavedImage` ' +
'that `v6` serves.',
parameters: [
intQuery('ids', 'Repeatable; each value may also be a comma-separated list of image ids'),
],
responses: { 200: json(SavedImageDto.array(), 'The matching records, in request order') },
responses: { 200: json(ImagesBulkDto.array(), 'The matching records, in request order') },
}),
async (c) => {
const ids =
@@ -644,7 +647,7 @@ export const imageRoutes = new Hono<App>({ strict: false })
?.flatMap((raw) => raw.split(','))
.map((raw) => Number.parseInt(raw.trim(), 10))
.filter((imageId) => !Number.isNaN(imageId)) ?? []
return c.json(await getImagesByIds(c.env.DB, ids))
return c.json((await getImagesByIds(c.env.DB, ids)).map(toImagesBulk))
}
)
@@ -661,7 +664,7 @@ export const imageRoutes = new Hono<App>({ strict: false })
summary: 'Image metadata by filename',
description:
'An image’s stored `SavedImage` record for a bucket key — the raw record, as ' +
'`v6/{id}` and `v5/bulk` serve, with `Description`, `RoomId` and `PlayerEventId` ' +
'`v6/{id}` serves, with `Description`, `RoomId` and `PlayerEventId` ' +
'null where the row holds null. 404s when the object exists but has no metadata row.',
parameters: [stringQuery('name', 'The image name (bucket key); required')],
responses: {
@@ -682,7 +685,7 @@ export const imageRoutes = new Hono<App>({ strict: false })
//
// Serves the RAW 13-key `SavedImage` (`Id`/`Type`, `TaggedPlayerIds`, nulls left null),
// which is the type the client decodes this route into — the same record `v6?name=`
// serves, and `v5/bulk` a list of. NOT the `ImagesPlayer` projection of the player lists.
// serves. NOT the `ImagesPlayer` projection of the player lists.
//
// Public, or the caller's own: ids are sequential, so anyone else's private photo is a
// 404 here, the same as an id that doesn't exist.
@@ -692,7 +695,7 @@ export const imageRoutes = new Hono<App>({ strict: false })
tags: ['Images'],
summary: 'One image by id',
description:
'The stored `SavedImage` record for an image id — the raw record, as `v5/bulk` ' +
'The stored `SavedImage` record for an image id — the raw record, as `v6?name=` ' +
'serves, with `Description`, `RoomId` and `PlayerEventId` null where the row holds ' +
'null. A private image is served only to the player who took it; to anyone else ' +
'it is a 404, like an unknown id.',
+25 -8
View File
@@ -7106,20 +7106,37 @@ describe('images', () => {
// Request order, not id order — the client lines the answers up with what it asked for.
const both = await bulk(`?ids=${two.Id}&ids=${one.Id}`)
expect(both.map((i) => i.Id)).toEqual([two.Id, one.Id])
// The RAW SavedImage: `Id`/`Type`, and TaggedPlayerIds present — not the
// SavedImageId/SavedImageType projection the player photo lists serve.
expect(both.map((i) => i.SavedImageId)).toEqual([two.Id, one.Id])
// The ImagesPlayer projection plus ClubId — not the raw SavedImage (`Id`/`Type`,
// `TaggedPlayerIds`).
expect(Object.keys(both[0]).sort()).toEqual(
[
'SavedImageId',
'ImageName',
'PlayerId',
'RoomId',
'PlayerEventId',
'ClubId',
'Description',
'Accessibility',
'AccessibilityLocked',
'SavedImageType',
'CreatedAt',
'CheerCount',
'CommentCount',
].sort()
)
expect(both[0]).toMatchObject({
Id: two.Id,
Type: two.Type,
SavedImageId: two.Id,
SavedImageType: two.Type,
ImageName: 'bulktwo.jpg',
PlayerId: 7102,
TaggedPlayerIds: [],
ClubId: null,
})
// An unknown id and a non-public one are absent rather than errors or holes, so the
// answer can be shorter than the request.
expect((await bulk(`?ids=${one.Id}&ids=999999&ids=${hidden.Id}`)).map((i) => i.Id)).toEqual([
expect((await bulk(`?ids=${one.Id}&ids=999999&ids=${hidden.Id}`)).map((i) => i.SavedImageId)).toEqual([
one.Id,
])
@@ -7128,7 +7145,7 @@ describe('images', () => {
// More ids than D1 will bind in one query (100) — the lookup has to split.
const many = [...Array.from({ length: 130 }, (_, i) => 800000 + i), one.Id, two.Id]
expect((await bulk(`?${many.map((id) => `ids=${id}`).join('&')}`)).map((i) => i.Id)).toEqual([
expect((await bulk(`?${many.map((id) => `ids=${id}`).join('&')}`)).map((i) => i.SavedImageId)).toEqual([
one.Id,
two.Id,
])
+40
View File
@@ -467,6 +467,46 @@ export function toImagesPlayer(img: SavedImage): ImagesPlayer {
}
}
/**
* The bulk-lookup projection (`GET /api/images/v5/bulk`): the {@link ImagesPlayer} shape
* plus a `ClubId`. Images aren't filed under clubs here, so it is always null — but the
* key is part of the shape and must be present.
*/
export interface ImagesBulk {
SavedImageId: number
ImageName: string
PlayerId: number
RoomId: number | null
PlayerEventId: number | null
ClubId: number | null
Description: string | null
Accessibility: number
AccessibilityLocked: boolean
SavedImageType: number
CreatedAt: string
CheerCount: number
CommentCount: number
}
/** Project a stored image to the bulk lookup's {@link ImagesBulk} shape. */
export function toImagesBulk(img: SavedImage): ImagesBulk {
return {
SavedImageId: img.Id,
ImageName: img.ImageName,
PlayerId: img.PlayerId,
RoomId: img.RoomId,
PlayerEventId: img.PlayerEventId,
ClubId: null,
Description: img.Description,
Accessibility: img.Accessibility,
AccessibilityLocked: img.AccessibilityLocked,
SavedImageType: img.Type,
CreatedAt: img.CreatedAt,
CheerCount: img.CheerCount,
CommentCount: img.CommentCount,
}
}
/** How many recent images the slideshow feed returns when the caller doesn't say. */
export const SLIDESHOW_LIMIT = 10