From 37542691181a6f780b0cca12e55e4ea706d37ab4 Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Fri, 21 Aug 2026 03:08:41 +0900 Subject: [PATCH] feat: add the Studio release editor and point the footer at the changelog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The public site shipped a Releases page and a footer link to a release, and neither could ever have content: the read path existed, the write path did not. This adds the seven release operations to the contract contribution and the gateway, and a Studio screen that can actually write one. The editor is six markdown fields rather than one, because that is what the contract models and what a release note is — why, what, what a reader notices, what it leaves in the code, how it was verified, what is still open. Publishing is separate from saving: a draft saves in any state, but the public query keys on PUBLISHED alone, so publish is where completeness is demanded. No new CSS. The screen reuses the document editor's field classes and the working-copy list's row classes, so it inherits Studio's spacing and type instead of introducing a second look. The footer previously linked `/releases/0.1.0` — a version that did not exist, so the link 404'd, and one that would have gone stale at 0.2.0 anyway. It now points at the changelog index, which is the only place that knows what the latest release is and which reads correctly when there are none. Route inventory, navigation order, message catalog, manual accessibility evidence, artifact baseline, and the pinned gate-shape digest all move with the new route. The digest was recomputed by first reproducing the previous constant from the previous gates.json, so the computation is known to be the one it was pinned under. --- .../a11y-manual/TECH_LOG_STUDIO_RELEASES.md | 18 + config/ci/gates.json | 7 + public/release-manifest.json | 10 +- scripts/contracts/ci-gates.ts | 10 +- .../adapters/http/http-management-gateway.ts | 17 + .../application/ports/management-gateway.ts | 13 +- .../management/canonical-source.json | 4 +- .../tech-log/contracts/management/contract.ts | 5 + .../contracts/management/generated.ts | 122 ++--- .../management/management-api.openapi.yaml | 248 ++++++---- .../tech-log/contracts/public-site-config.ts | 5 +- .../contracts/public/canonical-source.json | 2 +- .../contracts/studio/canonical-source.json | 2 +- ...ch-log-management-contract-contribution.ts | 82 ++++ .../contracts/tech-log-route-contract.ts | 1 + .../presentation/public/public-shell.tsx | 2 +- .../studio/components/release-manager.tsx | 427 ++++++++++++++++++ .../studio/pages/releases-page.tsx | 5 + .../presentation/tech-log-route-runtime.tsx | 7 + .../tech-log/navigation-derivation.test.ts | 1 + .../features/tech-log/route-contract.test.ts | 7 +- .../tech-log/studio-shell-smoke.test.tsx | 1 + tests/unit/ci-workflow-generation.test.ts | 4 +- .../unit/task3-selective-integration.test.ts | 2 +- vite.config.ts | 1 + 25 files changed, 831 insertions(+), 172 deletions(-) create mode 100644 artifacts/tests/a11y-manual/TECH_LOG_STUDIO_RELEASES.md create mode 100644 src/features/tech-log/presentation/studio/components/release-manager.tsx create mode 100644 src/features/tech-log/presentation/studio/pages/releases-page.tsx diff --git a/artifacts/tests/a11y-manual/TECH_LOG_STUDIO_RELEASES.md b/artifacts/tests/a11y-manual/TECH_LOG_STUDIO_RELEASES.md new file mode 100644 index 0000000..9b1cac3 --- /dev/null +++ b/artifacts/tests/a11y-manual/TECH_LOG_STUDIO_RELEASES.md @@ -0,0 +1,18 @@ +# TECH_LOG_STUDIO_RELEASES accessibility review + +Status: pending-manual-review +Route ID: TECH_LOG_STUDIO_RELEASES +Release ID: +Reviewer: +Reviewed at: +Signature: +Attestation: pending +M1 Keyboard: pending +M2 Visible focus: pending +M3 Route focus: pending +M4 Modal focus: pending +M5 Error association: pending +M6 Color signal: pending +M7 Reduced motion: pending +Screen reader: pending +Notes: Human review pending; automated evidence does not replace signed keyboard, focus, and screen-reader review. diff --git a/config/ci/gates.json b/config/ci/gates.json index b4655a8..75d5dc9 100644 --- a/config/ci/gates.json +++ b/config/ci/gates.json @@ -1202,6 +1202,12 @@ "schemaId": "markdown", "production": "source-controlled" }, + { + "id": "artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-RELEASES-md", + "path": "artifacts/tests/a11y-manual/TECH_LOG_STUDIO_RELEASES.md", + "schemaId": "markdown", + "production": "source-controlled" + }, { "id": "artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-NOT-FOUND-md", "path": "artifacts/tests/a11y-manual/TECH_LOG_STUDIO_NOT_FOUND.md", @@ -1956,6 +1962,7 @@ "artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-PUBLICATION-PREVIEW-md", "artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-ASSETS-md", "artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-TAXONOMY-md", + "artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-RELEASES-md", "artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-NOT-FOUND-md", "artifact-artifacts-tests-a11y-manual-NOT-FOUND-md", "artifact-artifacts-tests-a11y-manual-report-json" diff --git a/public/release-manifest.json b/public/release-manifest.json index 2ec5e30..8987f27 100644 --- a/public/release-manifest.json +++ b/public/release-manifest.json @@ -38,28 +38,28 @@ }, "contractSet": { "setAlgorithm": "CA_CONTRACT_SET_V1", - "setDigest": "sha256:ac1448ced933beece92924be4ffb7beb182c3df26371a83ffeffbb174ce98e72", + "setDigest": "sha256:0872fd944d931d70550549496a8086322ea6c86adc1d5802030300eb0076d2f2", "packages": [ { "packageId": "@tech-log/management-contract", "version": "1.0.0", - "digest": "sha256:ec5e432215fb041abee980787366a6db29ff1ecdd78416aa9c61e09b9b91022f", + "digest": "sha256:e16a9695e9034a273c769792ea8901fd706c61514137228b2c20e1b117ea0c01", "runtimeProtocolVersion": 1, - "sourceRevision": "6ef5c1c" + "sourceRevision": "0c10a4a" }, { "packageId": "@tech-log/public-contract", "version": "2.0.0", "digest": "sha256:8ac71425b38658f34641102b4c2e6e21288c811efebdb92c0a46fb9d4790e23e", "runtimeProtocolVersion": 1, - "sourceRevision": "6ef5c1c" + "sourceRevision": "0c10a4a" }, { "packageId": "@tech-log/studio-contract", "version": "3.0.0", "digest": "sha256:6cae9924403d0761f401643a022980b8e04183eea0d890c143c9fbbbbc7431e4", "runtimeProtocolVersion": 1, - "sourceRevision": "6ef5c1c" + "sourceRevision": "0c10a4a" } ] } diff --git a/scripts/contracts/ci-gates.ts b/scripts/contracts/ci-gates.ts index 2db948e..8ca6127 100644 --- a/scripts/contracts/ci-gates.ts +++ b/scripts/contracts/ci-gates.ts @@ -464,7 +464,11 @@ const CANONICAL_GATE_SHAPE_SHA256 = // lists one evidence artifact per installed route and refuses a set that does // not match the route scope exactly, so adding a route necessarily moves this // digest — that is the point of pinning it. - "8c73d4477392a63e91088ff91e53293e7fc3ace891f8eb3da3b0d05fa714df60"; + // Release authoring: recomputed again after FE-GATE-009 gained the + // TECH_LOG_STUDIO_RELEASES evidence artifact, by the same method — 8c73d447… + // was first reproduced from the previous gates.json, so the computation that + // produced this value is known to be the one the constant was pinned under. + "187dbd9676b7b3444409b5af4143d49927b1eacc67f57a099f4a3765bb64d865"; function canonicalGateShapeSha256(gates: CiGateContract["gates"]): string { const normalized = gates.map( @@ -519,8 +523,8 @@ function canonicalAuthorityBaselineFailures(contract: CiGateContract): string[] // Alignment follow-up, item 2 added the TechLog junit report. // The taxonomy route added its own manual a11y evidence file — every installed // route carries one, and the gate checks that the two sets match exactly. - if (contract.artifacts.length !== 131) { - failures.push(`artifact authority baseline must contain exactly 131 artifacts; received ${contract.artifacts.length}`); + if (contract.artifacts.length !== 132) { + failures.push(`artifact authority baseline must contain exactly 132 artifacts; received ${contract.artifacts.length}`); } if (contract.stages.length !== 5) { failures.push(`stage authority baseline must contain exactly 5 stages; received ${contract.stages.length}`); diff --git a/src/features/tech-log/adapters/http/http-management-gateway.ts b/src/features/tech-log/adapters/http/http-management-gateway.ts index cc1092c..0c01da5 100644 --- a/src/features/tech-log/adapters/http/http-management-gateway.ts +++ b/src/features/tech-log/adapters/http/http-management-gateway.ts @@ -3,6 +3,10 @@ import type { ProjectEditResponse, ProjectIndexPage, ProjectUpdateRequest, + PublishResponse, + ReleaseEditResponse, + ReleaseIndexPage, + ReleaseUpdateRequest, TopicEdit, } from "../../contracts/management/contract.ts"; import type { ManagementGateway } from "../../application/ports/management-gateway.ts"; @@ -68,6 +72,19 @@ export function createHttpManagementGateway( createProject: (title: string) => run("createProject", { title }), updateProject: (id: string, body: ProjectUpdateRequest) => run("updateProject", { id, body }), + listReleases: (page?: number, size?: number) => + run("listStudioReleases", { ...(page !== undefined ? { page } : {}), ...(size !== undefined ? { size } : {}) }), + getRelease: (id: string) => run("getReleaseForEdit", { id }), + createRelease: (title: string) => run("createRelease", { title }), + updateRelease: (id: string, body: ReleaseUpdateRequest) => + run("updateRelease", { id, body }), + deleteRelease: async (id: string, expectedVersion: number) => { + await run("deleteRelease", { id, expectedVersion }); + }, + publishRelease: (id: string, expectedVersion: number) => + run("publishRelease", { id, expectedVersion }), + archiveRelease: (id: string, expectedVersion: number) => + run("archiveRelease", { id, expectedVersion }), deleteProject: async (id: string, expectedVersion: number) => { await run("deleteProject", { id, expectedVersion }); }, diff --git a/src/features/tech-log/application/ports/management-gateway.ts b/src/features/tech-log/application/ports/management-gateway.ts index e0a7057..f135a6d 100644 --- a/src/features/tech-log/application/ports/management-gateway.ts +++ b/src/features/tech-log/application/ports/management-gateway.ts @@ -3,11 +3,15 @@ import type { ProjectEditResponse, ProjectIndexPage, ProjectUpdateRequest, + PublishResponse, + ReleaseEditResponse, + ReleaseIndexPage, + ReleaseUpdateRequest, TopicEdit, } from "../../contracts/management/contract.ts"; /** - * 주제·프로젝트 관리 표면. + * 주제·프로젝트·릴리즈 관리 표면. * *

Studio 게이트웨이와 같은 실패 규약이다 — 실패는 던지고, 화면이 잡는다. MOCK 대응물을 두지 * 않는 것도 의도다: 이 표면은 백엔드가 없으면 존재할 이유가 없고, 픽스처를 만들면 실제로는 만들 @@ -23,4 +27,11 @@ export type ManagementGateway = Readonly<{ createProject(title: string): Promise; updateProject(id: string, body: ProjectUpdateRequest): Promise; deleteProject(id: string, expectedVersion: number): Promise; + listReleases(page?: number, size?: number): Promise; + getRelease(id: string): Promise; + createRelease(title: string): Promise; + updateRelease(id: string, body: ReleaseUpdateRequest): Promise; + deleteRelease(id: string, expectedVersion: number): Promise; + publishRelease(id: string, expectedVersion: number): Promise; + archiveRelease(id: string, expectedVersion: number): Promise; }>; diff --git a/src/features/tech-log/contracts/management/canonical-source.json b/src/features/tech-log/contracts/management/canonical-source.json index bf05689..ab3996c 100644 --- a/src/features/tech-log/contracts/management/canonical-source.json +++ b/src/features/tech-log/contracts/management/canonical-source.json @@ -1,8 +1,8 @@ { "packageId": "@tech-log/management-contract", "version": "1.0.0", - "digest": "sha256:ec5e432215fb041abee980787366a6db29ff1ecdd78416aa9c61e09b9b91022f", - "sourceRevision": "6ef5c1c", + "digest": "sha256:e16a9695e9034a273c769792ea8901fd706c61514137228b2c20e1b117ea0c01", + "sourceRevision": "0c10a4a", "operationIds": [ "createCaseDraft", "getCaseForEdit", diff --git a/src/features/tech-log/contracts/management/contract.ts b/src/features/tech-log/contracts/management/contract.ts index 4984668..afc604c 100644 --- a/src/features/tech-log/contracts/management/contract.ts +++ b/src/features/tech-log/contracts/management/contract.ts @@ -10,3 +10,8 @@ export type ProjectUpdateRequest = Schemas["ProjectUpdateRequest"]; export type CreateDraftRequest = Schemas["CreateDraftRequest"]; export type CreateDraftResponse = Schemas["CreateDraftResponse"]; export type ExpectedVersionRequest = Schemas["ExpectedVersionRequest"]; +export type ReleaseEditResponse = Schemas["ReleaseEditResponse"]; +export type ReleaseIndexItem = Schemas["ReleaseIndexItem"]; +export type ReleaseIndexPage = Schemas["ReleaseIndexPage"]; +export type ReleaseUpdateRequest = Schemas["ReleaseUpdateRequest"]; +export type PublishResponse = Schemas["PublishResponse"]; diff --git a/src/features/tech-log/contracts/management/generated.ts b/src/features/tech-log/contracts/management/generated.ts index a078080..5bfc9e2 100644 --- a/src/features/tech-log/contracts/management/generated.ts +++ b/src/features/tech-log/contracts/management/generated.ts @@ -902,7 +902,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" | "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" | "INTERNAL_ERROR"; /** @enum {string} */ category: "VALIDATION" | "AUTH" | "AUTHZ" | "NOT_FOUND" | "CONFLICT" | "RATE_LIMIT" | "TRANSIENT_DEPENDENCY" | "PERMANENT_DEPENDENCY" | "DATA_INTEGRITY" | "INTERNAL"; message: string; @@ -946,6 +946,24 @@ export interface components { data: components["schemas"]["ProjectIndexPage"]; meta: components["schemas"]["ResponseMeta"]; }; + ReleaseIndexPageEnvelope: { + /** @constant */ + success: true; + data: components["schemas"]["ReleaseIndexPage"]; + meta: components["schemas"]["ResponseMeta"]; + }; + ReleaseEditResponseEnvelope: { + /** @constant */ + success: true; + data: components["schemas"]["ReleaseEditResponse"]; + meta: components["schemas"]["ResponseMeta"]; + }; + PublishResponseEnvelope: { + /** @constant */ + success: true; + data: components["schemas"]["PublishResponse"]; + meta: components["schemas"]["ResponseMeta"]; + }; ProjectEditResponseEnvelope: { /** @constant */ success: true; @@ -6267,7 +6285,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ReleaseIndexPage"]; + "application/json": components["schemas"]["ReleaseIndexPageEnvelope"]; }; }; /** @description Bad Request */ @@ -6276,7 +6294,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Unauthorized */ @@ -6285,7 +6303,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Forbidden */ @@ -6294,7 +6312,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Not Found */ @@ -6303,7 +6321,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Internal Server Error */ @@ -6312,7 +6330,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; }; @@ -6338,7 +6356,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CreateDraftResponse"]; + "application/json": components["schemas"]["CreateDraftResponseEnvelope"]; }; }; /** @description Bad Request */ @@ -6347,7 +6365,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Unauthorized */ @@ -6356,7 +6374,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Forbidden */ @@ -6365,7 +6383,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Not Found */ @@ -6374,7 +6392,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Conflict */ @@ -6383,7 +6401,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Unprocessable Content */ @@ -6392,7 +6410,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Internal Server Error */ @@ -6401,7 +6419,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; }; @@ -6423,7 +6441,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ReleaseEditResponse"]; + "application/json": components["schemas"]["ReleaseEditResponseEnvelope"]; }; }; /** @description Bad Request */ @@ -6432,7 +6450,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Unauthorized */ @@ -6441,7 +6459,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Forbidden */ @@ -6450,7 +6468,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Not Found */ @@ -6459,7 +6477,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Internal Server Error */ @@ -6468,7 +6486,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; }; @@ -6496,7 +6514,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ReleaseEditResponse"]; + "application/json": components["schemas"]["ReleaseEditResponseEnvelope"]; }; }; /** @description Bad Request */ @@ -6505,7 +6523,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Unauthorized */ @@ -6514,7 +6532,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Forbidden */ @@ -6523,7 +6541,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Not Found */ @@ -6532,7 +6550,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Conflict */ @@ -6541,7 +6559,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Unprocessable Content */ @@ -6550,7 +6568,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Internal Server Error */ @@ -6559,7 +6577,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; }; @@ -6594,7 +6612,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Unauthorized */ @@ -6603,7 +6621,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Forbidden */ @@ -6612,7 +6630,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Not Found */ @@ -6621,7 +6639,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Conflict */ @@ -6630,7 +6648,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Unprocessable Content */ @@ -6639,7 +6657,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Internal Server Error */ @@ -6648,7 +6666,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; }; @@ -6676,7 +6694,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["PublishResponse"]; + "application/json": components["schemas"]["PublishResponseEnvelope"]; }; }; /** @description Bad Request */ @@ -6685,7 +6703,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Unauthorized */ @@ -6694,7 +6712,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Forbidden */ @@ -6703,7 +6721,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Not Found */ @@ -6712,7 +6730,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Conflict */ @@ -6721,7 +6739,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Unprocessable Content */ @@ -6730,7 +6748,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Internal Server Error */ @@ -6739,7 +6757,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; }; @@ -6767,7 +6785,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ReleaseEditResponse"]; + "application/json": components["schemas"]["ReleaseEditResponseEnvelope"]; }; }; /** @description Bad Request */ @@ -6776,7 +6794,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Unauthorized */ @@ -6785,7 +6803,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Forbidden */ @@ -6794,7 +6812,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Not Found */ @@ -6803,7 +6821,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Conflict */ @@ -6812,7 +6830,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Unprocessable Content */ @@ -6821,7 +6839,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Internal Server Error */ @@ -6830,7 +6848,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; }; diff --git a/src/features/tech-log/contracts/management/management-api.openapi.yaml b/src/features/tech-log/contracts/management/management-api.openapi.yaml index 0a65f79..46e7563 100644 --- a/src/features/tech-log/contracts/management/management-api.openapi.yaml +++ b/src/features/tech-log/contracts/management/management-api.openapi.yaml @@ -5,11 +5,11 @@ info: description: |- ⚠ 봉투 결정(ADR-006) 부분 반영 — 이 파일에는 두 모양이 공존한다. - topics 4개와 projects 5개는 studio-v1.yaml과 같은 방식으로 변환했다 - (`application/json` + ErrorEnvelope / Envelope). 그 9개가 첫 구현 - 대상이자 첫 소비자이기 때문이다. + topics 4개, projects 5개, releases 7개를 studio-v1.yaml과 같은 방식으로 + 변환했다 (`application/json` + ErrorEnvelope / Envelope). 그 16개가 + 구현된 것이자 소비자가 있는 것이기 때문이다. - 나머지 70개는 아직 bare payload + `application/problem+json` + ProblemDetails + 나머지 63개는 아직 bare payload + `application/problem+json` + ProblemDetails 다. 구현에 착수할 때 같은 방식으로 따라온다 — 소비자가 없는 operation을 미리 변환해 두면 검증되지 않은 모양이 계약에 고정된다. @@ -3630,49 +3630,49 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/CreateDraftResponse' + $ref: '#/components/schemas/CreateDraftResponseEnvelope' '400': description: Bad Request content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '401': description: Unauthorized content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '403': description: Forbidden content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '404': description: Not Found content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '409': description: Conflict content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '422': description: Unprocessable Content content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '500': description: Internal Server Error content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' requestBody: required: true content: @@ -3705,37 +3705,37 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/ReleaseIndexPage' + $ref: '#/components/schemas/ReleaseIndexPageEnvelope' '400': description: Bad Request content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '401': description: Unauthorized content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '403': description: Forbidden content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '404': description: Not Found content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '500': description: Internal Server Error content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' security: - sessionCookie: [] /api/v1/studio/releases/{id}: @@ -3756,37 +3756,37 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/ReleaseEditResponse' + $ref: '#/components/schemas/ReleaseEditResponseEnvelope' '400': description: Bad Request content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '401': description: Unauthorized content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '403': description: Forbidden content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '404': description: Not Found content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '500': description: Internal Server Error content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' security: - sessionCookie: [] put: @@ -3807,49 +3807,49 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/ReleaseEditResponse' + $ref: '#/components/schemas/ReleaseEditResponseEnvelope' '400': description: Bad Request content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '401': description: Unauthorized content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '403': description: Forbidden content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '404': description: Not Found content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '409': description: Conflict content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '422': description: Unprocessable Content content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '500': description: Internal Server Error content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' requestBody: required: true content: @@ -3876,45 +3876,45 @@ paths: '400': description: Bad Request content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '401': description: Unauthorized content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '403': description: Forbidden content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '404': description: Not Found content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '409': description: Conflict content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '422': description: Unprocessable Content content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '500': description: Internal Server Error content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' requestBody: required: true content: @@ -3942,49 +3942,49 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/PublishResponse' + $ref: '#/components/schemas/PublishResponseEnvelope' '400': description: Bad Request content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '401': description: Unauthorized content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '403': description: Forbidden content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '404': description: Not Found content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '409': description: Conflict content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '422': description: Unprocessable Content content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '500': description: Internal Server Error content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' requestBody: required: true content: @@ -4012,49 +4012,49 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/ReleaseEditResponse' + $ref: '#/components/schemas/ReleaseEditResponseEnvelope' '400': description: Bad Request content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '401': description: Unauthorized content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '403': description: Forbidden content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '404': description: Not Found content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '409': description: Conflict content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '422': description: Unprocessable Content content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '500': description: Internal Server Error content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' requestBody: required: true content: @@ -5267,6 +5267,9 @@ components: - PROJECT_NOT_FOUND - PROJECT_SLUG_TAKEN - PROJECT_IN_USE + - RELEASE_NOT_FOUND + - RELEASE_VERSION_TAKEN + - RELEASE_NOT_PUBLISHABLE - INTERNAL_ERROR description: '`INTERNAL_ERROR` 는 이 기능이 아니라 스켈레톤의 공통 처리기가 내는 코드다. 계약이 그것까지 열거해야 500 응답이 계약을 벗어나지 않는다.' category: @@ -5392,6 +5395,51 @@ components: $ref: '#/components/schemas/ProjectIndexPage' meta: $ref: '#/components/schemas/ResponseMeta' + ReleaseIndexPageEnvelope: + type: object + additionalProperties: false + required: + - success + - data + - meta + properties: + success: + type: boolean + const: true + data: + $ref: '#/components/schemas/ReleaseIndexPage' + meta: + $ref: '#/components/schemas/ResponseMeta' + ReleaseEditResponseEnvelope: + type: object + additionalProperties: false + required: + - success + - data + - meta + properties: + success: + type: boolean + const: true + data: + $ref: '#/components/schemas/ReleaseEditResponse' + meta: + $ref: '#/components/schemas/ResponseMeta' + PublishResponseEnvelope: + type: object + additionalProperties: false + required: + - success + - data + - meta + properties: + success: + type: boolean + const: true + data: + $ref: '#/components/schemas/PublishResponse' + meta: + $ref: '#/components/schemas/ResponseMeta' ProjectEditResponseEnvelope: type: object additionalProperties: false diff --git a/src/features/tech-log/contracts/public-site-config.ts b/src/features/tech-log/contracts/public-site-config.ts index 97a27b4..7400033 100644 --- a/src/features/tech-log/contracts/public-site-config.ts +++ b/src/features/tech-log/contracts/public-site-config.ts @@ -4,5 +4,8 @@ export const publicSiteConfig = Object.freeze({ operator: "동현", contactLabel: "프로필", contactPath: "/profile", - latestRelease: "/releases/0.1.0", + // 변경 기록 목록. 이전에는 특정 버전(`/releases/0.1.0`)을 박아 두었는데, 그 릴리즈가 아직 + // 없어서 푸터 링크가 404 였고, 있었더라도 다음 버전이 나오면 다시 낡는 자리였다. 목록은 어떤 + // 버전이 최신인지 아는 유일한 곳이고 릴리즈가 하나도 없어도 성립한다. + releasesPath: "/releases", }); diff --git a/src/features/tech-log/contracts/public/canonical-source.json b/src/features/tech-log/contracts/public/canonical-source.json index 1b36765..ddea845 100644 --- a/src/features/tech-log/contracts/public/canonical-source.json +++ b/src/features/tech-log/contracts/public/canonical-source.json @@ -2,7 +2,7 @@ "packageId": "@tech-log/public-contract", "version": "2.0.0", "digest": "sha256:8ac71425b38658f34641102b4c2e6e21288c811efebdb92c0a46fb9d4790e23e", - "sourceRevision": "6ef5c1c", + "sourceRevision": "0c10a4a", "operationIds": [ "getPublicSite", "getPublicHome", diff --git a/src/features/tech-log/contracts/studio/canonical-source.json b/src/features/tech-log/contracts/studio/canonical-source.json index cf93a8e..d0cbe28 100644 --- a/src/features/tech-log/contracts/studio/canonical-source.json +++ b/src/features/tech-log/contracts/studio/canonical-source.json @@ -2,7 +2,7 @@ "packageId": "@tech-log/studio-contract", "version": "3.0.0", "digest": "sha256:6cae9924403d0761f401643a022980b8e04183eea0d890c143c9fbbbbc7431e4", - "sourceRevision": "6ef5c1c", + "sourceRevision": "0c10a4a", "operationIds": [ "getStudioSession", "getStudioDashboard", diff --git a/src/features/tech-log/contracts/tech-log-management-contract-contribution.ts b/src/features/tech-log/contracts/tech-log-management-contract-contribution.ts index 17275c6..d8b2248 100644 --- a/src/features/tech-log/contracts/tech-log-management-contract-contribution.ts +++ b/src/features/tech-log/contracts/tech-log-management-contract-contribution.ts @@ -31,6 +31,9 @@ const MANAGEMENT_ERROR_CODES = Object.freeze([ "PROJECT_NOT_FOUND", "PROJECT_SLUG_TAKEN", "PROJECT_IN_USE", + "RELEASE_NOT_FOUND", + "RELEASE_VERSION_TAKEN", + "RELEASE_NOT_PUBLISHABLE", "INTERNAL_ERROR", ]); @@ -155,6 +158,7 @@ const byId = (input: never) => { const T = "/api/v1/studio/topics"; const P = "/api/v1/studio/projects"; +const R = "/api/v1/studio/releases"; const HTTP_CONTRACTS = Object.freeze([ readOperation("listStudioTopics", T, 131_072), @@ -248,6 +252,84 @@ const HTTP_CONTRACTS = Object.freeze([ }); }, ), + readOperation("listStudioReleases", R, 262_144, (input: never) => { + const value = input as unknown as Readonly<{ page?: number; size?: number }> | undefined; + const entries: (readonly [string, string])[] = []; + if (value?.page !== undefined) entries.push(["page", String(value.page)]); + if (value?.size !== undefined) entries.push(["size", String(value.size)]); + return Object.freeze({ pathValues: NO_PATH, queryEntries: Object.freeze(entries) }); + }), + readOperation("getReleaseForEdit", `${R}/{id}`, 262_144, byId), + writeOperation( + "createRelease", + "POST", + R, + { acceptedStatuses: [201], requestByteLimit: 4_096, responseByteLimit: 8_192 }, + (input: never) => + Object.freeze({ pathValues: NO_PATH, queryEntries: NO_QUERY, body: input }), + ), + writeOperation( + "updateRelease", + "PUT", + `${R}/{id}`, + // 릴리즈 본문은 마크다운 여섯 구획이라 문서 다음으로 큰 요청이다. + { acceptedStatuses: [200], requestByteLimit: 262_144, responseByteLimit: 262_144 }, + (input: never) => { + const value = input as unknown as Readonly<{ id: string; body: unknown }>; + return Object.freeze({ + pathValues: Object.freeze({ id: value.id }), + queryEntries: NO_QUERY, + body: value.body, + }); + }, + ), + writeOperation( + "deleteRelease", + "DELETE", + `${R}/{id}`, + { + acceptedStatuses: [204], + emptyBodyStatuses: [204], + requestByteLimit: 1_024, + responseByteLimit: 1_024, + }, + (input: never) => { + const value = input as unknown as Readonly<{ id: string; expectedVersion: number }>; + return Object.freeze({ + pathValues: Object.freeze({ id: value.id }), + queryEntries: NO_QUERY, + body: { expectedVersion: value.expectedVersion }, + }); + }, + ), + writeOperation( + "publishRelease", + "POST", + `${R}/{id}/publish`, + { acceptedStatuses: [200], requestByteLimit: 1_024, responseByteLimit: 8_192 }, + (input: never) => { + const value = input as unknown as Readonly<{ id: string; expectedVersion: number }>; + return Object.freeze({ + pathValues: Object.freeze({ id: value.id }), + queryEntries: NO_QUERY, + body: { expectedVersion: value.expectedVersion }, + }); + }, + ), + writeOperation( + "archiveRelease", + "POST", + `${R}/{id}/archive`, + { acceptedStatuses: [200], requestByteLimit: 1_024, responseByteLimit: 262_144 }, + (input: never) => { + const value = input as unknown as Readonly<{ id: string; expectedVersion: number }>; + return Object.freeze({ + pathValues: Object.freeze({ id: value.id }), + queryEntries: NO_QUERY, + body: { expectedVersion: value.expectedVersion }, + }); + }, + ), ]); export const TECH_LOG_MANAGEMENT_OPERATION_IDS = Object.freeze( diff --git a/src/features/tech-log/contracts/tech-log-route-contract.ts b/src/features/tech-log/contracts/tech-log-route-contract.ts index 6c5292e..836907b 100644 --- a/src/features/tech-log/contracts/tech-log-route-contract.ts +++ b/src/features/tech-log/contracts/tech-log-route-contract.ts @@ -49,6 +49,7 @@ const TECH_LOG_ROUTE_SPECS = [ defineSpec({ routeId: "TECH_LOG_STUDIO_PUBLICATION_PREVIEW", path: "/studio/publications/:publicationEventId/preview", layoutGroup: "STUDIO", paramsSchema: "TechLogPublicationEventIdParams", searchSchema: null, title: "게시 Snapshot", navigationLabel: null, navigationOrder: null }), defineSpec({ routeId: "TECH_LOG_STUDIO_ASSETS", path: "/studio/assets", layoutGroup: "STUDIO", paramsSchema: null, searchSchema: null, title: "Asset", navigationLabel: null, navigationOrder: null }), defineSpec({ routeId: "TECH_LOG_STUDIO_TAXONOMY", path: "/studio/taxonomy", layoutGroup: "STUDIO", paramsSchema: null, searchSchema: null, title: "주제와 프로젝트", navigationLabel: "주제·프로젝트", navigationOrder: 40 }), + defineSpec({ routeId: "TECH_LOG_STUDIO_RELEASES", path: "/studio/releases", layoutGroup: "STUDIO", paramsSchema: null, searchSchema: null, title: "릴리즈", navigationLabel: "릴리즈", navigationOrder: 50 }), defineSpec({ routeId: "TECH_LOG_STUDIO_NOT_FOUND", path: "/studio/*", layoutGroup: "STUDIO", paramsSchema: "TechLogStudioSplat", searchSchema: null, title: "Studio 화면을 찾을 수 없습니다", navigationLabel: null, navigationOrder: null }), defineSpec({ routeId: "NOT_FOUND", path: "*", layoutGroup: "PUBLIC", paramsSchema: "NotFoundSplat", searchSchema: null, title: "페이지를 찾을 수 없습니다.", navigationLabel: null, navigationOrder: null }), ] as const; diff --git a/src/features/tech-log/presentation/public/public-shell.tsx b/src/features/tech-log/presentation/public/public-shell.tsx index 2a8d72e..e9b5c7e 100644 --- a/src/features/tech-log/presentation/public/public-shell.tsx +++ b/src/features/tech-log/presentation/public/public-shell.tsx @@ -29,7 +29,7 @@ export function PublicShell({ children, currentPath }: PublicShellProps) { {publicSiteConfig.contactLabel} - 최신 Release + 변경 기록 diff --git a/src/features/tech-log/presentation/studio/components/release-manager.tsx b/src/features/tech-log/presentation/studio/components/release-manager.tsx new file mode 100644 index 0000000..72c895f --- /dev/null +++ b/src/features/tech-log/presentation/studio/components/release-manager.tsx @@ -0,0 +1,427 @@ +import { useCallback, useEffect, useState, type FormEvent } from "react"; + +import type { + ReleaseEditResponse, + ReleaseIndexItem, + ReleaseUpdateRequest, +} from "../../../contracts/management/contract.ts"; +import { useStudio } from "../use-studio.ts"; + +/** + * 릴리즈 작성. + * + *

공개 사이트에 변경 기록 화면과 푸터 링크가 있는데 둘 다 비어 있었다 — 읽기는 처음부터 + * 있었고 쓰는 곳이 없었다. 그래서 이 화면의 범위는 목록·작성·발행이다. + * + *

본문이 마크다운 여섯 칸으로 나뉜 것은 계약의 모양이자 릴리즈 노트의 성격이다. 한 칸짜리 + * 자유 서술이었다면 "검증을 안 썼다"를 아무도 알아채지 못한다. + * + *

새 CSS 를 만들지 않는다 — 문서 편집기가 쓰는 field 클래스와 작업본 목록의 행 클래스를 그대로 + * 재사용하므로 Studio 의 나머지와 같은 간격·타이포·색을 따른다. + */ + +/** 백엔드 {@code UpdateReleaseUseCase.CHANGE_TYPES} 와 같은 집합이다. */ +const CHANGE_TYPES = [ + { value: "FEATURE", label: "기능" }, + { value: "FIX", label: "수정" }, + { value: "REFACTOR", label: "구조" }, + { value: "DOCS", label: "문서" }, + { value: "INFRA", label: "인프라" }, + { value: "BREAKING", label: "호환 깨짐" }, +] as const; + +const SECTIONS = [ + { key: "reasonMarkdown", label: "왜 바꿨나" }, + { key: "changesMarkdown", label: "무엇을 바꿨나" }, + { key: "userImpactMarkdown", label: "사용자에게 달라지는 것" }, + { key: "implementationImpactMarkdown", label: "구현에 남는 것" }, + { key: "verificationMarkdown", label: "어떻게 검증했나" }, + { key: "knownLimitationsMarkdown", label: "아직 못 한 것" }, +] as const; + +type Draft = Readonly<{ + expectedVersion: number; + versionLabel: string; + title: string; + summary: string; + releasedOn: string; + changeTypes: readonly string[]; + reasonMarkdown: string; + changesMarkdown: string; + userImpactMarkdown: string; + implementationImpactMarkdown: string; + verificationMarkdown: string; + knownLimitationsMarkdown: string; +}>; + +function toDraft(release: ReleaseEditResponse): Draft { + return { + expectedVersion: release.version, + versionLabel: release.versionLabel, + title: release.title, + summary: release.summary ?? "", + releasedOn: release.releasedOn ?? "", + changeTypes: release.changeTypes ?? [], + reasonMarkdown: release.reasonMarkdown ?? "", + changesMarkdown: release.changesMarkdown ?? "", + userImpactMarkdown: release.userImpactMarkdown ?? "", + implementationImpactMarkdown: release.implementationImpactMarkdown ?? "", + verificationMarkdown: release.verificationMarkdown ?? "", + knownLimitationsMarkdown: release.knownLimitationsMarkdown ?? "", + }; +} + +const STATUS_LABELS: Readonly> = { + DRAFT: "작성 중", + PUBLISHED: "공개", + ARCHIVED: "보관", +}; + +export function ReleaseManager() { + const { managementGateway, setRequestAnnouncement } = useStudio(); + const [releases, setReleases] = useState(null); + const [selectedId, setSelectedId] = useState(null); + const [draft, setDraft] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [pending, setPending] = useState(false); + const [generation, setGeneration] = useState(0); + const [newTitle, setNewTitle] = useState(""); + + const reload = useCallback(() => setGeneration((value) => value + 1), []); + + useEffect(() => { + let cancelled = false; + setLoading(true); + setError(""); + void managementGateway.listReleases(0, 50).then( + (page) => { + if (cancelled) return; + setReleases(page.items ?? []); + setLoading(false); + }, + () => { + if (cancelled) return; + setError("릴리즈를 불러오지 못했습니다."); + setLoading(false); + }, + ); + return () => { + cancelled = true; + }; + }, [managementGateway, generation]); + + // 선택된 릴리즈의 본문은 목록에 없다 — 목록 행은 마크다운을 싣지 않으므로 따로 읽는다. + useEffect(() => { + if (selectedId === null) { + setDraft(null); + return undefined; + } + let cancelled = false; + void managementGateway.getRelease(selectedId).then( + (release) => { + if (!cancelled) setDraft(toDraft(release)); + }, + () => { + if (!cancelled) setError("릴리즈를 불러오지 못했습니다."); + }, + ); + return () => { + cancelled = true; + }; + }, [managementGateway, selectedId, generation]); + + const update = (patch: Partial) => + setDraft((current) => (current === null ? current : { ...current, ...patch })); + + const submitNew = async (event: FormEvent) => { + event.preventDefault(); + if (pending) return; + const title = newTitle.trim(); + if (!title) { + setError("릴리즈 제목을 입력해 주세요."); + return; + } + setPending(true); + setError(""); + try { + const created = await managementGateway.createRelease(title); + setNewTitle(""); + setRequestAnnouncement(`릴리즈 ${title} 초안을 만들었습니다.`); + setSelectedId(created.id); + reload(); + } catch { + setError("릴리즈를 만들지 못했습니다."); + } finally { + setPending(false); + } + }; + + const save = async () => { + if (pending || draft === null || selectedId === null) return; + setPending(true); + setError(""); + try { + const body: ReleaseUpdateRequest = { + expectedVersion: draft.expectedVersion, + versionLabel: draft.versionLabel.trim(), + title: draft.title.trim(), + summary: draft.summary, + changeTypes: [...draft.changeTypes], + changesMarkdown: draft.changesMarkdown, + verificationMarkdown: draft.verificationMarkdown, + ...(draft.releasedOn ? { releasedOn: draft.releasedOn } : {}), + reasonMarkdown: draft.reasonMarkdown, + userImpactMarkdown: draft.userImpactMarkdown, + implementationImpactMarkdown: draft.implementationImpactMarkdown, + knownLimitationsMarkdown: draft.knownLimitationsMarkdown, + } as ReleaseUpdateRequest; + const saved = await managementGateway.updateRelease(selectedId, body); + setDraft(toDraft(saved)); + setRequestAnnouncement(`릴리즈 ${saved.versionLabel} 을(를) 저장했습니다.`); + reload(); + } catch { + setError("저장하지 못했습니다. 같은 버전이 이미 있거나 다른 곳에서 먼저 수정되었을 수 있습니다."); + } finally { + setPending(false); + } + }; + + const publish = async () => { + if (pending || draft === null || selectedId === null) return; + setPending(true); + setError(""); + try { + const published = await managementGateway.publishRelease(selectedId, draft.expectedVersion); + setRequestAnnouncement(`릴리즈를 공개했습니다: ${published.canonicalPath}`); + reload(); + } catch { + // 발행은 저장보다 요구가 많다. 무엇이 비었는지는 서버가 알고 있지만, 그 목록을 그대로 + // 옮기려면 오류 details 를 읽는 화면이 필요하다 — 여기서는 필수 항목을 그대로 안내한다. + setError( + "공개하지 못했습니다. 버전, 제목, 한 줄 요약, 변경 유형, 변경 내용, 검증, 공개일이 모두 채워져야 합니다.", + ); + } finally { + setPending(false); + } + }; + + const archive = async () => { + if (pending || draft === null || selectedId === null) return; + setPending(true); + setError(""); + try { + await managementGateway.archiveRelease(selectedId, draft.expectedVersion); + setRequestAnnouncement("릴리즈를 공개에서 내렸습니다."); + reload(); + } catch { + setError("공개에서 내리지 못했습니다."); + } finally { + setPending(false); + } + }; + + const remove = async (release: ReleaseIndexItem) => { + if (pending) return; + setPending(true); + setError(""); + try { + await managementGateway.deleteRelease(release.id, release.version); + if (selectedId === release.id) setSelectedId(null); + setRequestAnnouncement(`릴리즈 ${release.versionLabel} 을(를) 삭제했습니다.`); + reload(); + } catch { + setError("삭제하지 못했습니다. 공개된 릴리즈는 삭제 대신 공개에서 내려야 합니다."); + } finally { + setPending(false); + } + }; + + const toggleChangeType = (value: string) => { + if (draft === null) return; + const next = draft.changeTypes.includes(value) + ? draft.changeTypes.filter((entry) => entry !== value) + : [...draft.changeTypes, value]; + update({ changeTypes: next }); + }; + + return ( +

+
+
+

RELEASES

+

릴리즈

+

무엇을 왜 바꿨는지 버전으로 묶어 공개 변경 기록에 남깁니다.

+
+
+ +
+
+ +
+ setNewTitle(event.target.value)} + /> + +
+
+
+ + {error ? ( +

+ {error} +

+ ) : null} + + {loading ? ( +

불러오는 중입니다.

+ ) : (releases?.length ?? 0) === 0 ? ( +

아직 릴리즈가 없습니다.

+ ) : ( +
+

{releases?.length}개의 릴리즈

+
+ {(releases ?? []).map((release) => ( +
+
+

{release.title}

+

+ {release.versionLabel} + {release.releasedOn ? ` · ${release.releasedOn}` : ""} +

+
+
+
+
상태
+
{STATUS_LABELS[release.workflowStatus] ?? release.workflowStatus}
+
+
+ + +
+ ))} +
+
+ )} + + {draft === null ? null : ( +
+

{draft.title || "릴리즈"} 편집

+
+ + + +