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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user