From 1d0e71d6515e0e5132b6b4b1fbcfb7ab6ebf547d Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Thu, 22 Oct 2026 13:08:29 -0400 Subject: [PATCH] [api] adjust to match images v5 derived shape --- apps/api/src/openapi.ts | 8 +++++ apps/api/src/routes/images.ts | 23 +++++++------ apps/api/src/test/integration/api.test.ts | 33 ++++++++++++++----- packages/domain/src/images-db.ts | 40 +++++++++++++++++++++++ 4 files changed, 86 insertions(+), 18 deletions(-) diff --git a/apps/api/src/openapi.ts b/apps/api/src/openapi.ts index 58776d1..8beea0a 100644 --- a/apps/api/src/openapi.ts +++ b/apps/api/src/openapi.ts @@ -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(), diff --git a/apps/api/src/routes/images.ts b/apps/api/src/routes/images.ts index c2d2265..fa70cb5 100644 --- a/apps/api/src/routes/images.ts +++ b/apps/api/src/routes/images.ts @@ -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({ 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({ 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({ 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({ 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({ 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({ 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.', diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index 7412943..0d32292 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -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, ]) diff --git a/packages/domain/src/images-db.ts b/packages/domain/src/images-db.ts index d6a98c7..6b3ea24 100644 --- a/packages/domain/src/images-db.ts +++ b/packages/domain/src/images-db.ts @@ -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