diff --git a/apps/clubs/package.json b/apps/clubs/package.json index 5a8e29b..f91fa02 100644 --- a/apps/clubs/package.json +++ b/apps/clubs/package.json @@ -16,6 +16,7 @@ "test": "run-vitest" }, "dependencies": { + "@repo/domain": "workspace:*", "@repo/hono-helpers": "workspace:*", "@repo/jwt": "workspace:*", "hono": "4.12.27", diff --git a/apps/clubs/src/clubs-db.ts b/apps/clubs/src/clubs-db.ts index fa3e92f..d18c05c 100644 --- a/apps/clubs/src/clubs-db.ts +++ b/apps/clubs/src/clubs-db.ts @@ -14,6 +14,10 @@ * can build the tables directly. */ +import { getSavedImagesByNames, placeholderSavedImage } from '@repo/domain' + +import type { SavedImage } from '@repo/domain' + /** Schema DDL (mirror of migrations/0001_club.sql, sans seed rows). */ export const SCHEMA_DDL: string[] = [ `CREATE TABLE IF NOT EXISTS club ( @@ -125,8 +129,17 @@ export interface Club { interface StoredClub extends Club { CreatedAt: string CustomTags?: string[] + /** + * The club's gallery images, by slot (the client PUTs to + * `/additionalimage/{index}`). Positional, so a cleared middle slot stays as an + * empty string rather than shifting the images after it. + */ + AdditionalImages?: string[] } +/** How many gallery images a club has room for (slots 0..2). */ +export const MAX_ADDITIONAL_IMAGES = 3 + interface ClubRow { data: string } @@ -307,7 +320,12 @@ function clubPermission( /** The club-details payload the client reads from create/details. */ export interface ClubDetails { - AdditionalImages: unknown[] + /** + * The club's gallery images as whole image records — the same `SavedImage` shape + * every other image on the site is served as. The client deserializes these into + * objects, so a bare array of names fails its parser ("expected '{'"). + */ + AdditionalImages: SavedImage[] Club: Club ClubId: number CoownerPermissions: ClubPermission @@ -319,8 +337,9 @@ export interface ClubDetails { /** * Build the club-details view for a caller. `MyMembershipType` is the caller's own - * membership (0 = none, e.g. a signed-out viewer). Additional images have no storage - * yet, so they're empty; custom tags come from the club (set via `modifydetails`). + * membership (0 = none, e.g. a signed-out viewer). Additional images (set via + * `/additionalimage/{index}`) and custom tags (set via `modifydetails`) both come off + * the club's blob. */ export async function getClubDetails( db: D1Database, @@ -328,7 +347,7 @@ export async function getClubDetails( accountId: number | null ): Promise { return { - AdditionalImages: [], + AdditionalImages: await getClubGallery(db, club.ClubId), Club: club, ClubId: club.ClubId, CoownerPermissions: clubPermission(club.ClubId, ClubMembershipType.Coowner, { @@ -654,6 +673,67 @@ function dedupeTags(tags: string[]): string[] { return out } +/** + * A club's gallery images (stored on the blob). Trailing empty slots are trimmed, so + * a club with nothing set reads as `[]` while a club with only slot 1 filled still + * reports `['', 'name.jpg']` — the index a client PUT to is the index it reads back. + */ +export async function getClubAdditionalImages(db: D1Database, clubId: number): Promise { + const row = await db + .prepare('SELECT data FROM club WHERE club_id = ?1') + .bind(clubId) + .first() + const images = row === null ? [] : ((JSON.parse(row.data) as StoredClub).AdditionalImages ?? []) + let end = images.length + while (end > 0 && images[end - 1] === '') end-- + return images.slice(0, end) +} + +/** + * A club's gallery as the client reads it: the image record behind each filled slot, + * in slot order. Empty slots are left out (the records carry no index, so a hole + * would just be a blank image), and a name whose metadata row is missing falls back + * to a placeholder record so the picture still renders. + */ +export async function getClubGallery(db: D1Database, clubId: number): Promise { + const names = (await getClubAdditionalImages(db, clubId)).filter((n) => n !== '') + if (names.length === 0) return [] + const records = await getSavedImagesByNames(db, names) + return names.map((name) => records.get(name) ?? placeholderSavedImage(name)) +} + +/** + * Set (or clear, with an empty `imageName`) one of a club's gallery image slots. + * Returns null when the club doesn't exist. The slot must be in range — callers + * validate the index before getting here. + */ +export async function setClubAdditionalImage( + db: D1Database, + clubId: number, + index: number, + imageName: string +): Promise { + const row = await db + .prepare('SELECT data FROM club WHERE club_id = ?1') + .bind(clubId) + .first() + if (row === null) return null + const stored = JSON.parse(row.data) as StoredClub + + // Pad rather than assign past the end: a sparse array would serialize its holes as + // nulls, and the client expects strings in every slot it reads. + const images = [...(stored.AdditionalImages ?? [])] + while (images.length <= index) images.push('') + images[index] = imageName + + const updated: StoredClub = { ...stored, AdditionalImages: images } + await db + .prepare('UPDATE club SET data = ?1 WHERE club_id = ?2') + .bind(JSON.stringify(updated), clubId) + .run() + return toDto(updated) +} + /** A club's custom tags (stored on the blob; empty when it has none). */ export async function getClubCustomTags(db: D1Database, clubId: number): Promise { const row = await db diff --git a/apps/clubs/src/clubs.app.ts b/apps/clubs/src/clubs.app.ts index 778ee0d..ac0a802 100644 --- a/apps/clubs/src/clubs.app.ts +++ b/apps/clubs/src/clubs.app.ts @@ -22,8 +22,10 @@ import { getMembership, joinClub, leaveClub, + MAX_ADDITIONAL_IMAGES, requestToJoinClub, searchClubs, + setClubAdditionalImage, setHomeClub, updateClub, } from './clubs-db' @@ -572,6 +574,42 @@ const app = new Hono() }) }) + // One of the club's gallery images, by slot (`/additionalimage/{index}`, 0-based — + // the client PUTs the first image to 0, the second to 1). Takes the same + // `imageName` the `storage` worker handed back; an empty one clears that slot. + // Co-owner or above, like the main image. The slots are positional, so clearing + // one doesn't shift the others; they come back on `value.AdditionalImages`. + .put('/club/:clubId{[0-9]+}/additionalimage/:index{[0-9]+}', async (c) => { + const id = await authedId(c) + if (id === null) return c.body(null, 401) + + const clubId = Number.parseInt(c.req.param('clubId'), 10) + const club = await getClub(c.env.DB, clubId) + if (club === null) return c.notFound() + + const membership = await getMembership(c.env.DB, clubId, id) + if (membership < ClubMembershipType.Coowner) { + return c.json({ error: 'Insufficient permissions.', success: false, value: null }, 403) + } + + const index = Number.parseInt(c.req.param('index'), 10) + if (index >= MAX_ADDITIONAL_IMAGES) { + return clubError(c, `A club has ${MAX_ADDITIONAL_IMAGES} additional image slots (0-based).`) + } + + const body = (await c.req.parseBody().catch(() => ({}))) as Record + const key = Object.keys(body).find((k) => k.toLowerCase() === 'imagename') + const imageName = typeof body[key ?? ''] === 'string' ? (body[key ?? ''] as string).trim() : '' + + const updated = await setClubAdditionalImage(c.env.DB, clubId, index, imageName) + if (updated === null) return c.notFound() + return c.json({ + error: '', + success: true, + value: await getClubDetails(c.env.DB, updated, id), + }) + }) + // A single club by id. 404 when the club isn't in the DB. Public. .get('/club/:clubId{[0-9]+}', async (c) => { const club = await getClub(c.env.DB, Number.parseInt(c.req.param('clubId'), 10)) diff --git a/apps/clubs/src/test/integration/api.test.ts b/apps/clubs/src/test/integration/api.test.ts index 234253f..87e39a7 100644 --- a/apps/clubs/src/test/integration/api.test.ts +++ b/apps/clubs/src/test/integration/api.test.ts @@ -28,6 +28,16 @@ beforeAll(async () => { account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.accountId')) VIRTUAL )` ).run() + // Image metadata (owned by the img worker, written by api on upload) — a club's + // gallery serves the whole image record behind each stored image name. + await env.DB.prepare( + `CREATE TABLE IF NOT EXISTS image ( + data TEXT NOT NULL, + id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.Id')) VIRTUAL, + image_name TEXT GENERATED ALWAYS AS (json_extract(data, '$.ImageName')) VIRTUAL + )` + ).run() + const insertAccount = env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)') await env.DB.batch( [42, 9100, 9101].map((accountId) => @@ -567,6 +577,104 @@ describe('clubs endpoints', () => { expect(anon.status).toBe(401) }) + test('PUT /club/:id/additionalimage/:index fills the club’s gallery slots', async () => { + type Image = { Id: number; ImageName: string; PlayerId: number } + type Details = { error: string; success: boolean; value: { AdditionalImages: Image[] } } + // The gallery is served as whole image records, joined from the image table the + // `api` worker writes on upload. Seed the rows those names point at. + const first = 'sharecamera/2026-07-21/e37fc41f-005e-4216-8f1e-a37dca953981.jpg' + const insertImage = env.DB.prepare('INSERT OR IGNORE INTO image (data) VALUES (?1)') + await env.DB.batch( + [first, 'b.jpg', 'c.jpg'].map((ImageName, i) => + insertImage.bind( + JSON.stringify({ + Id: 500 + i, + Type: 1, + Accessibility: 1, + AccessibilityLocked: false, + ImageName, + Description: null, + PlayerId: 7100, + TaggedPlayerIds: [], + RoomId: null, + PlayerEventId: null, + CreatedAt: '2026-07-21T00:00:00Z', + CheerCount: 0, + CommentCount: 0, + }) + ) + ) + ) + const create = await exports.default.fetch(`${ORIGIN}/club/create`, { + method: 'POST', + headers: { ...(await bearer('7100')), 'Content-Type': 'application/x-www-form-urlencoded' }, + body: 'name=Gallery', + }) + const clubId = ((await create.json()) as { value: { ClubId: number } }).value.ClubId + const setImage = async (index: number, imageName: string, sub = '7100') => + exports.default.fetch(`${ORIGIN}/club/${clubId}/additionalimage/${index}`, { + method: 'PUT', + headers: { ...(await bearer(sub)), 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ imageName }).toString(), + }) + + const names = (images: Image[]) => images.map((i) => i.ImageName) + + // A fresh club has no gallery images. + const fresh = (await ( + await exports.default.fetch(`${ORIGIN}/club/${clubId}/details`) + ).json()) as { AdditionalImages: Image[] } + expect(fresh.AdditionalImages).toEqual([]) + + // The client's exact request: the storage worker's image name into slot 0. It + // comes back as the whole image record, not a bare name. + const set = (await (await setImage(0, first)).json()) as Details + expect(set).toMatchObject({ error: '', success: true }) + expect(set.value.AdditionalImages).toEqual([ + expect.objectContaining({ Id: 500, ImageName: first, PlayerId: 7100 }), + ]) + + // Slots are positional: filling 2 while 1 is empty keeps them in that order, and + // the empty slot isn't served as a blank image. + const third = (await (await setImage(2, 'c.jpg')).json()) as Details + expect(names(third.value.AdditionalImages)).toEqual([first, 'c.jpg']) + const second = (await (await setImage(1, 'b.jpg')).json()) as Details + expect(names(second.value.AdditionalImages)).toEqual([first, 'b.jpg', 'c.jpg']) + + // Re-PUTting a slot replaces just that image; an empty name clears it. A name with + // no image row still renders, as a placeholder record. + const replaced = (await (await setImage(0, 'a2.jpg')).json()) as Details + expect(names(replaced.value.AdditionalImages)).toEqual(['a2.jpg', 'b.jpg', 'c.jpg']) + expect(replaced.value.AdditionalImages[0]).toMatchObject({ Id: 0, ImageName: 'a2.jpg' }) + const cleared = (await (await setImage(1, '')).json()) as Details + expect(names(cleared.value.AdditionalImages)).toEqual(['a2.jpg', 'c.jpg']) + + // They're on the club's details payload, for everyone reading the club. + const details = (await ( + await exports.default.fetch(`${ORIGIN}/club/${clubId}/details`) + ).json()) as { AdditionalImages: Image[] } + expect(names(details.AdditionalImages)).toEqual(['a2.jpg', 'c.jpg']) + + // There are only three slots, and only co-owners may set them. + expect((await setImage(3, 'd.jpg')).status).toBe(400) + expect((await setImage(0, 'hijack.jpg', '7101')).status).toBe(403) + expect( + ( + await exports.default.fetch(`${ORIGIN}/club/${clubId}/additionalimage/0`, { + method: 'PUT', + }) + ).status + ).toBe(401) + expect( + ( + await exports.default.fetch(`${ORIGIN}/club/99999/additionalimage/0`, { + method: 'PUT', + headers: await bearer('7100'), + }) + ).status + ).toBe(404) + }) + test('GET /club/search filters by category/query and sorts', async () => { type Result = { Clubs: Array<{ ClubId: number; Name: string; Category: string }> diff --git a/packages/domain/src/images-db.ts b/packages/domain/src/images-db.ts new file mode 100644 index 0000000..4adbc83 --- /dev/null +++ b/packages/domain/src/images-db.ts @@ -0,0 +1,74 @@ +/** + * Cross-worker *reads* of the image-metadata table. The `img` worker owns the schema + * and the `api` worker handles uploads and writes (see apps/api/src/images-db.ts); + * this is the read-only view other workers need when they store an image *name* but + * have to serve the client the whole image record — the client deserializes those + * into its `SavedImage` type, not into strings. + * + * Right now that's `clubs`, for a club's gallery images. + */ + +/** A stored image record (the client-facing SavedImage shape). */ +export interface SavedImage { + Id: number + Type: number + Accessibility: number + AccessibilityLocked: boolean + ImageName: string + Description: string | null + PlayerId: number + TaggedPlayerIds: number[] + RoomId: number | null + PlayerEventId: number | null + CreatedAt: string + CheerCount: number + CommentCount: number +} + +/** Build the `?1,?2,…` placeholder list for an `IN (…)` clause. */ +const placeholders = (n: number): string => + Array.from({ length: n }, (_, i) => `?${i + 1}`).join(',') + +/** + * Look up image records by name (the R2 key), returned keyed by ImageName. One query + * for the whole set; names with no record are simply absent from the map. + */ +export async function getSavedImagesByNames( + db: D1Database, + names: string[] +): Promise> { + if (names.length === 0) return new Map() + const { results } = await db + .prepare(`SELECT data FROM image WHERE image_name IN (${placeholders(names.length)})`) + .bind(...names) + .all<{ data: string }>() + return new Map( + results.map((r) => { + const image = JSON.parse(r.data) as SavedImage + return [image.ImageName, image] + }) + ) +} + +/** + * A minimal SavedImage for an image name with no metadata row — enough for the client + * to render the picture. Uploads normally write a row first, so this only covers a + * name that was set directly (or whose row was since deleted). + */ +export function placeholderSavedImage(imageName: string): SavedImage { + return { + Id: 0, + Type: 1, + Accessibility: 1, + AccessibilityLocked: false, + ImageName: imageName, + Description: null, + PlayerId: 0, + TaggedPlayerIds: [], + RoomId: null, + PlayerEventId: null, + CreatedAt: new Date(0).toISOString(), + CheerCount: 0, + CommentCount: 0, + } +} diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts index 40dbd36..f249829 100644 --- a/packages/domain/src/index.ts +++ b/packages/domain/src/index.ts @@ -1,6 +1,7 @@ export { RoomInstanceType, Accessibility, Role } from './enums' export * from './accounts-db' export * from './clubs-db' +export * from './images-db' export * from './password' export * from './rooms-db' export * from './room-instance-db' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 209d888..59319a5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -262,6 +262,9 @@ importers: apps/clubs: dependencies: + '@repo/domain': + specifier: workspace:* + version: link:../../packages/domain '@repo/hono-helpers': specifier: workspace:* version: link:../../packages/hono-helpers