mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
[api] delete invention
This commit is contained in:
@@ -973,6 +973,55 @@ export async function setInventionPrice(
|
||||
return updated
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an invention (`v2/delete`), returning the record that was removed, or null
|
||||
* when there's no such row. The whole invention lives in the one JSON blob, so its
|
||||
* versions, tags and referenced-invention lists go with it in a single DELETE.
|
||||
*
|
||||
* Two things are deliberately LEFT behind.
|
||||
*
|
||||
* The data blob in R2 stays: it is named by the file the creator uploaded through the
|
||||
* `storage` worker, and nothing here knows whether another record still points at that
|
||||
* name (a converted invention carries the same lineage, and a save that reuses a
|
||||
* filename reuses the object). An orphan blob costs storage; a missing one breaks
|
||||
* whatever still references it.
|
||||
*
|
||||
* The `inventory_invention` rows stay too — deleting a creator's invention must not
|
||||
* rewrite what other players bought. They already fall out of every list on their own:
|
||||
* `getMyInventions` resolves owned ids against this table and an id with no row left
|
||||
* simply drops out, and `ownsAllInventions` reads a missing row as not-owned. Purging
|
||||
* them would also erase the acquisition history that ranks the "top today" feed.
|
||||
*/
|
||||
export async function deleteInvention(
|
||||
db: D1Database,
|
||||
inventionId: number
|
||||
): Promise<SavedInvention | null> {
|
||||
const invention = await getInventionById(db, inventionId)
|
||||
if (invention === null) return null
|
||||
await db.prepare('DELETE FROM invention WHERE id = ?1').bind(inventionId).run()
|
||||
return invention
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* gone, so there is nothing for the client to redraw from and it reads only `Success`
|
||||
* (and `Error`, the one string that reaches a human). This is why the delete does not
|
||||
* borrow {@link InventionSaveV9Result}: that envelope's `Value` carries an invention the
|
||||
* client dereferences, and a delete has none to give.
|
||||
*/
|
||||
export interface InventionDeleteResult {
|
||||
Value: null
|
||||
Success: boolean
|
||||
Error: string | null
|
||||
error_id: string | null
|
||||
}
|
||||
|
||||
/** The delete envelope: a refusal when given a message, success when given null. */
|
||||
export function inventionDeleteResult(error: string | null = null): InventionDeleteResult {
|
||||
return { Value: null, Success: error === null, Error: error, error_id: null }
|
||||
}
|
||||
|
||||
/** The tag filter chips the client offers when browsing inventions. */
|
||||
export interface InventionTagFilters {
|
||||
PinnedFilters: string[]
|
||||
|
||||
@@ -666,6 +666,24 @@ export const PublishInventionRequest = z.object({
|
||||
.describe('Price in tokens; null leaves it as it is, and a negative one is ignored'),
|
||||
})
|
||||
|
||||
/** `POST /api/inventions/v2/delete` JSON body — the id and nothing else. */
|
||||
export const DeleteInventionRequest = z.object({
|
||||
InventionId: z.int().describe('The invention to delete; the caller must have created it'),
|
||||
})
|
||||
|
||||
/**
|
||||
* 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
|
||||
* gone, so there is nothing for the client to redraw from: it reads `Success`, and
|
||||
* `Error` when that is false.
|
||||
*/
|
||||
export const InventionDeleteResult = z.object({
|
||||
Value: z.null().describe('Always null — the invention no longer exists'),
|
||||
Success: z.boolean(),
|
||||
Error: z.string().nullable().describe('The refusal message; null on success'),
|
||||
error_id: z.string().nullable().describe('Always null'),
|
||||
})
|
||||
|
||||
/** `POST /api/inventions/v1/updateprice` JSON body. */
|
||||
export const UpdatePriceRequest = z.object({
|
||||
InventionId: z.int(),
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
import { authedId, unauthorized } from '../http'
|
||||
import {
|
||||
createInvention,
|
||||
deleteInvention,
|
||||
getFeaturedInventions,
|
||||
getInventionById,
|
||||
getInventionsByIds,
|
||||
@@ -37,6 +38,7 @@ import {
|
||||
getMyInventions,
|
||||
getTopInventions,
|
||||
INVENTION_TAG_RESULT,
|
||||
inventionDeleteResult,
|
||||
inventionSaveV9Failure,
|
||||
normalizeInventionTags,
|
||||
ownsAllInventions,
|
||||
@@ -59,12 +61,14 @@ import {
|
||||
CustomAvatarItemReportRequest,
|
||||
CustomAvatarItemResponse,
|
||||
CustomAvatarItemsPage,
|
||||
DeleteInventionRequest,
|
||||
ErrorResponse,
|
||||
form,
|
||||
GeneratedGift,
|
||||
GenerateGiftRequest,
|
||||
idParam,
|
||||
intQuery,
|
||||
InventionDeleteResult,
|
||||
InventionDetails,
|
||||
InventionDto,
|
||||
InventionPersonalDetails,
|
||||
@@ -2030,3 +2034,63 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
return c.json(toSaveResultV9(published, published.Tags ?? []))
|
||||
}
|
||||
)
|
||||
|
||||
// Delete an invention. The newer client's shape: a POST with a PascalCase body
|
||||
// carrying nothing but the id. Auth-gated, creator only — the only thing that may
|
||||
// remove an invention is the account that made it, not a co-owner and not a buyer.
|
||||
//
|
||||
// The record and everything inside it (versions, tags, referenced-invention lists)
|
||||
// go in one DELETE; the data blob in R2 and the `inventory_invention` rows of
|
||||
// players who bought it are left alone. See `deleteInvention` for why.
|
||||
.post(
|
||||
'/api/inventions/v2/delete',
|
||||
describeRoute({
|
||||
tags: ['Inventions'],
|
||||
summary: 'Delete an invention',
|
||||
description:
|
||||
'Creator only — a buyer or a co-owner cannot delete someone else’s invention. ' +
|
||||
'The record goes entirely: its versions, tags and referenced-invention lists live ' +
|
||||
'in the same row.\n\n' +
|
||||
'What survives is deliberate. The data blob stays in storage, because nothing ' +
|
||||
'here knows whether another record still points at that filename. The ownership ' +
|
||||
'rows of players who bought it stay too — a delete must not rewrite what someone ' +
|
||||
'else paid for — and they fall out of every list on their own, since an owned id ' +
|
||||
'with no invention row behind it is skipped.\n\n' +
|
||||
'Answers the `{ Value, Success, Error, error_id }` envelope the other v2+ ' +
|
||||
'invention routes use, with `Value` NULL: the invention is gone, so there is ' +
|
||||
'nothing to redraw from and the client reads only `Success`. Refusals — an ' +
|
||||
'unknown invention and someone else’s alike — are `Success: false` with a ' +
|
||||
'message, not a bare error body that client cannot parse.',
|
||||
security: AUTHED,
|
||||
requestBody: jsonBody(DeleteInventionRequest, 'The invention to delete'),
|
||||
responses: {
|
||||
200: json(InventionDeleteResult, 'The delete envelope, `Value` null either way'),
|
||||
401: json(InventionDeleteResult, 'The same envelope, refused — not an empty body'),
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
|
||||
if (body === null) return c.json(inventionDeleteResult('Invalid request body'))
|
||||
|
||||
// The id rides in the body, as it does on `v2/metadata` and `v4/publish`.
|
||||
const gate = await creatorsInventionResult(
|
||||
c,
|
||||
typeof body.InventionId === 'number' ? body.InventionId : Number.NaN
|
||||
)
|
||||
// As on those two: only a missing token is a transport failure. An unknown
|
||||
// invention or someone else's is a domain answer the client reads out of the
|
||||
// envelope, where the message reaches a human.
|
||||
if ('rejection' in gate) {
|
||||
return gate.status === 401
|
||||
? c.json(inventionDeleteResult(gate.rejection), 401)
|
||||
: c.json(inventionDeleteResult(gate.rejection))
|
||||
}
|
||||
|
||||
// The gate already loaded the row, so a null here is a race — someone deleted it
|
||||
// between the two reads — and lands where the client would put it anyway: gone.
|
||||
const deleted = await deleteInvention(c.env.DB, gate.invention.InventionId)
|
||||
return c.json(
|
||||
deleted === null ? inventionDeleteResult('No such invention') : inventionDeleteResult()
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -2365,6 +2365,147 @@ describe('public endpoints', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test('POST /api/inventions/v2/delete removes the creator’s invention', async () => {
|
||||
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('5180')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: 'Delete Me',
|
||||
inventionDataFilename: 'delete-me.inv',
|
||||
tagsRequest: { AutoTags: ['small'], CustomTags: null },
|
||||
}),
|
||||
})
|
||||
const inventionId = ((await save.json()) as InventionSaveV9Result).Value?.Invention.InventionId
|
||||
expect(inventionId).toBeGreaterThan(0)
|
||||
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/delete`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('5180')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ InventionId: inventionId }),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
// `Value` is null even on success — there is no invention left to redraw from.
|
||||
expect(await res.json()).toEqual({ Value: null, Success: true, Error: null, error_id: null })
|
||||
|
||||
// Gone from the read and from the creator's shelf.
|
||||
const one = await exports.default.fetch(
|
||||
`${ORIGIN}/api/inventions/v1?inventionId=${inventionId}`
|
||||
)
|
||||
expect(one.status).toBe(404)
|
||||
const mine = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/mine`, {
|
||||
headers: await bearer('5180'),
|
||||
})
|
||||
expect(((await mine.json()) as SavedInvention[]).map((i) => i.InventionId)).not.toContain(
|
||||
inventionId
|
||||
)
|
||||
|
||||
// And the row itself, tags and all, rather than a hidden record still taking the id.
|
||||
const row = await env.DB.prepare('SELECT COUNT(*) AS n FROM invention WHERE id = ?1')
|
||||
.bind(inventionId)
|
||||
.first<{ n: number }>()
|
||||
expect(row?.n).toBe(0)
|
||||
|
||||
// Deleting it twice is a refusal, not a second success: the id resolves to nothing.
|
||||
const again = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/delete`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('5180')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ InventionId: inventionId }),
|
||||
})
|
||||
expect(await again.json()).toEqual({
|
||||
Value: null,
|
||||
Success: false,
|
||||
Error: 'No such invention',
|
||||
error_id: null,
|
||||
})
|
||||
})
|
||||
|
||||
test('POST /api/inventions/v2/delete refuses anyone but the creator, in-band', async () => {
|
||||
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('5181')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'Not Yours To Bin', inventionDataFilename: 'not-yours.inv' }),
|
||||
})
|
||||
const inventionId = ((await save.json()) as InventionSaveV9Result).Value?.Invention.InventionId
|
||||
|
||||
// 5182 BOUGHT it — owning a copy is still not the right to delete it.
|
||||
await grantInvention(env.DB, 5182, inventionId as number)
|
||||
const theirs = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/delete`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('5182')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ InventionId: inventionId }),
|
||||
})
|
||||
expect(theirs.status).toBe(200)
|
||||
expect(await theirs.json()).toEqual({
|
||||
Value: null,
|
||||
Success: false,
|
||||
Error: 'Not your invention',
|
||||
error_id: null,
|
||||
})
|
||||
|
||||
const missing = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/delete`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('5181')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ InventionId: 987_655 }),
|
||||
})
|
||||
expect(missing.status).toBe(200)
|
||||
expect(await missing.json()).toEqual({
|
||||
Value: null,
|
||||
Success: false,
|
||||
Error: 'No such invention',
|
||||
error_id: null,
|
||||
})
|
||||
|
||||
// A missing token is the one refusal that stays a transport failure — and it still
|
||||
// answers the envelope rather than a bare error body.
|
||||
const anon = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/delete`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ InventionId: inventionId }),
|
||||
})
|
||||
expect(anon.status).toBe(401)
|
||||
expect(await anon.json()).toMatchObject({ Value: null, Success: false })
|
||||
|
||||
// Still there throughout.
|
||||
const one = await exports.default.fetch(
|
||||
`${ORIGIN}/api/inventions/v1?inventionId=${inventionId}`
|
||||
)
|
||||
expect(one.status).toBe(200)
|
||||
})
|
||||
|
||||
test('POST /api/inventions/v2/delete leaves a buyer’s ownership row behind', async () => {
|
||||
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('5183')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'Sold Then Binned', inventionDataFilename: 'sold.inv' }),
|
||||
})
|
||||
const inventionId = ((await save.json()) as InventionSaveV9Result).Value?.Invention
|
||||
.InventionId as number
|
||||
await grantInvention(env.DB, 5184, inventionId)
|
||||
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/delete`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('5183')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ InventionId: inventionId }),
|
||||
})
|
||||
expect((await res.json()) as { Success: boolean }).toMatchObject({ Success: true })
|
||||
|
||||
// The purchase record is not rewritten by someone else's delete...
|
||||
const owned = await env.DB.prepare(
|
||||
'SELECT COUNT(*) AS n FROM inventory_invention WHERE invention_id = ?1'
|
||||
)
|
||||
.bind(inventionId)
|
||||
.first<{ n: number }>()
|
||||
expect(owned?.n).toBe(1)
|
||||
|
||||
// ...but with no invention row behind it, it drops out of the buyer's shelf anyway.
|
||||
const mine = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/mine`, {
|
||||
headers: await bearer('5184'),
|
||||
})
|
||||
expect(((await mine.json()) as SavedInvention[]).map((i) => i.InventionId)).not.toContain(
|
||||
inventionId
|
||||
)
|
||||
})
|
||||
|
||||
test('GET /api/inventions/v2/mine lists bought inventions alongside the caller’s own', async () => {
|
||||
// Account 6100 creates one; 6101 buys it (the econ worker's buyInvention writes
|
||||
// exactly this row) and also creates one of their own.
|
||||
@@ -6874,6 +7015,7 @@ describe('openapi', () => {
|
||||
'POST /api/inventions/v1/settags',
|
||||
'POST /api/inventions/v1/update',
|
||||
'POST /api/inventions/v1/updateprice',
|
||||
'POST /api/inventions/v2/delete',
|
||||
'POST /api/inventions/v4/publish',
|
||||
'POST /api/inventions/v6/save',
|
||||
'POST /api/inventions/v9/save',
|
||||
|
||||
Reference in New Issue
Block a user