mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-10 07:31:27 -07:00
[api] implement invention cheers (#55)
Co-authored-by: Nexi (CWN) <communityshieldofficial@gmail.com>
This commit is contained in:
@@ -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);
|
||||||
@@ -39,6 +39,15 @@ export const SCHEMA_DDL: string[] = [
|
|||||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_invention_id ON invention (id)`,
|
`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_creator ON invention (creator_player_id)`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_invention_featured ON invention (is_featured)`,
|
`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`). */
|
/** A single saved version of an invention (Rec Room's `RRInventionVersion`). */
|
||||||
@@ -1014,10 +1023,65 @@ export async function deleteInvention(
|
|||||||
): Promise<SavedInvention | null> {
|
): Promise<SavedInvention | null> {
|
||||||
const invention = await getInventionById(db, inventionId)
|
const invention = await getInventionById(db, inventionId)
|
||||||
if (invention === null) return null
|
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
|
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<number> {
|
||||||
|
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<boolean> {
|
||||||
|
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
|
* 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
|
* other newer-client invention routes use, with `Value` always NULL — the invention is
|
||||||
|
|||||||
@@ -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. */
|
/** `GET /api/inventions/v1/personaldetails/:id` — the caller's own relation to it. */
|
||||||
export const InventionPersonalDetails = z.object({
|
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. */
|
/** `POST /api/inventions/v1/settags` JSON body — both lists are replaced wholesale. */
|
||||||
|
|||||||
@@ -41,11 +41,13 @@ import {
|
|||||||
INVENTION_TAG_RESULT,
|
INVENTION_TAG_RESULT,
|
||||||
inventionDeleteResult,
|
inventionDeleteResult,
|
||||||
inventionSaveV9Failure,
|
inventionSaveV9Failure,
|
||||||
|
isInventionCheered,
|
||||||
normalizeInventionTags,
|
normalizeInventionTags,
|
||||||
ownsAllInventions,
|
ownsAllInventions,
|
||||||
parsePermissionLevel,
|
parsePermissionLevel,
|
||||||
publishInvention,
|
publishInvention,
|
||||||
searchInventions,
|
searchInventions,
|
||||||
|
setInventionCheer,
|
||||||
setInventionPrice,
|
setInventionPrice,
|
||||||
setInventionTags,
|
setInventionTags,
|
||||||
toSaveResult,
|
toSaveResult,
|
||||||
@@ -69,6 +71,7 @@ import {
|
|||||||
GenerateGiftRequest,
|
GenerateGiftRequest,
|
||||||
idParam,
|
idParam,
|
||||||
intQuery,
|
intQuery,
|
||||||
|
InventionCheerRequest,
|
||||||
InventionDeleteResult,
|
InventionDeleteResult,
|
||||||
InventionDetails,
|
InventionDetails,
|
||||||
InventionDto,
|
InventionDto,
|
||||||
@@ -1219,23 +1222,26 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// The signed-in player's own relationship to an invention (`/personaldetails/2`)
|
// 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
|
// just whether they're cheering it. Signed-out callers read false: there is no player
|
||||||
// invention yet), so this is always false; it stays a 200 for signed-out callers
|
// whose interaction could be looked up, and the client still needs a flag to render.
|
||||||
// too, since the client only reads the flag.
|
|
||||||
.get(
|
.get(
|
||||||
'/api/inventions/v1/personaldetails/:inventionId{[0-9]+}',
|
'/api/inventions/v1/personaldetails/:inventionId{[0-9]+}',
|
||||||
describeRoute({
|
describeRoute({
|
||||||
tags: ['Inventions'],
|
tags: ['Inventions'],
|
||||||
summary: 'The caller’s own relation to an invention',
|
summary: 'The caller’s own relation to an invention',
|
||||||
description:
|
description:
|
||||||
'Just whether the caller is cheering it. We store no cheers, so it is always false ' +
|
'Whether the caller is cheering this invention. Signed-out callers receive false, ' +
|
||||||
'— and this stays a 200 for signed-out callers too, since the client only reads the ' +
|
'since there is no player interaction to look up.',
|
||||||
'flag.',
|
|
||||||
parameters: [idParam('inventionId', 'Invention id')],
|
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
|
// A single version of an invention (`?inventionId=…&version=…`) — the bare
|
||||||
@@ -1751,6 +1757,42 @@ export const avatarRoutes = new Hono<App>({ 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<string, unknown>)
|
||||||
|
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 —
|
// Report an invention. Stored in the `report` table the player and event reports use —
|
||||||
// same fields, same moderation life — with `invention_id` set. See
|
// same fields, same moderation life — with `invention_id` set. See
|
||||||
// migrations/0016_report_invention.sql.
|
// migrations/0016_report_invention.sql.
|
||||||
|
|||||||
@@ -3224,17 +3224,71 @@ describe('public endpoints', () => {
|
|||||||
expect(noId.status).toBe(400)
|
expect(noId.status).toBe(400)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('GET /api/inventions/v1/personaldetails/:id reports the cheer flag', async () => {
|
test('POST /api/inventions/v1/cheer persists and personaldetails reflects it', async () => {
|
||||||
// No cheer storage yet, so nobody is ever cheering — signed in or not.
|
const saved = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/personaldetails/2`, {
|
method: 'POST',
|
||||||
headers: await bearer('42'),
|
headers: { ...(await bearer('8200')), 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name: 'Cheerable Lamp', inventionDataFilename: 'cheerable.inv' }),
|
||||||
})
|
})
|
||||||
expect(res.status).toBe(200)
|
const invention = ((await saved.json()) as InventionSaveResult).Invention
|
||||||
expect(await res.json()).toEqual({ IsCheering: false })
|
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<number> => {
|
||||||
|
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`)
|
// The write requires a player; the read remains useful to signed-out callers.
|
||||||
expect(anon.status).toBe(200)
|
expect(
|
||||||
expect(await anon.json()).toEqual({ IsCheering: false })
|
(
|
||||||
|
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 () => {
|
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/v1/cheer',
|
||||||
'POST /api/images/v4/uploadsaved',
|
'POST /api/images/v4/uploadsaved',
|
||||||
'POST /api/images/v5/cheered/bulk',
|
'POST /api/images/v5/cheered/bulk',
|
||||||
|
'POST /api/inventions/v1/cheer',
|
||||||
'POST /api/inventions/v1/report',
|
'POST /api/inventions/v1/report',
|
||||||
'POST /api/inventions/v1/settags',
|
'POST /api/inventions/v1/settags',
|
||||||
'POST /api/inventions/v1/update',
|
'POST /api/inventions/v1/update',
|
||||||
|
|||||||
Reference in New Issue
Block a user