[api] add missing invention status endpoint

This commit is contained in:
Devin Zuczek
2026-09-15 16:32:03 -04:00
parent cce921e4cd
commit 7774975619
3 changed files with 297 additions and 0 deletions
+107
View File
@@ -104,6 +104,7 @@ import {
UNAUTHORIZED_RESPONSE,
UpdateCustomAvatarItemRequest,
UpdateInventionMetadataRequest,
UpdateInventionRequest,
UpdatePriceRequest,
} from '../openapi'
import { createReport } from '../reports-db'
@@ -2055,6 +2056,112 @@ export const avatarRoutes = new Hono<App>({ strict: false })
}
)
// Edit an invention, as the newer client sends it: the `v1/update` write with a
// PascalCase body instead of a query string. What the permission picker posts —
// `{ InventionId, Permission }` — and so the endpoint that changes what other players
// may DO with an invention, where `v2/metadata` beside it changes what it SAYS.
//
// Creator only, which is the whole point of the gate: `GeneralPermission` is what lets
// another player edit, re-publish or charge for someone's build, so a write that took
// the caller's word for the id would let anyone hand themselves rights over any
// invention in the game.
.post(
'/api/inventions/v2/update',
describeRoute({
tags: ['Inventions'],
summary: 'Edit an invention (v2)',
description:
'The newer client’s form of `v1/update`: a PascalCase JSON body in place of the ' +
'query string. A patch, not a replace — every field but `InventionId` is nullable ' +
'and a null one is left as it is, since the client sends the whole shape on every ' +
'edit.\n\n' +
'**Creator only.** `Permission` decides what other players may do with the ' +
'invention — up to editing, re-publishing and charging for it — so only the ' +
'account that created it may set it.\n\n' +
'`Permission` is the raw `GeneralPermission` ladder number (UseOnly 20, ' +
'EditAndSave 40, Publish 60 …), stored verbatim and unmapped, exactly as ' +
'`v1/update` stores the value its picker names. A null one LEAVES IT ALONE — ' +
'unlike `v4/publish`, where null means UseOnly: publishing something with no ' +
'permission named is a decision, but editing it without naming one is not.\n\n' +
'Publishing, pricing and tags are elsewhere (`v4/publish`, `v1/updateprice`, ' +
'`v2/metadata`), as they are for `v1/update`.\n\n' +
'Answers the enveloped result `v9/save` answers, carrying the UPDATED invention: ' +
'the client re-renders the detail sheet from `Value.Invention`, so a refusal — an ' +
'unknown invention and someone else’s alike — is `Success: false` with a null ' +
'`Value` rather than a bare error body, which that client cannot parse.',
security: AUTHED,
requestBody: jsonBody(UpdateInventionRequest, 'The fields to change'),
responses: {
200: json(
InventionSaveV9Result,
'The envelope — the updated invention under `Value`, or `Success: false` with ' +
'`Error` when the edit was refused'
),
401: json(InventionSaveV9Result, '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(inventionSaveV9Failure('Invalid request body'))
// The id rides in the body here, not the query string. The gate is what makes this
// creator-only: it loads the invention and refuses anyone but the account that
// created it.
const gate = await creatorsInventionResult(
c,
typeof body.InventionId === 'number' ? body.InventionId : Number.NaN
)
// Only a missing token is answered as a transport failure. An unknown invention or
// someone else's is a domain answer this client is meant to read — its own status
// enum has DoesNotExist and NotCreator members — so it goes in the envelope, where
// the message reaches a human. Same split `v2/metadata` makes.
if ('rejection' in gate) {
return gate.status === 401
? c.json(inventionSaveV9Failure(gate.rejection), 401)
: c.json(inventionSaveV9Failure(gate.rejection))
}
// A number is the picker's own form; a string is accepted through the same parser
// `v1/update`'s query param goes through, so a build sending `"EditAndSave"` or
// `"40"` lands on the same level. Anything else (null included) leaves it alone.
const permission =
typeof body.Permission === 'number'
? body.Permission
: typeof body.Permission === 'string'
? parsePermissionLevel(body.Permission)
: undefined
// Null is "leave it"; a string, empty or not, is an edit — `v2/metadata`'s rule,
// since these are the same client's bodies.
const edited = (key: string): string | undefined =>
typeof body[key] === 'string' ? body[key] : undefined
const name = edited('Name')?.trim()
const description = edited('Description')
const longDescription = edited('LongDescription')
for (const rejection of [
name === undefined ? null : inventionNameRejection(name),
description === undefined ? null : inventionDescriptionRejection(description),
longDescription === undefined ? null : inventionLongDescriptionRejection(longDescription),
]) {
if (rejection !== null) return c.json(inventionSaveV9Failure(rejection))
}
const updated = await updateInvention(c.env.DB, gate.invention.InventionId, {
generalPermission: permission,
allowTrial: typeof body.AllowTrial === 'boolean' ? body.AllowTrial : undefined,
name,
description,
longDescription,
imageName: edited('ImageName'),
})
if (updated === null) return c.json(inventionSaveV9Failure('No such invention'))
// The tags are reported as the ones the invention HAS, not as ones this call
// changed — it changes none. Same reading `v2/metadata` gives a null TagsRequest.
return c.json(toSaveResultV9(updated, updated.Tags ?? [], INVENTION_TAG_RESULT.success))
}
)
// Publish an invention, as the newer client sends it: a PascalCase body instead of a
// query string, and an Accessibility of its own — where `v3/publish` only ever flipped
// the published flag, this decides whether the result can be FOUND. Same enveloped