mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 06:31:27 -07:00
club photos
This commit is contained in:
@@ -16,6 +16,7 @@
|
||||
"test": "run-vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@repo/domain": "workspace:*",
|
||||
"@repo/hono-helpers": "workspace:*",
|
||||
"@repo/jwt": "workspace:*",
|
||||
"hono": "4.12.27",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 }>
|
||||
|
||||
@@ -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,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'
|
||||
|
||||
Generated
+3
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user