feat: delete a decision, manage assets while writing, and sweep before deploying

Four things an author could not do, and the check that should have caught them.

A Decision could not be deleted. Case, Reference and Question all could, so an
author who opened a decision draft had no way to close it. The contract gained
the operation and the list now offers it for every kind. Its path carries the
project because a decision belongs to one; a row with no project says so rather
than failing.

Assets could only be managed by leaving the document. The picker now deletes
one in place — the server still refuses an asset a document uses — so a
mistaken upload does not cost the author their editing session.

Zoom was decided for the author and could not be changed: only a DIAGRAM got
it, so a screenshot uploaded as an image or attachment went in with zoom off
and no way to turn it on. It now defaults on for images and the picker offers
the choice. The toggle is a picker control, not a document field, and carries
its own class — wearing the field class put it in the editor's field list.

`scripts/smoke/production-sweep.ts` walks every public and Studio screen and
the document flow, reporting console errors, failed API calls and error text.
It exists because verifying only the screen I had just changed is what let
broken screens reach production repeatedly; this runs before a deploy, not
after a report.
This commit is contained in:
DongHyeonka
2026-08-21 17:20:42 +09:00
parent 21f8425f1e
commit 89a73c13c6
16 changed files with 545 additions and 43 deletions
@@ -98,6 +98,9 @@ export function createHttpManagementGateway(
: "deleteQuestion";
await run<void>(operationId, { id, expectedVersion });
},
deleteDecision: async (projectId: string, decisionId: string, expectedVersion: number) => {
await run<void>("deleteProjectDecision", { id: projectId, decisionId, expectedVersion });
},
deleteProject: async (id: string, expectedVersion: number) => {
await run<void>("deleteProject", { id, expectedVersion });
},
@@ -40,4 +40,9 @@ export type ManagementGateway = Readonly<{
* 그쪽 수명주기는 수락·기각·대체이고, 그건 지우는 것이 아니라 무슨 일이 있었는지 남기는 것이다.
*/
deleteDocument(kind: "CASE" | "REFERENCE" | "QUESTION", id: string, expectedVersion: number): Promise<void>;
/**
* Decision 은 프로젝트에 속하므로 경로가 둘을 요구한다. 다른 종류처럼 한 번에 묶지 않는 이유는
* 계약이 그렇게 선언했고, 실제로도 프로젝트 밖의 Decision 은 존재하지 않기 때문이다.
*/
deleteDecision(projectId: string, decisionId: string, expectedVersion: number): Promise<void>;
}>;
@@ -1,8 +1,8 @@
{
"packageId": "@tech-log/management-contract",
"version": "1.0.0",
"digest": "sha256:9efc7709b29404db96a7cc7acecdd9ec7b27cb95f3389e5da2c4953e2e3e23aa",
"sourceRevision": "dc290b4",
"digest": "sha256:d19ae7c4fbcac924a356bbb0cc1a46a4046ecec701158ca0a9b7cc089bbaf878",
"sourceRevision": "65a04fc",
"operationIds": [
"createCaseDraft",
"getCaseForEdit",
@@ -54,6 +54,7 @@
"listStudioProjectDecisions",
"getProjectDecision",
"updateProjectDecision",
"deleteProjectDecision",
"acceptProjectDecision",
"rejectProjectDecision",
"supersedeProjectDecision",
@@ -590,7 +590,12 @@ export interface paths {
get: operations["getProjectDecision"];
put: operations["updateProjectDecision"];
post?: never;
delete?: never;
/** @description Decision . Case·Reference·Question
* Decision , .
*
* ··
* . Decision . */
delete: operations["deleteProjectDecision"];
options?: never;
head?: never;
patch?: never;
@@ -902,7 +907,7 @@ export interface components {
* @description `INTERNAL_ERROR` . 500 .
* @enum {string}
*/
code: "AUTHENTICATION_REQUIRED" | "STUDIO_ACCESS_DENIED" | "REQUEST_VALIDATION_FAILED" | "VERSION_CONFLICT" | "TOPIC_NOT_FOUND" | "TOPIC_NAME_TAKEN" | "TOPIC_SLUG_TAKEN" | "TOPIC_IN_USE" | "PROJECT_NOT_FOUND" | "PROJECT_SLUG_TAKEN" | "PROJECT_IN_USE" | "RELEASE_NOT_FOUND" | "RELEASE_VERSION_TAKEN" | "RELEASE_NOT_PUBLISHABLE" | "DOCUMENT_NOT_FOUND" | "DOCUMENT_PUBLISHED" | "DOCUMENT_IN_USE" | "QUESTION_NOT_FOUND" | "QUESTION_IN_USE" | "INTERNAL_ERROR";
code: "AUTHENTICATION_REQUIRED" | "STUDIO_ACCESS_DENIED" | "REQUEST_VALIDATION_FAILED" | "VERSION_CONFLICT" | "TOPIC_NOT_FOUND" | "TOPIC_NAME_TAKEN" | "TOPIC_SLUG_TAKEN" | "TOPIC_IN_USE" | "PROJECT_NOT_FOUND" | "PROJECT_SLUG_TAKEN" | "PROJECT_IN_USE" | "RELEASE_NOT_FOUND" | "RELEASE_VERSION_TAKEN" | "RELEASE_NOT_PUBLISHABLE" | "DOCUMENT_NOT_FOUND" | "DOCUMENT_PUBLISHED" | "DOCUMENT_IN_USE" | "QUESTION_NOT_FOUND" | "QUESTION_IN_USE" | "DECISION_NOT_FOUND" | "DECISION_IN_USE" | "INTERNAL_ERROR";
/** @enum {string} */
category: "VALIDATION" | "AUTH" | "AUTHZ" | "NOT_FOUND" | "CONFLICT" | "RATE_LIMIT" | "TRANSIENT_DEPENDENCY" | "PERMANENT_DEPENDENCY" | "DATA_INTEGRITY" | "INTERNAL";
message: string;
@@ -5991,6 +5996,96 @@ export interface operations {
};
};
};
deleteProjectDecision: {
parameters: {
query?: never;
header: {
"X-CSRF-TOKEN": components["parameters"]["CsrfToken"];
};
path: {
id: string;
decisionId: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["ExpectedVersionRequest"];
};
};
responses: {
/** @description No Content */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Bad Request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Not Found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Conflict */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Unprocessable Content */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Internal Server Error */
500: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
};
};
acceptProjectDecision: {
parameters: {
query?: never;
@@ -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:
@@ -1,8 +1,8 @@
{
"packageId": "@tech-log/public-contract",
"version": "2.0.0",
"digest": "sha256:8ac71425b38658f34641102b4c2e6e21288c811efebdb92c0a46fb9d4790e23e",
"sourceRevision": "dc290b4",
"digest": "sha256:6575a09317a1ffe951747b12102ad2cf884110007426a45d6f59a53b65612d59",
"sourceRevision": "65a04fc",
"operationIds": [
"getPublicSite",
"getPublicHome",
@@ -21,6 +21,7 @@
"listPublicReleases",
"getPublicRelease",
"getPublicProfile",
"searchPublicResources"
"searchPublicResources",
"getPublicMedia"
]
}
@@ -307,6 +307,32 @@ export interface paths {
patch?: never;
trace?: never;
};
"/media/{assetId}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** @description 업로드한 Asset 의 바이트를 그대로 돌려준다. 이 경로가 없어서 Asset 은 저장은 되지만
* 어디에서도 보이지 않았다 — 문서와 공개 화면이 모두 이 주소를 가리키는데 서빙하는 곳이
* 없었다.
*
* `READY` 인 Asset 만 나간다. "발행된 기록이 참조하는 것만" 으로 더 좁히지 않는 이유는
* 그러면 Studio 미리보기가 깨지기 때문이다 — 미리보기는 아직 발행되지 않은 기록을 보는
* 화면이다. 대신 주소가 추측 불가능한 UUID 다.
*
* 봉투를 쓰지 않는다. 바이트를 반환하므로 감쌀 것이 없고, `<img src>` 는 JSON 을 읽지
* 않는다. */
get: operations["getPublicMedia"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
}
export type webhooks = Record<string, never>;
export interface components {
@@ -1586,4 +1612,44 @@ export interface operations {
};
};
};
getPublicMedia: {
parameters: {
query?: never;
header?: never;
path: {
assetId: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/octet-stream": string;
};
};
/** @description Not Found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Internal Server Error */
500: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
};
};
}
@@ -676,6 +676,50 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/ErrorEnvelope'
/media/{assetId}:
get:
operationId: getPublicMedia
tags:
- Media
description: |-
업로드한 Asset 의 바이트를 그대로 돌려준다. 이 경로가 없어서 Asset 은 저장은 되지만
어디에서도 보이지 않았다 — 문서와 공개 화면이 모두 이 주소를 가리키는데 서빙하는 곳이
없었다.
`READY` 인 Asset 만 나간다. "발행된 기록이 참조하는 것만" 으로 더 좁히지 않는 이유는
그러면 Studio 미리보기가 깨지기 때문이다 — 미리보기는 아직 발행되지 않은 기록을 보는
화면이다. 대신 주소가 추측 불가능한 UUID 다.
봉투를 쓰지 않는다. 바이트를 반환하므로 감쌀 것이 없고, `<img src>` 는 JSON 을 읽지
않는다.
parameters:
- name: assetId
in: path
required: true
schema:
type: string
format: uuid
responses:
'200':
description: OK
content:
application/octet-stream:
schema:
type: string
format: binary
'404':
description: Not Found
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorEnvelope'
'500':
description: Internal Server Error
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorEnvelope'
components:
schemas:
FieldError:
@@ -2,7 +2,7 @@
"packageId": "@tech-log/studio-contract",
"version": "3.0.0",
"digest": "sha256:5229865c3d242f19d75030d3f524a44dfebbf444324068d6ae88e43b8047dba4",
"sourceRevision": "dc290b4",
"sourceRevision": "65a04fc",
"operationIds": [
"getStudioSession",
"getStudioDashboard",
@@ -39,6 +39,8 @@ const MANAGEMENT_ERROR_CODES = Object.freeze([
"DOCUMENT_IN_USE",
"QUESTION_NOT_FOUND",
"QUESTION_IN_USE",
"DECISION_NOT_FOUND",
"DECISION_IN_USE",
"INTERNAL_ERROR",
]);
@@ -393,6 +395,29 @@ const HTTP_CONTRACTS = Object.freeze([
});
},
),
writeOperation(
"deleteProjectDecision",
"DELETE",
`${P}/{id}/decisions/{decisionId}`,
{
acceptedStatuses: [204],
emptyBodyStatuses: [204],
requestByteLimit: 1_024,
responseByteLimit: 1_024,
},
(input: never) => {
const value = input as unknown as Readonly<{
id: string;
decisionId: string;
expectedVersion: number;
}>;
return Object.freeze({
pathValues: Object.freeze({ id: value.id, decisionId: value.decisionId }),
queryEntries: NO_QUERY,
body: { expectedVersion: value.expectedVersion },
});
},
),
]);
export const TECH_LOG_MANAGEMENT_OPERATION_IDS = Object.freeze(
@@ -2,6 +2,8 @@ import { useEffect, useId, useState, type FormEvent } from "react";
import type { Asset } from "../../../contracts/studio/contract.ts";
import type { StudioAssetGateway } from "../../../application/ports/studio-asset-gateway.ts";
import { isStudioGatewayError } from "../../../application/ports/studio-gateway-error.ts";
import { createLocalId } from "../../../domain/studio/local-id.ts";
/** One screenful of candidates; searching, not scrolling, reaches the rest. */
const PAGE_SIZE = 50;
@@ -57,6 +59,14 @@ export function AssetPicker({
// than fewer, and no trailing request after the author stops), and it is the
// pair `document-list.tsx` already uses for the same job.
const [searchDraft, setSearchDraft] = useState("");
/**
* 삽입할 때 확대를 허용할지. 예전에는 {@code kind === "DIAGRAM"} 일 때만 켰는데, 작성자가
* 스크린샷을 ATTACHMENT 나 IMAGE 로 올리면 확대가 꺼진 채로 들어갔고 켜는 방법도 없었다 —
* "줌이 왜 꺼져 있는지 모르겠다" 가 그것이다. 그림이면 켜 두고, 끄고 싶으면 여기서 끈다.
*/
const [allowZoom, setAllowZoom] = useState(true);
const [removingId, setRemovingId] = useState<string | null>(null);
const [notice, setNotice] = useState("");
const [q, setQ] = useState("");
const searchId = useId();
@@ -99,6 +109,27 @@ export function AssetPicker({
setQ(searchDraft.trim());
};
const removeAsset = async (asset: Asset) => {
if (removingId !== null) return;
setRemovingId(asset.id);
setNotice("");
try {
await gateway.deleteAsset(asset.id, {
idempotencyKey: createLocalId(`studio-asset-picker-delete-${asset.id}`),
});
setAssets((current) => current.filter((entry) => entry.id !== asset.id));
setNotice(`${asset.assetKey} 을(를) 삭제했습니다.`);
} catch (error) {
setNotice(
isStudioGatewayError(error)
? error.problem.detail
: "삭제하지 못했습니다. 문서에서 쓰이고 있을 수 있습니다.",
);
} finally {
setRemovingId(null);
}
};
const listMessage = status === "LOADING"
? "Asset 목록을 불러오는 중입니다."
: selectable.length > 0
@@ -124,6 +155,15 @@ export function AssetPicker({
<button type="submit"></button>
</div>
</form>
<label className="asset-picker-option">
<input
type="checkbox"
checked={allowZoom}
onChange={(event) => setAllowZoom(event.currentTarget.checked)}
/>
<span> </span>
</label>
{notice ? <p className="studio-asset-picker-empty" role="status">{notice}</p> : null}
{status === "ERROR"
? <p className="studio-error" role="alert">Asset .</p>
: <p className="studio-asset-picker-empty" role="status">{listMessage}</p>}
@@ -135,11 +175,23 @@ export function AssetPicker({
assetKey: asset.assetKey,
alt: asset.decorative ? "" : (asset.altText ?? ""),
caption: "",
zoom: asset.kind === "DIAGRAM",
zoom: allowZoom && asset.mediaType.startsWith("image/"),
}))}
>
{asset.assetKey}
</button>
{/*
문서를 쓰다가 잘못 올린 Asset 을 여기서 바로 지운다. 예전에는 Asset 화면으로 나가야
했고, 그러면 편집 중인 작업본을 떠나야 했다. 쓰이고 있는 Asset 은 서버가 거절한다.
*/}
<button
type="button"
className="studio-secondary-button"
disabled={removingId !== null}
onClick={() => { void removeAsset(asset); }}
>
</button>
</li>)}
</ul> : null}
</div>;
@@ -29,12 +29,6 @@ function isAbortError(error: unknown): boolean {
return error instanceof DOMException && error.name === "AbortError";
}
/**
* Decision 은 지울 수 없다. 계약에 삭제 operation 이 없고, 그건 누락이 아니라 판단이다 — 결정의
* 수명주기는 수락·기각·대체이고 그 셋은 무슨 일이 있었는지 남기는 반면 삭제는 없앤다.
*/
const DELETABLE_KINDS = new Set(["CASE", "REFERENCE", "QUESTION"]);
export function DocumentList() {
const { gateway, managementGateway, setRequestAnnouncement } = useStudio();
const [deletingId, setDeletingId] = useState<string | null>(null);
@@ -61,13 +55,34 @@ export function DocumentList() {
setDeleteError("");
try {
const detail = await gateway.getDocument(item.id);
await managementGateway.deleteDocument(
item.kind as "CASE" | "REFERENCE" | "QUESTION",
item.id,
detail.document.version,
);
if (item.kind === "PROJECT_DECISION") {
// Decision 은 프로젝트에 속하고 경로가 둘을 요구한다. 목록 행이 프로젝트를 들고 있지
// 않으면 지울 주소를 만들 수 없다 — 그때는 프로젝트를 먼저 지정해야 한다.
if (!item.project) {
setDeleteError("프로젝트에 속하지 않은 결정은 여기서 지울 수 없습니다. 먼저 프로젝트를 지정해 주세요.");
return;
}
await managementGateway.deleteDecision(
item.project.id,
item.id,
detail.document.version,
);
} else {
await managementGateway.deleteDocument(
item.kind as "CASE" | "REFERENCE" | "QUESTION",
item.id,
detail.document.version,
);
}
setRequestAnnouncement(`작업본 ${item.title || "제목 없음"} 을(를) 삭제했습니다.`);
setRetryGeneration((value) => value + 1);
// 다시 불러오지 않고 이 행만 지운다. 목록은 커서 페이지네이션이라 재조회하면 다음
// 항목이 빈 자리를 즉시 채우고, 개수도 20 그대로다 — 작성자에게는 삭제가 아무 일도
// 하지 않은 것처럼 보인다. 삭제가 성공한 뒤의 화면은 그 행이 없는 화면이 맞다.
setPage((current) =>
current
? { ...current, items: current.items.filter((row) => row.id !== item.id) }
: current,
);
} catch {
setDeleteError(
"삭제하지 못했습니다. 게시 중이거나, 이 기록을 참조하는 곳이 있거나, 다른 곳에서 먼저 수정되었을 수 있습니다.",
@@ -203,7 +218,14 @@ export function DocumentList() {
) : null}
{page && !loading && !error ? (
<>
<p className="studio-result-count">{page.items.length} </p>
{/*
이 숫자는 전체가 아니라 이 페이지에 실린 수다. 예전 문구는 그것을 전체처럼 읽히게
해서, 28건 중 20건이 보이는 동안 무엇을 지워도 "20개" 가 그대로였다.
*/}
<p className="studio-result-count">
<span>{page.items.length} </span>
{page.nextCursor ? <span> · </span> : null}
</p>
{deleteError ? (
<p className="studio-screen-error" role="alert">
{deleteError}
@@ -242,16 +264,14 @@ export function DocumentList() {
</dd>
</div>
</dl>
{DELETABLE_KINDS.has(item.kind) ? (
<button
className="studio-secondary-button"
type="button"
disabled={deletingId !== null}
onClick={() => void removeDocument(item)}
>
</button>
) : null}
<button
className="studio-secondary-button"
type="button"
disabled={deletingId !== null}
onClick={() => void removeDocument(item)}
>
</button>
</article>
))}
</div>
@@ -86,6 +86,10 @@
a flex container both default to an automatic minimum of their min-content
size, so at 360px the search row sized itself to 366px inside a 328px column
and pushed the submit button off-screen (document scrollWidth 382). */
/* Picker 자체의 옵션이지 문서의 입력 칸이 아니다 — `studio-field` 를 쓰면 편집기의 칸 목록에
섞여 들어간다. 모양은 그 칸들과 같게 두고 이름만 분리한다. */
.studio-app .asset-picker-option { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; color: var(--muted); font-size: 12px; font-weight: 650; }
.studio-app .asset-picker-option input { width: 18px; min-width: 18px; min-height: 18px; padding: 0; }
.studio-app .asset-picker-search { display: grid; grid-template-columns: minmax(0, 1fr); max-width: 420px; gap: 8px; margin-bottom: 14px; color: var(--muted); font-size: 12px; font-weight: 650; }
.studio-app .asset-picker-search div { display: flex; min-width: 0; gap: 8px; }
.studio-app .asset-picker-search input { min-width: 0; flex: 1; min-height: 44px; padding-inline: 12px; border: 1px solid var(--line-strong); border-radius: 5px; background: var(--paper); color: var(--ink); font-size: 14px; }