diff --git a/apps/api/migrations/0018_invention_interaction.sql b/apps/api/migrations/0018_invention_interaction.sql new file mode 100644 index 0000000..3a6b369 --- /dev/null +++ b/apps/api/migrations/0018_invention_interaction.sql @@ -0,0 +1,13 @@ +-- A player's interaction with an invention. One row per (player, invention); `cheered` +-- is toggled in place and the invention JSON's denormalized `CheerCount` is resynced +-- after every write. Generated from src/inventions-db.ts (SCHEMA_DDL) — keep in sync. + +CREATE TABLE IF NOT EXISTS invention_interaction ( + player_id INTEGER NOT NULL, + invention_id INTEGER NOT NULL, + cheered INTEGER NOT NULL DEFAULT 0, + created_at TEXT, + PRIMARY KEY (player_id, invention_id) +); +CREATE INDEX IF NOT EXISTS idx_invention_interaction_invention + ON invention_interaction (invention_id); diff --git a/apps/api/src/inventions-db.ts b/apps/api/src/inventions-db.ts index 21bf955..d3cf2d9 100644 --- a/apps/api/src/inventions-db.ts +++ b/apps/api/src/inventions-db.ts @@ -39,6 +39,15 @@ export const SCHEMA_DDL: string[] = [ `CREATE UNIQUE INDEX IF NOT EXISTS idx_invention_id ON invention (id)`, `CREATE INDEX IF NOT EXISTS idx_invention_creator ON invention (creator_player_id)`, `CREATE INDEX IF NOT EXISTS idx_invention_featured ON invention (is_featured)`, + `CREATE TABLE IF NOT EXISTS invention_interaction ( + player_id INTEGER NOT NULL, + invention_id INTEGER NOT NULL, + cheered INTEGER NOT NULL DEFAULT 0, + created_at TEXT, + PRIMARY KEY (player_id, invention_id) + )`, + `CREATE INDEX IF NOT EXISTS idx_invention_interaction_invention + ON invention_interaction (invention_id)`, ] /** A single saved version of an invention (Rec Room's `RRInventionVersion`). */ @@ -1014,10 +1023,65 @@ export async function deleteInvention( ): Promise { const invention = await getInventionById(db, inventionId) if (invention === null) return null - await db.prepare('DELETE FROM invention WHERE id = ?1').bind(inventionId).run() + await db.batch([ + db.prepare('DELETE FROM invention WHERE id = ?1').bind(inventionId), + db.prepare('DELETE FROM invention_interaction WHERE invention_id = ?1').bind(inventionId), + ]) return invention } +/** + * Set or clear one player's cheer on an invention and resync the invention's denormalized + * `CheerCount`. Repeating either state is idempotent because the interaction row is keyed by + * `(player_id, invention_id)` and the public count is always derived from those rows. + */ +export async function setInventionCheer( + db: D1Database, + playerId: number, + inventionId: number, + cheer: boolean +): Promise { + await db + .prepare( + `INSERT INTO invention_interaction (player_id, invention_id, cheered, created_at) + VALUES (?1, ?2, ?3, ?4) + ON CONFLICT(player_id, invention_id) DO UPDATE SET cheered = ?3` + ) + .bind(playerId, inventionId, cheer ? 1 : 0, new Date().toISOString()) + .run() + + const row = await db + .prepare( + 'SELECT COUNT(*) AS n FROM invention_interaction WHERE invention_id = ?1 AND cheered = 1' + ) + .bind(inventionId) + .first<{ n: number }>() + const count = row?.n ?? 0 + await db + .prepare( + "UPDATE invention SET data = json_set(data, '$.CheerCount', CAST(?2 AS INTEGER)) WHERE id = ?1" + ) + .bind(inventionId, count) + .run() + return count +} + +/** Whether one player currently cheers an invention. */ +export async function isInventionCheered( + db: D1Database, + playerId: number, + inventionId: number +): Promise { + const row = await db + .prepare( + `SELECT 1 AS found FROM invention_interaction + WHERE player_id = ?1 AND invention_id = ?2 AND cheered = 1` + ) + .bind(playerId, inventionId) + .first<{ found: number }>() + return row !== null +} + /** * What `v2/delete` answers: the same `{ Value, Success, Error, error_id }` envelope the * other newer-client invention routes use, with `Value` always NULL — the invention is diff --git a/apps/api/src/openapi.ts b/apps/api/src/openapi.ts index 2d7eca6..f670782 100644 --- a/apps/api/src/openapi.ts +++ b/apps/api/src/openapi.ts @@ -598,7 +598,13 @@ export const InventionDetails = z.object({ Tags: z.array(InventionTagDto) }) /** `GET /api/inventions/v1/personaldetails/:id` — the caller's own relation to it. */ export const InventionPersonalDetails = z.object({ - IsCheering: z.boolean().describe('Always false — nothing can cheer an invention yet'), + IsCheering: z.boolean().describe('Whether the caller currently cheers this invention'), +}) + +/** `POST /api/inventions/v1/cheer` JSON body. */ +export const InventionCheerRequest = z.object({ + InventionId: z.int().describe('The invention whose cheer state is changing'), + Cheer: z.boolean().describe('True to cheer; false to remove the cheer'), }) /** `POST /api/inventions/v1/settags` JSON body — both lists are replaced wholesale. */ diff --git a/apps/api/src/routes/avatar.ts b/apps/api/src/routes/avatar.ts index e30b6fc..f476d0b 100644 --- a/apps/api/src/routes/avatar.ts +++ b/apps/api/src/routes/avatar.ts @@ -41,11 +41,13 @@ import { INVENTION_TAG_RESULT, inventionDeleteResult, inventionSaveV9Failure, + isInventionCheered, normalizeInventionTags, ownsAllInventions, parsePermissionLevel, publishInvention, searchInventions, + setInventionCheer, setInventionPrice, setInventionTags, toSaveResult, @@ -69,6 +71,7 @@ import { GenerateGiftRequest, idParam, intQuery, + InventionCheerRequest, InventionDeleteResult, InventionDetails, InventionDto, @@ -1219,23 +1222,26 @@ export const avatarRoutes = new Hono({ strict: false }) } ) - // The signed-in player's own relationship to an invention (`/personaldetails/2`) - // — just whether they're cheering it. We store no cheers (nothing can cheer an - // invention yet), so this is always false; it stays a 200 for signed-out callers - // too, since the client only reads the flag. + // The signed-in player's own relationship to an invention (`/personaldetails/2`) — + // just whether they're cheering it. Signed-out callers read false: there is no player + // whose interaction could be looked up, and the client still needs a flag to render. .get( '/api/inventions/v1/personaldetails/:inventionId{[0-9]+}', describeRoute({ tags: ['Inventions'], summary: 'The caller’s own relation to an invention', description: - 'Just whether the caller is cheering it. We store no cheers, so it is always false ' + - '— and this stays a 200 for signed-out callers too, since the client only reads the ' + - 'flag.', + 'Whether the caller is cheering this invention. Signed-out callers receive false, ' + + 'since there is no player interaction to look up.', parameters: [idParam('inventionId', 'Invention id')], - responses: { 200: json(InventionPersonalDetails, 'Always not cheering') }, + responses: { 200: json(InventionPersonalDetails, 'The caller’s cheer state') }, }), - (c) => c.json({ IsCheering: false }) + async (c) => { + const playerId = await authedId(c) + if (playerId === null) return c.json({ IsCheering: false }) + const inventionId = Number.parseInt(c.req.param('inventionId'), 10) + return c.json({ IsCheering: await isInventionCheered(c.env.DB, playerId, inventionId) }) + } ) // A single version of an invention (`?inventionId=…&version=…`) — the bare @@ -1751,6 +1757,42 @@ export const avatarRoutes = new Hono({ strict: false }) } ) + // Cheer or un-cheer an invention. The interaction row is per player and the stored + // invention's public CheerCount is derived from all active cheers. + .post( + '/api/inventions/v1/cheer', + describeRoute({ + tags: ['Inventions'], + summary: 'Cheer or un-cheer an invention', + description: + 'Persists the caller’s cheer state and resyncs the invention’s `CheerCount`. ' + + 'Repeating the same state is idempotent.', + security: AUTHED, + requestBody: jsonBody(InventionCheerRequest, 'The invention and new cheer state'), + responses: { + 200: json(SuccessErrorEnvelope, '`{ success: true, error: "" }`'), + 400: json(SuccessErrorEnvelope, 'Invalid body'), + 401: UNAUTHORIZED_RESPONSE, + 404: json(SuccessErrorEnvelope, 'No such invention'), + }, + }), + async (c) => { + const playerId = await authedId(c) + if (playerId === null) return unauthorized(c) + const body = await c.req + .json<{ InventionId?: unknown; Cheer?: unknown }>() + .catch(() => ({}) as Record) + const inventionId = Number(body.InventionId) + if (!Number.isInteger(inventionId) || typeof body.Cheer !== 'boolean') { + return c.json({ success: false, error: 'InventionId and Cheer are required' }, 400) + } + if ((await getInventionById(c.env.DB, inventionId)) === null) { + return c.json({ success: false, error: 'No such invention' }, 404) + } + await setInventionCheer(c.env.DB, playerId, inventionId, body.Cheer) + return c.json({ success: true, error: '' }) + } + ) // Report an invention. Stored in the `report` table the player and event reports use — // same fields, same moderation life — with `invention_id` set. See // migrations/0016_report_invention.sql. diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index 040bce9..305acc0 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -3224,17 +3224,71 @@ describe('public endpoints', () => { expect(noId.status).toBe(400) }) - test('GET /api/inventions/v1/personaldetails/:id reports the cheer flag', async () => { - // No cheer storage yet, so nobody is ever cheering — signed in or not. - const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/personaldetails/2`, { - headers: await bearer('42'), + test('POST /api/inventions/v1/cheer persists and personaldetails reflects it', async () => { + const saved = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, { + method: 'POST', + headers: { ...(await bearer('8200')), 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Cheerable Lamp', inventionDataFilename: 'cheerable.inv' }), }) - expect(res.status).toBe(200) - expect(await res.json()).toEqual({ IsCheering: false }) + const invention = ((await saved.json()) as InventionSaveResult).Invention + const path = `${ORIGIN}/api/inventions/v1/cheer` + const cheer = async (value: boolean, sub = '42') => + exports.default.fetch(path, { + method: 'POST', + headers: { ...(await bearer(sub)), 'Content-Type': 'application/json' }, + body: JSON.stringify({ InventionId: invention.InventionId, Cheer: value }), + }) + const personal = async (sub?: string) => + exports.default.fetch( + `${ORIGIN}/api/inventions/v1/personaldetails/${invention.InventionId}`, + sub ? { headers: await bearer(sub) } : undefined + ) + const storedCount = async (): Promise => { + const row = await env.DB.prepare('SELECT data FROM invention WHERE id = ?1') + .bind(invention.InventionId) + .first<{ data: string }>() + return (JSON.parse(row!.data) as SavedInvention).CheerCount + } - const anon = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/personaldetails/2`) - expect(anon.status).toBe(200) - expect(await anon.json()).toEqual({ IsCheering: false }) + // The write requires a player; the read remains useful to signed-out callers. + expect( + ( + await exports.default.fetch(path, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ InventionId: invention.InventionId, Cheer: true }), + }) + ).status + ).toBe(401) + expect(await (await personal()).json()).toEqual({ IsCheering: false }) + + expect((await cheer(true)).status).toBe(200) + expect(await (await personal('42')).json()).toEqual({ IsCheering: true }) + expect(await storedCount()).toBe(1) + + // Repeating a state is idempotent, and a second player counts separately. + await cheer(true) + expect(await storedCount()).toBe(1) + await cheer(true, '43') + expect(await storedCount()).toBe(2) + + await cheer(false) + expect(await (await personal('42')).json()).toEqual({ IsCheering: false }) + expect(await (await personal('43')).json()).toEqual({ IsCheering: true }) + expect(await storedCount()).toBe(1) + + const unknown = await exports.default.fetch(path, { + method: 'POST', + headers: { ...(await bearer('42')), 'Content-Type': 'application/json' }, + body: JSON.stringify({ InventionId: 999999, Cheer: true }), + }) + expect(unknown.status).toBe(404) + const malformed = await exports.default.fetch(path, { + method: 'POST', + headers: { ...(await bearer('42')), 'Content-Type': 'application/json' }, + body: JSON.stringify({ InventionId: invention.InventionId, Cheer: 'yes' }), + }) + expect(malformed.status).toBe(400) }) test('GET /api/inventions/v1/version serves the version; unknown versions 404', async () => { @@ -7665,6 +7719,7 @@ describe('openapi', () => { 'POST /api/images/v1/cheer', 'POST /api/images/v4/uploadsaved', 'POST /api/images/v5/cheered/bulk', + 'POST /api/inventions/v1/cheer', 'POST /api/inventions/v1/report', 'POST /api/inventions/v1/settags', 'POST /api/inventions/v1/update',