feat: serve uploaded media, delete a decision, and stop orphaning publications

Three gaps that all showed up as something broken on screen.

An uploaded image could never be fetched. Every asset address the backend
builds pointed at a path nothing served, so a figure was missing in the Studio
preview and in the published page alike. There is now a public media endpoint
under the prefix that is already anonymous and already routed, so no edge
configuration has to change for an image to appear. Only READY assets are
served; narrowing it to assets a published record uses would break previewing,
which is the one thing an author does before publishing.

Deleting a working copy left wreckage. Publications, validations and previews
address a document by (kind, id) with no foreign key, so deleting one left rows
pointing at nothing — and the dashboard and publication history read a title
off that nothing and crashed. Both screens went down in production this way. A
record with publication history is now refused outright, because that history
says what was once public; validations and previews go with the record, because
they are its scaffolding.

A Decision could not be deleted at all while every other kind could. It can
now, under the same two rules, and refuses when another decision supersedes it
or the home page features it — neither cascades, so deleting would have been a
foreign-key violation reaching the author as a 500.

The persistence tests cover every one of these queries against real PostgreSQL.
That suite exists because a column name I assumed rather than checked reached
production once already; three more assumptions were caught here before it
could happen again.
This commit is contained in:
DongHyeonka
2026-08-21 17:21:02 +09:00
parent af91f7d768
commit 96521a94d4
25 changed files with 777 additions and 9 deletions
@@ -3390,6 +3390,83 @@ paths:
$ref: '#/components/schemas/DecisionUpdateRequest'
security:
- sessionCookie: []
delete:
operationId: deleteProjectDecision
tags:
- Decisions
description: |-
작업본 목록에서 Decision 을 지운다. Case·Reference·Question 에는 이 경로가 있었는데
Decision 에만 없어서, 작성자가 연 초안을 접을 방법이 없었다.
수락·기각·대체는 무슨 일이 있었는지 남기는 수명주기이고 이것은 그것과 다르다 — 아직
아무 판단도 하지 않은 초안을 없애는 일이다. 그래서 이미 게시된 Decision 은 거절한다.
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
- name: decisionId
in: path
required: true
schema:
type: string
format: uuid
- $ref: '#/components/parameters/CsrfToken'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/ExpectedVersionRequest'
responses:
'204':
description: No Content
'400':
description: Bad Request
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorEnvelope'
'401':
description: Unauthorized
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorEnvelope'
'403':
description: Forbidden
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorEnvelope'
'404':
description: Not Found
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorEnvelope'
'409':
description: Conflict
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorEnvelope'
'422':
description: Unprocessable Content
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorEnvelope'
'500':
description: Internal Server Error
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorEnvelope'
security:
- sessionCookie: []
/api/v1/studio/projects/{id}/decisions/{decisionId}/accept:
post:
operationId: acceptProjectDecision
@@ -5276,6 +5353,8 @@ components:
- DOCUMENT_IN_USE
- QUESTION_NOT_FOUND
- QUESTION_IN_USE
- DECISION_NOT_FOUND
- DECISION_IN_USE
- INTERNAL_ERROR
description: '`INTERNAL_ERROR` 는 이 기능이 아니라 스켈레톤의 공통 처리기가 내는 코드다. 계약이 그것까지 열거해야 500 응답이 계약을 벗어나지 않는다.'
category: