[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(), 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. */ /** One entry in the anonymous slideshow feed, joined to its creator and room. */
export const SlideshowImageDto = z.object({ export const SlideshowImageDto = z.object({
SavedImageId: z.int(), SavedImageId: z.int(),
+13 -10
View File
@@ -20,6 +20,7 @@ import {
setImageDescription, setImageDescription,
SLIDESHOW_LIMIT, SLIDESHOW_LIMIT,
SLIDESHOW_MAX_LIMIT, SLIDESHOW_MAX_LIMIT,
toImagesBulk,
toImagesPlayer, toImagesPlayer,
} from '@repo/domain' } from '@repo/domain'
import { SNIFFED_IMAGE_FORMATS, sniffImage } from '@repo/hono-helpers' import { SNIFFED_IMAGE_FORMATS, sniffImage } from '@repo/hono-helpers'
@@ -34,6 +35,7 @@ import {
ErrorResponse, ErrorResponse,
form, form,
idParam, idParam,
ImagesBulkDto,
ImagesPlayerDto, ImagesPlayerDto,
intQuery, intQuery,
json, 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 // 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. // 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 // Serves the `ImagesBulk` projection: the player lists' `ImagesPlayer` shape
// the `ImagesPlayer` projection the player photo LISTS use. Those are a rendered grid, // (`SavedImageId`/`SavedImageType`, no `TaggedPlayerIds`) plus a `ClubId`, always null.
// where the raw record comes up blank; this is a metadata lookup. // 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 // 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. // 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 ' + '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 ' + '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. ' + '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 ' + 'Serves the `ImagesPlayer` projection (`SavedImageId`/`SavedImageType`, no ' +
'player photo lists use.', '`TaggedPlayerIds`) plus a `ClubId` that is always null — not the raw `SavedImage` ' +
'that `v6` serves.',
parameters: [ parameters: [
intQuery('ids', 'Repeatable; each value may also be a comma-separated list of image ids'), 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) => { async (c) => {
const ids = const ids =
@@ -644,7 +647,7 @@ export const imageRoutes = new Hono<App>({ strict: false })
?.flatMap((raw) => raw.split(',')) ?.flatMap((raw) => raw.split(','))
.map((raw) => Number.parseInt(raw.trim(), 10)) .map((raw) => Number.parseInt(raw.trim(), 10))
.filter((imageId) => !Number.isNaN(imageId)) ?? [] .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', summary: 'Image metadata by filename',
description: description:
'An image’s stored `SavedImage` record for a bucket key — the raw record, as ' + '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.', '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')], parameters: [stringQuery('name', 'The image name (bucket key); required')],
responses: { 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), // 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=` // 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 // 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. // 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'], tags: ['Images'],
summary: 'One image by id', summary: 'One image by id',
description: 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 ' + '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 ' + 'null. A private image is served only to the player who took it; to anyone else ' +
'it is a 404, like an unknown id.', '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. // 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}`) const both = await bulk(`?ids=${two.Id}&ids=${one.Id}`)
expect(both.map((i) => i.Id)).toEqual([two.Id, one.Id]) expect(both.map((i) => i.SavedImageId)).toEqual([two.Id, one.Id])
// The RAW SavedImage: `Id`/`Type`, and TaggedPlayerIds present — not the // The ImagesPlayer projection plus ClubId — not the raw SavedImage (`Id`/`Type`,
// SavedImageId/SavedImageType projection the player photo lists serve. // `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({ expect(both[0]).toMatchObject({
Id: two.Id, SavedImageId: two.Id,
Type: two.Type, SavedImageType: two.Type,
ImageName: 'bulktwo.jpg', ImageName: 'bulktwo.jpg',
PlayerId: 7102, PlayerId: 7102,
TaggedPlayerIds: [], ClubId: null,
}) })
// An unknown id and a non-public one are absent rather than errors or holes, so the // An unknown id and a non-public one are absent rather than errors or holes, so the
// answer can be shorter than the request. // 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, one.Id,
]) ])
@@ -7128,7 +7145,7 @@ describe('images', () => {
// More ids than D1 will bind in one query (100) — the lookup has to split. // 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] 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, one.Id,
two.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. */ /** How many recent images the slideshow feed returns when the caller doesn't say. */
export const SLIDESHOW_LIMIT = 10 export const SLIDESHOW_LIMIT = 10