club photos

This commit is contained in:
Devin Zuczek
2026-07-21 13:26:57 -04:00
parent 7f497fcee0
commit 430825d72e
7 changed files with 309 additions and 4 deletions
+1
View File
@@ -16,6 +16,7 @@
"test": "run-vitest"
},
"dependencies": {
"@repo/domain": "workspace:*",
"@repo/hono-helpers": "workspace:*",
"@repo/jwt": "workspace:*",
"hono": "4.12.27",
+84 -4
View File
@@ -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<ClubDetails> {
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<string[]> {
const row = await db
.prepare('SELECT data FROM club WHERE club_id = ?1')
.bind(clubId)
.first<ClubRow>()
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<SavedImage[]> {
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<Club | null> {
const row = await db
.prepare('SELECT data FROM club WHERE club_id = ?1')
.bind(clubId)
.first<ClubRow>()
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<string[]> {
const row = await db
+38
View File
@@ -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<App>()
})
})
// 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<string, unknown>
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))
+108
View File
@@ -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 clubs 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 }>
+74
View File
@@ -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<Map<string, SavedImage>> {
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,
}
}
+1
View File
@@ -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'
+3
View File
@@ -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