mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
fix some custom room authoring issues
This commit is contained in:
@@ -21,6 +21,19 @@ export const SCHEMA_DDL: string[] = [
|
||||
`CREATE INDEX IF NOT EXISTS idx_image_image_name ON image (image_name)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_image_player_id ON image (player_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_image_room_id ON image (room_id)`,
|
||||
// A player's interaction with a saved image — one row per (player, image). Only
|
||||
// `cheered` for now; named generically so other per-user interactions (e.g.
|
||||
// favorited) can be added as columns. This worker writes it (cheer endpoints) and
|
||||
// keeps the image's denormalized `CheerCount` in sync from it. Schema owned by the
|
||||
// `img` worker (migrations/0002_image_interaction.sql) — keep in sync.
|
||||
`CREATE TABLE IF NOT EXISTS image_interaction (
|
||||
player_id INTEGER NOT NULL,
|
||||
saved_image_id INTEGER NOT NULL,
|
||||
cheered INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT,
|
||||
PRIMARY KEY (player_id, saved_image_id)
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_image_interaction_image ON image_interaction (saved_image_id)`,
|
||||
]
|
||||
|
||||
/** A stored image record (the client-facing SavedImage shape). */
|
||||
@@ -81,6 +94,68 @@ export async function createImage(db: D1Database, input: NewImage): Promise<Save
|
||||
return image
|
||||
}
|
||||
|
||||
/**
|
||||
* Recompute an image's `CheerCount` from the `image_interaction` rows and write it
|
||||
* back into the blob (nothing reads a generated column for it, but the client-facing
|
||||
* blob must stay accurate). CAST to INTEGER: D1 binds a JS number as a SQLite REAL,
|
||||
* which json_set would otherwise store as `"CheerCount":3.0`. Returns the fresh count.
|
||||
*/
|
||||
async function syncImageCheerCount(db: D1Database, savedImageId: number): Promise<number> {
|
||||
const row = await db
|
||||
.prepare('SELECT COUNT(*) AS n FROM image_interaction WHERE saved_image_id = ?1 AND cheered = 1')
|
||||
.bind(savedImageId)
|
||||
.first<{ n: number }>()
|
||||
const count = row?.n ?? 0
|
||||
await db
|
||||
.prepare("UPDATE image SET data = json_set(data, '$.CheerCount', CAST(?2 AS INTEGER)) WHERE id = ?1")
|
||||
.bind(savedImageId, count)
|
||||
.run()
|
||||
return count
|
||||
}
|
||||
|
||||
/**
|
||||
* Set (or clear) a player's cheer on a saved image — upserts the one row per
|
||||
* (player, image) — then resyncs the image's `CheerCount`. Idempotent: re-cheering
|
||||
* an already-cheered image is a no-op on the count.
|
||||
*/
|
||||
export async function setImageCheer(
|
||||
db: D1Database,
|
||||
playerId: number,
|
||||
savedImageId: number,
|
||||
cheer: boolean
|
||||
): Promise<void> {
|
||||
await db
|
||||
.prepare(
|
||||
`INSERT INTO image_interaction (player_id, saved_image_id, cheered, created_at)
|
||||
VALUES (?1, ?2, ?3, ?4)
|
||||
ON CONFLICT(player_id, saved_image_id) DO UPDATE SET cheered = ?3`
|
||||
)
|
||||
.bind(playerId, savedImageId, cheer ? 1 : 0, new Date().toISOString())
|
||||
.run()
|
||||
await syncImageCheerCount(db, savedImageId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Which of the given saved-image ids the player has cheered — the set of cheered
|
||||
* ids (a subset of `ids`). Backs the bulk `cheered` lookup. Empty input → empty set.
|
||||
*/
|
||||
export async function getCheeredImageIds(
|
||||
db: D1Database,
|
||||
playerId: number,
|
||||
ids: number[]
|
||||
): Promise<Set<number>> {
|
||||
if (ids.length === 0) return new Set()
|
||||
const inList = ids.map((_, i) => `?${i + 2}`).join(',')
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT saved_image_id AS id FROM image_interaction
|
||||
WHERE player_id = ?1 AND cheered = 1 AND saved_image_id IN (${inList})`
|
||||
)
|
||||
.bind(playerId, ...ids)
|
||||
.all<{ id: number }>()
|
||||
return new Set(results.map((r) => r.id))
|
||||
}
|
||||
|
||||
/** Look up an image record by its ImageName (the R2 key / filename), or null. */
|
||||
export async function getImageByName(db: D1Database, name: string): Promise<SavedImage | null> {
|
||||
const row = await db
|
||||
|
||||
@@ -2,11 +2,13 @@ import { Hono } from 'hono'
|
||||
|
||||
import {
|
||||
createImage,
|
||||
getCheeredImageIds,
|
||||
getImageByName,
|
||||
getImagesByPlayer,
|
||||
getImagesByRoom,
|
||||
getPlayerFeed,
|
||||
getSlideshowImages,
|
||||
setImageCheer,
|
||||
} from '../images-db'
|
||||
import { authedId, unauthorized } from '../http'
|
||||
|
||||
@@ -169,12 +171,33 @@ export const imageRoutes = new Hono<App>({ strict: false })
|
||||
return image ? c.json(image) : c.notFound()
|
||||
})
|
||||
|
||||
// Cheer / un-cheer a saved image ({ SavedImageId, Cheer }). Auth-gated. Stubbed
|
||||
// for now — accepted but not persisted; cheer storage is still TBD.
|
||||
// Cheer / un-cheer a saved image ({ SavedImageId, Cheer }). Auth-gated. Persists the
|
||||
// caller's cheer to `image_interaction` and resyncs the image's CheerCount.
|
||||
.post('/api/images/v1/cheer', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
// TODO: record the cheer against the image once cheer storage is designed.
|
||||
await c.req.json().catch(() => null)
|
||||
const body = (await c.req.json().catch(() => null)) as {
|
||||
SavedImageId?: number
|
||||
Cheer?: boolean
|
||||
} | null
|
||||
if (body && typeof body.SavedImageId === 'number') {
|
||||
await setImageCheer(c.env.DB, id, body.SavedImageId, body.Cheer === true)
|
||||
}
|
||||
return c.json({ success: true })
|
||||
})
|
||||
|
||||
// Whether the caller has cheered each of the given saved-image ids (`?id=55&id=54`,
|
||||
// and each `id` may itself be a comma-separated list). Auth-gated. Returns one
|
||||
// `{ SavedImageId, IsCheered }` per requested id, in order.
|
||||
.get('/api/images/v5/cheered/bulk', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const ids =
|
||||
c.req
|
||||
.queries('id')
|
||||
?.flatMap((raw) => raw.split(','))
|
||||
.map((raw) => Number.parseInt(raw.trim(), 10))
|
||||
.filter((imageId) => !Number.isNaN(imageId)) ?? []
|
||||
const cheered = await getCheeredImageIds(c.env.DB, id, ids)
|
||||
return c.json(ids.map((imageId) => ({ SavedImageId: imageId, IsCheered: cheered.has(imageId) })))
|
||||
})
|
||||
|
||||
@@ -6,7 +6,7 @@ import '../../api.app'
|
||||
|
||||
import { createGift, getPendingGifts, RECEIVED_GIFT_SCHEMA_DDL } from '@repo/domain'
|
||||
|
||||
import { SCHEMA_DDL as IMAGES_SCHEMA_DDL } from '../../images-db'
|
||||
import { createImage, SCHEMA_DDL as IMAGES_SCHEMA_DDL } from '../../images-db'
|
||||
import { SCHEMA_DDL as INVENTIONS_SCHEMA_DDL } from '../../inventions-db'
|
||||
import { SCHEMA_DDL as RELATIONSHIPS_SCHEMA_DDL } from '../../relationships-db'
|
||||
|
||||
@@ -1203,26 +1203,92 @@ describe('images', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test('POST /api/images/v1/cheer is auth-gated and stubs success', async () => {
|
||||
const body = JSON.stringify({ SavedImageId: 2, Cheer: true })
|
||||
test('POST /api/images/v1/cheer persists, syncs CheerCount, and the bulk lookup reflects it', async () => {
|
||||
// Seed an image to cheer.
|
||||
const img = await createImage(env.DB, { imageName: 'cheerme.jpg', playerId: 700 })
|
||||
const cheerBody = JSON.stringify({ SavedImageId: img.Id, Cheer: true })
|
||||
|
||||
// No token → 401.
|
||||
expect(
|
||||
(
|
||||
await exports.default.fetch(`${ORIGIN}/api/images/v1/cheer`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body,
|
||||
body: cheerBody,
|
||||
})
|
||||
).status
|
||||
).toBe(401)
|
||||
// With a token → accepted.
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/images/v1/cheer`, {
|
||||
|
||||
const cheer = async (cheerVal: boolean, sub = '42') =>
|
||||
exports.default.fetch(`${ORIGIN}/api/images/v1/cheer`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer(sub)), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ SavedImageId: img.Id, Cheer: cheerVal }),
|
||||
})
|
||||
const cheerCount = async (): Promise<number> => {
|
||||
const row = await env.DB.prepare('SELECT data FROM image WHERE id = ?1')
|
||||
.bind(img.Id)
|
||||
.first<{ data: string }>()
|
||||
return (JSON.parse(row!.data) as { CheerCount: number }).CheerCount
|
||||
}
|
||||
|
||||
// Account 42 cheers → CheerCount syncs to 1 (a real integer, not 1.0).
|
||||
expect((await cheer(true)).status).toBe(200)
|
||||
const rawAfter = await env.DB.prepare('SELECT data FROM image WHERE id = ?1')
|
||||
.bind(img.Id)
|
||||
.first<{ data: string }>()
|
||||
expect(rawAfter!.data).toContain('"CheerCount":1')
|
||||
expect(rawAfter!.data).not.toContain('"CheerCount":1.0')
|
||||
expect(await cheerCount()).toBe(1)
|
||||
|
||||
// Re-cheering is idempotent on the count.
|
||||
await cheer(true)
|
||||
expect(await cheerCount()).toBe(1)
|
||||
|
||||
// Un-cheer → count back to 0.
|
||||
await cheer(false)
|
||||
expect(await cheerCount()).toBe(0)
|
||||
})
|
||||
|
||||
test('GET /api/images/v5/cheered/bulk reports per-id cheer state for the caller (auth-gated)', async () => {
|
||||
const img = await createImage(env.DB, { imageName: 'bulkcheer.jpg', playerId: 701 })
|
||||
const other = 999999
|
||||
|
||||
// No token → 401.
|
||||
expect(
|
||||
(await exports.default.fetch(`${ORIGIN}/api/images/v5/cheered/bulk?id=${img.Id}`)).status
|
||||
).toBe(401)
|
||||
|
||||
const bulk = async (sub: string) =>
|
||||
(await (
|
||||
await exports.default.fetch(
|
||||
`${ORIGIN}/api/images/v5/cheered/bulk?id=${img.Id}&id=${other}`,
|
||||
{ headers: await bearer(sub) }
|
||||
)
|
||||
).json()) as Array<{ SavedImageId: number; IsCheered: boolean }>
|
||||
|
||||
// Before cheering: one entry per requested id, in order, all false.
|
||||
expect(await bulk('42')).toEqual([
|
||||
{ SavedImageId: img.Id, IsCheered: false },
|
||||
{ SavedImageId: other, IsCheered: false },
|
||||
])
|
||||
|
||||
// Account 42 cheers the image.
|
||||
await exports.default.fetch(`${ORIGIN}/api/images/v1/cheer`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer()), 'Content-Type': 'application/json' },
|
||||
body,
|
||||
headers: { ...(await bearer('42')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ SavedImageId: img.Id, Cheer: true }),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ success: true })
|
||||
|
||||
// The cheerer sees it cheered; a different player does not.
|
||||
expect((await bulk('42')).find((x) => x.SavedImageId === img.Id)?.IsCheered).toBe(true)
|
||||
expect((await bulk('43')).find((x) => x.SavedImageId === img.Id)?.IsCheered).toBe(false)
|
||||
|
||||
// No ids → empty array.
|
||||
const empty = await exports.default.fetch(`${ORIGIN}/api/images/v5/cheered/bulk`, {
|
||||
headers: await bearer('42'),
|
||||
})
|
||||
expect(await empty.json()).toEqual([])
|
||||
})
|
||||
|
||||
test('GET /api/images/v6 400s without a name and 404s for an unknown one', async () => {
|
||||
|
||||
Reference in New Issue
Block a user