From 16e5b9f4ec634d906565cfec53d2345f5f1bc37a Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Sun, 23 Aug 2026 19:45:00 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=94=84=EB=A1=9C=EC=A0=9D=ED=8A=B8?= =?UTF-8?q?=EB=A5=BC=20=ED=8E=B8=EC=A7=91=ED=95=98=EA=B3=A0=20=ED=99=9C?= =?UTF-8?q?=EB=8F=99=EC=9D=84=20=EB=82=A8=EA=B8=B8=20=EC=88=98=20=EC=9E=88?= =?UTF-8?q?=EA=B2=8C=20=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 홈의 집중 카드가 제목만 보여 주고 "현재 목표"·"다음 작업" 칸이 비어 있었다. 프로젝트 페이지의 "활동" 도 늘 비어 있었다. 프로젝트를 만들 수는 있었지만 고칠 화면이 없었다. 그래서 이름과 slug 말고는 아무 값도 가질 수 없었고, 그 값을 읽는 공개 화면들은 빈칸을 그렸다 — 백엔드의 `updateProject` 는 처음부터 구현돼 있었고 채울 화면만 없었다. `/studio/projects/:id` 를 연다. 프로젝트 필드 전부와 활동 목록을 한 화면에 둔다 — 프로젝트 밖의 활동은 존재하지 않고, 무엇이 공개 타임라인에 실리는지를 프로젝트 필드와 같은 자리에서 보는 편이 낫다. 이미 공개된 프로젝트는 저장한 뒤 투영도 함께 갱신한다. 투영은 게시할 때 세워지므로, 저장만 하면 공개 화면에는 예전 값이 남는다. 단계와 활동 유형의 선택지는 DB CHECK 제약과 같은 목록이다. 화면이 더 많은 값을 보여 주면 저장이 제약에서 터지고, 작성자는 왜 안 되는지 알 수 없다. 라우트가 하나 늘어 CI 게이트가 함께 움직였다 — FE-GATE-009 는 설치된 라우트마다 수동 접근성 증거를 하나씩 요구하고 그 집합이 정확히 일치하지 않으면 거절한다. 아티팩트 기준선 132→133, 증거 개수 111→112, 게이트 형태 다이제스트 재계산(이전 상수 187dbd96… 을 이전 gates.json 에서 먼저 재현해 계산 방법을 확인했다), 서빙 패턴 하나 추가. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu --- .../TECH_LOG_STUDIO_PROJECT_EDIT.md | 18 + config/ci/gates.json | 7 + public/release-manifest.json | 10 +- scripts/contracts/ci-gates.ts | 12 +- .../adapters/http/http-management-gateway.ts | 15 + .../application/ports/management-gateway.ts | 15 + .../management/canonical-source.json | 7 +- .../tech-log/contracts/management/contract.ts | 3 + .../contracts/management/generated.ts | 151 +++++- .../management/management-api.openapi.yaml | 189 ++++++-- .../contracts/public/canonical-source.json | 2 +- .../contracts/studio/canonical-source.json | 2 +- ...ch-log-management-contract-contribution.ts | 56 +++ .../contracts/tech-log-route-contract.ts | 1 + .../studio/components/project-editor.tsx | 445 ++++++++++++++++++ .../studio/components/taxonomy-manager.tsx | 7 +- .../studio/pages/project-edit-page.tsx | 5 + .../presentation/tech-log-route-runtime.tsx | 7 + tests/unit/ci-workflow-generation.test.ts | 4 +- .../unit/task3-selective-integration.test.ts | 2 +- tests/unit/tech-log-serving-contract.test.ts | 1 + vite.config.ts | 1 + 22 files changed, 880 insertions(+), 80 deletions(-) create mode 100644 artifacts/tests/a11y-manual/TECH_LOG_STUDIO_PROJECT_EDIT.md create mode 100644 src/features/tech-log/presentation/studio/components/project-editor.tsx create mode 100644 src/features/tech-log/presentation/studio/pages/project-edit-page.tsx diff --git a/artifacts/tests/a11y-manual/TECH_LOG_STUDIO_PROJECT_EDIT.md b/artifacts/tests/a11y-manual/TECH_LOG_STUDIO_PROJECT_EDIT.md new file mode 100644 index 0000000..b9fdf06 --- /dev/null +++ b/artifacts/tests/a11y-manual/TECH_LOG_STUDIO_PROJECT_EDIT.md @@ -0,0 +1,18 @@ +# TECH_LOG_STUDIO_PROJECT_EDIT accessibility review + +Status: pending-manual-review +Route ID: TECH_LOG_STUDIO_PROJECT_EDIT +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 75d5dc9..259b9ac 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-PROJECT-EDIT-md", + "path": "artifacts/tests/a11y-manual/TECH_LOG_STUDIO_PROJECT_EDIT.md", + "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", @@ -1962,6 +1968,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-PROJECT-EDIT-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", diff --git a/public/release-manifest.json b/public/release-manifest.json index 42cad13..fa37ac8 100644 --- a/public/release-manifest.json +++ b/public/release-manifest.json @@ -38,28 +38,28 @@ }, "contractSet": { "setAlgorithm": "CA_CONTRACT_SET_V1", - "setDigest": "sha256:5fcc7f278c09fdca2a50a65ab669039c27a0a9a1b4e56062f1309f46a7a90ab7", + "setDigest": "sha256:9e51906d7d3b8c1b6a475ea0bd2f6bb28aadba832a29e13f635b5b5acefa2d8e", "packages": [ { "packageId": "@tech-log/management-contract", "version": "1.0.0", - "digest": "sha256:c143ad3303eb05a02320940e2a0f5300a38b0c05f40b5bd5cf30960ef8004573", + "digest": "sha256:72650735061fde627f5037571eb986cb758f44a546f065c88408399f8eec4a55", "runtimeProtocolVersion": 1, - "sourceRevision": "ed04872" + "sourceRevision": "436937f" }, { "packageId": "@tech-log/public-contract", "version": "2.1.0", "digest": "sha256:37e6f804165de3e492e975075bea563ee41ae74076222a3562d3452631bfdb2b", "runtimeProtocolVersion": 1, - "sourceRevision": "ed04872" + "sourceRevision": "436937f" }, { "packageId": "@tech-log/studio-contract", "version": "3.1.0", "digest": "sha256:6fc015ca6727af88b7fb0088e02ba97846e1dd79fb0d4fc593cc79f2a3b9795f", "runtimeProtocolVersion": 1, - "sourceRevision": "ed04872" + "sourceRevision": "436937f" } ] } diff --git a/scripts/contracts/ci-gates.ts b/scripts/contracts/ci-gates.ts index 8ca6127..174600a 100644 --- a/scripts/contracts/ci-gates.ts +++ b/scripts/contracts/ci-gates.ts @@ -468,7 +468,10 @@ const CANONICAL_GATE_SHAPE_SHA256 = // 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"; + // 프로젝트 편집 화면: FE-GATE-009 가 TECH_LOG_STUDIO_PROJECT_EDIT 증거 아티팩트를 + // 얻어 다시 계산했다. 같은 방법이다 — 187dbd96… 을 이전 gates.json 에서 먼저 재현해, + // 이 값을 만든 계산이 상수가 고정될 때 쓰인 그 계산임을 확인했다. + "f9e7e521c1ea587c992f475e38f0c5f2ca39721970724529fa98678cc078a6c7"; function canonicalGateShapeSha256(gates: CiGateContract["gates"]): string { const normalized = gates.map( @@ -523,8 +526,11 @@ 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 !== 132) { - failures.push(`artifact authority baseline must contain exactly 132 artifacts; received ${contract.artifacts.length}`); + // The project edit route did the same: it is what finally lets a project carry + // a purpose, a current objective, and a next step, so the public screens that + // read those fields stop rendering blanks. + if (contract.artifacts.length !== 133) { + failures.push(`artifact authority baseline must contain exactly 133 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 3581b4d..33f55db 100644 --- a/src/features/tech-log/adapters/http/http-management-gateway.ts +++ b/src/features/tech-log/adapters/http/http-management-gateway.ts @@ -2,6 +2,9 @@ import type { CreateDraftResponse, HomeFocusRequest, HomeFocusResponse, + ProjectActivityRequest, + ProjectActivityResponse, + UpdateProjectActivityRequest, ProjectEditResponse, ProjectIndexPage, ProjectUpdateRequest, @@ -87,6 +90,18 @@ export function createHttpManagementGateway( deleteRelease: async (id: string, expectedVersion: number) => { await run("deleteRelease", { id, expectedVersion }); }, + listProjectActivities: (id: string) => + run("listStudioProjectActivities", { id }), + createProjectActivity: (id: string, body: ProjectActivityRequest) => + run("createProjectActivity", { id, body }), + updateProjectActivity: ( + id: string, + activityId: string, + body: UpdateProjectActivityRequest, + ) => run("updateProjectActivity", { id, activityId, body }), + deleteProjectActivity: async (id: string, activityId: string, expectedVersion: number) => { + await run("deleteProjectActivity", { id, activityId, expectedVersion }); + }, publishProject: ( id: string, expectedVersion: number, diff --git a/src/features/tech-log/application/ports/management-gateway.ts b/src/features/tech-log/application/ports/management-gateway.ts index a332b1a..29c7d1f 100644 --- a/src/features/tech-log/application/ports/management-gateway.ts +++ b/src/features/tech-log/application/ports/management-gateway.ts @@ -2,6 +2,9 @@ import type { CreateDraftResponse, HomeFocusRequest, HomeFocusResponse, + ProjectActivityRequest, + ProjectActivityResponse, + UpdateProjectActivityRequest, ProjectEditResponse, ProjectIndexPage, ProjectUpdateRequest, @@ -41,6 +44,18 @@ export type ManagementGateway = Readonly<{ visibility?: "PUBLIC" | "UNLISTED", ): Promise; unpublishProject(id: string, expectedVersion: number): Promise; + /** + * 프로젝트 활동. 공개 프로젝트 화면의 "활동" 은 이 목록을 투영 없이 직접 읽으므로, 여기서 + * 만든 줄이 곧 그 화면이다. + */ + listProjectActivities(id: string): Promise; + createProjectActivity(id: string, body: ProjectActivityRequest): Promise; + updateProjectActivity( + id: string, + activityId: string, + body: UpdateProjectActivityRequest, + ): Promise; + deleteProjectActivity(id: string, activityId: string, expectedVersion: number): Promise; /** 공개 홈이 무엇을 앞에 둘지. 세 슬롯이 모두 비면 홈은 그 영역을 아예 그리지 않는다. */ getHomeFocus(): Promise; updateHomeFocus(body: HomeFocusRequest): Promise; diff --git a/src/features/tech-log/contracts/management/canonical-source.json b/src/features/tech-log/contracts/management/canonical-source.json index df73590..d108862 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:c143ad3303eb05a02320940e2a0f5300a38b0c05f40b5bd5cf30960ef8004573", - "sourceRevision": "ed04872", + "digest": "sha256:72650735061fde627f5037571eb986cb758f44a546f065c88408399f8eec4a55", + "sourceRevision": "436937f", "operationIds": [ "createCaseDraft", "getCaseForEdit", @@ -83,6 +83,7 @@ "updateHomeFocus", "listStudioProjectActivities", "createProjectActivity", - "updateProjectActivity" + "updateProjectActivity", + "deleteProjectActivity" ] } diff --git a/src/features/tech-log/contracts/management/contract.ts b/src/features/tech-log/contracts/management/contract.ts index 6b6041e..79a0bab 100644 --- a/src/features/tech-log/contracts/management/contract.ts +++ b/src/features/tech-log/contracts/management/contract.ts @@ -17,3 +17,6 @@ export type ReleaseUpdateRequest = Schemas["ReleaseUpdateRequest"]; export type PublishResponse = Schemas["PublishResponse"]; export type HomeFocusRequest = Schemas["HomeFocusRequest"]; export type HomeFocusResponse = Schemas["HomeFocusResponse"]; +export type ProjectActivityRequest = Schemas["ProjectActivityRequest"]; +export type UpdateProjectActivityRequest = Schemas["UpdateProjectActivityRequest"]; +export type ProjectActivityResponse = Schemas["ProjectActivityResponse"]; diff --git a/src/features/tech-log/contracts/management/generated.ts b/src/features/tech-log/contracts/management/generated.ts index 456b347..431435a 100644 --- a/src/features/tech-log/contracts/management/generated.ts +++ b/src/features/tech-log/contracts/management/generated.ts @@ -883,7 +883,12 @@ export interface paths { get?: never; put: operations["updateProjectActivity"]; post?: never; - delete?: never; + /** @description 프로젝트 활동 한 줄을 지운다. 계약에는 만들기와 고치기만 있었고 지우기가 없어서, 잘못 + * 적은 줄이 공개 타임라인에 영구히 남았다. + * + * `AUTO` 로 기록된 줄은 게시 같은 실제 사건의 흔적이므로 지우지 않는다 — 지우면 무슨 일이 + * 있었는지가 사라진다. 손으로 적은 `MANUAL` 줄만 지울 수 있다. */ + delete: operations["deleteProjectActivity"]; options?: never; head?: never; patch?: never; @@ -969,6 +974,18 @@ export interface components { data: components["schemas"]["HomeFocusResponse"]; meta: components["schemas"]["ResponseMeta"]; }; + ProjectActivityResponseEnvelope: { + /** @constant */ + success: true; + data: components["schemas"]["ProjectActivityResponse"]; + meta: components["schemas"]["ResponseMeta"]; + }; + ProjectActivityListEnvelope: { + /** @constant */ + success: true; + data: components["schemas"]["ProjectActivityResponse"][]; + meta: components["schemas"]["ResponseMeta"]; + }; PublishResponseEnvelope: { /** @constant */ success: true; @@ -8279,7 +8296,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ProjectActivityResponse"][]; + "application/json": components["schemas"]["ProjectActivityListEnvelope"]; }; }; /** @description 401 */ @@ -8288,7 +8305,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description 403 */ @@ -8297,7 +8314,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description 404 */ @@ -8306,7 +8323,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description 500 */ @@ -8315,7 +8332,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; }; @@ -8343,7 +8360,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ProjectActivityResponse"]; + "application/json": components["schemas"]["ProjectActivityResponseEnvelope"]; }; }; /** @description 400 */ @@ -8352,7 +8369,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description 401 */ @@ -8361,7 +8378,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description 403 */ @@ -8370,7 +8387,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description 404 */ @@ -8379,7 +8396,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description 409 */ @@ -8388,7 +8405,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description 422 */ @@ -8397,7 +8414,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description 500 */ @@ -8406,7 +8423,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; }; @@ -8435,7 +8452,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ProjectActivityResponse"]; + "application/json": components["schemas"]["ProjectActivityResponseEnvelope"]; }; }; /** @description 400 */ @@ -8444,7 +8461,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description 401 */ @@ -8453,7 +8470,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description 403 */ @@ -8462,7 +8479,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description 404 */ @@ -8471,7 +8488,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description 409 */ @@ -8480,7 +8497,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description 422 */ @@ -8489,7 +8506,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description 500 */ @@ -8498,7 +8515,97 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + }; + }; + deleteProjectActivity: { + parameters: { + query?: never; + header: { + "X-CSRF-TOKEN": components["parameters"]["CsrfToken"]; + }; + path: { + id: string; + activityId: 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"]; }; }; }; 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 a4d366e..ad744a9 100644 --- a/src/features/tech-log/contracts/management/management-api.openapi.yaml +++ b/src/features/tech-log/contracts/management/management-api.openapi.yaml @@ -5101,33 +5101,31 @@ paths: content: application/json: schema: - type: array - items: - $ref: '#/components/schemas/ProjectActivityResponse' + $ref: '#/components/schemas/ProjectActivityListEnvelope' '401': description: '401' content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '403': description: '403' content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '404': description: '404' content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '500': description: '500' content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' post: operationId: createProjectActivity tags: @@ -5154,49 +5152,49 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/ProjectActivityResponse' + $ref: '#/components/schemas/ProjectActivityResponseEnvelope' '400': description: '400' content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '401': description: '401' content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '403': description: '403' content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '404': description: '404' content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '409': description: '409' content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '422': description: '422' content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '500': description: '500' content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' /api/v1/studio/projects/{id}/activities/{activityId}: put: operationId: updateProjectActivity @@ -5230,49 +5228,126 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/ProjectActivityResponse' + $ref: '#/components/schemas/ProjectActivityResponseEnvelope' '400': description: '400' content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '401': description: '401' content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '403': description: '403' content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '404': description: '404' content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '409': description: '409' content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '422': description: '422' content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '500': description: '500' content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' + delete: + operationId: deleteProjectActivity + tags: + - Projects + description: |- + 프로젝트 활동 한 줄을 지운다. 계약에는 만들기와 고치기만 있었고 지우기가 없어서, 잘못 + 적은 줄이 공개 타임라인에 영구히 남았다. + + `AUTO` 로 기록된 줄은 게시 같은 실제 사건의 흔적이므로 지우지 않는다 — 지우면 무슨 일이 + 있었는지가 사라진다. 손으로 적은 `MANUAL` 줄만 지울 수 있다. + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + - name: activityId + 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: [] components: securitySchemes: sessionCookie: @@ -5525,6 +5600,38 @@ components: $ref: '#/components/schemas/HomeFocusResponse' meta: $ref: '#/components/schemas/ResponseMeta' + ProjectActivityResponseEnvelope: + type: object + additionalProperties: false + required: + - success + - data + - meta + properties: + success: + type: boolean + const: true + data: + $ref: '#/components/schemas/ProjectActivityResponse' + meta: + $ref: '#/components/schemas/ResponseMeta' + ProjectActivityListEnvelope: + type: object + additionalProperties: false + required: + - success + - data + - meta + properties: + success: + type: boolean + const: true + data: + type: array + items: + $ref: '#/components/schemas/ProjectActivityResponse' + meta: + $ref: '#/components/schemas/ResponseMeta' PublishResponseEnvelope: type: object additionalProperties: false diff --git a/src/features/tech-log/contracts/public/canonical-source.json b/src/features/tech-log/contracts/public/canonical-source.json index df8f537..6e4293d 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.1.0", "digest": "sha256:37e6f804165de3e492e975075bea563ee41ae74076222a3562d3452631bfdb2b", - "sourceRevision": "ed04872", + "sourceRevision": "436937f", "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 39b2fe0..72ddc51 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.1.0", "digest": "sha256:6fc015ca6727af88b7fb0088e02ba97846e1dd79fb0d4fc593cc79f2a3b9795f", - "sourceRevision": "ed04872", + "sourceRevision": "436937f", "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 8dbae90..8f94d31 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 @@ -310,6 +310,62 @@ const HTTP_CONTRACTS = Object.freeze([ }); }, ), + readOperation("listStudioProjectActivities", `${P}/{id}/activities`, 262_144, byId), + writeOperation( + "createProjectActivity", + "POST", + `${P}/{id}/activities`, + { acceptedStatuses: [201], requestByteLimit: 16_384, responseByteLimit: 16_384 }, + (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( + "updateProjectActivity", + "PUT", + `${P}/{id}/activities/{activityId}`, + { acceptedStatuses: [200], requestByteLimit: 16_384, responseByteLimit: 16_384 }, + (input: never) => { + const value = input as unknown as Readonly<{ + id: string; + activityId: string; + body: unknown; + }>; + return Object.freeze({ + pathValues: Object.freeze({ id: value.id, activityId: value.activityId }), + queryEntries: NO_QUERY, + body: value.body, + }); + }, + ), + writeOperation( + "deleteProjectActivity", + "DELETE", + `${P}/{id}/activities/{activityId}`, + { + acceptedStatuses: [204], + emptyBodyStatuses: [204], + requestByteLimit: 1_024, + responseByteLimit: 1_024, + }, + (input: never) => { + const value = input as unknown as Readonly<{ + id: string; + activityId: string; + expectedVersion: number; + }>; + return Object.freeze({ + pathValues: Object.freeze({ id: value.id, activityId: value.activityId }), + queryEntries: NO_QUERY, + body: { expectedVersion: value.expectedVersion }, + }); + }, + ), writeOperation( "publishProject", "POST", 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 836907b..1a7a5dc 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_PROJECT_EDIT", path: "/studio/projects/:id", layoutGroup: "STUDIO", paramsSchema: "TechLogDocumentIdParams", searchSchema: null, title: "프로젝트 편집", navigationLabel: null, navigationOrder: null }), 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 }), diff --git a/src/features/tech-log/presentation/studio/components/project-editor.tsx b/src/features/tech-log/presentation/studio/components/project-editor.tsx new file mode 100644 index 0000000..f3d0f23 --- /dev/null +++ b/src/features/tech-log/presentation/studio/components/project-editor.tsx @@ -0,0 +1,445 @@ +import { useCallback, useEffect, useState, type FormEvent } from "react"; + +import type { + ProjectActivityResponse, + ProjectEditResponse, +} from "../../../contracts/management/contract.ts"; +import { managementFailureMessage } from "../../../application/ports/management-gateway-error.ts"; +import { useRouteInput } from "../../../../../presentation/routes/route-input.tsx"; +import { GuardedStudioLink } from "./guarded-studio-link.tsx"; +import { slugFromName } from "./slug-from-name.ts"; +import { useStudio } from "../use-studio.ts"; + +/** `project_phase_check` 와 같은 목록이다 — 화면이 더 많은 값을 보여 주면 저장이 제약에서 터진다. */ +const PHASES = [ + ["RESEARCH", "조사"], + ["DESIGN", "설계"], + ["IMPLEMENTATION", "구현"], + ["VERIFICATION", "검증"], + ["MAINTENANCE", "유지"], + ["PAUSED", "중단"], + ["COMPLETED", "완료"], +] as const; + +/** `project_activity_activity_type_check` 와 같은 목록이다. */ +const ACTIVITY_TYPES = [ + ["MILESTONE_REACHED", "이정표 도달"], + ["PHASE_CHANGED", "단계 전환"], + ["QUESTION_OPENED", "질문 열림"], + ["QUESTION_RESOLVED", "질문 해결"], + ["DECISION_ACCEPTED", "결정 수락"], + ["CASE_PUBLISHED", "Case 게시"], + ["REFERENCE_PUBLISHED", "Reference 게시"], + ["PROJECT_PAUSED", "프로젝트 중단"], + ["PROJECT_RESUMED", "프로젝트 재개"], +] as const; + +/** `` 은 초와 시간대가 없는 지역 시각을 쓴다. */ +function toLocalInput(isoTimestamp: string): string { + const value = new Date(isoTimestamp); + if (Number.isNaN(value.getTime())) return ""; + const pad = (part: number) => String(part).padStart(2, "0"); + return `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())}T${pad( + value.getHours(), + )}:${pad(value.getMinutes())}`; +} + +function fromLocalInput(value: string): string { + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? new Date().toISOString() : parsed.toISOString(); +} + +/** + * 프로젝트 한 편의 편집 화면. + * + *

이 화면이 없는 동안 프로젝트는 이름과 slug 만 가질 수 있었다. 그래서 공개 화면의 "현재 목표"·"다음 + * 작업"·"목적" 이 늘 비어 있었고, 홈의 focus 카드도 제목만 남았다 — 백엔드의 `updateProject` 는 처음부터 + * 구현돼 있었고 채울 화면만 없었다. + * + *

활동도 여기서 다룬다. 프로젝트 밖의 활동은 존재하지 않으므로 따로 둘 이유가 없고, 무엇이 공개 + * 타임라인에 실리는지를 프로젝트 필드와 같은 화면에서 보는 편이 낫다. + */ +export function ProjectEditor() { + const { params } = useRouteInput<"TECH_LOG_STUDIO_PROJECT_EDIT">(); + const projectId = params.id; + const { managementGateway, setRequestAnnouncement } = useStudio(); + + const [project, setProject] = useState(null); + const [activities, setActivities] = useState([]); + const [draft, setDraft] = useState>({}); + const [labels, setLabels] = useState(""); + const [pending, setPending] = useState(false); + const [error, setError] = useState(""); + const [generation, setGeneration] = useState(0); + + const reload = useCallback(() => setGeneration((value) => value + 1), []); + + useEffect(() => { + let cancelled = false; + void Promise.all([ + managementGateway.getProject(projectId), + managementGateway.listProjectActivities(projectId), + ]).then( + ([loaded, activityList]) => { + if (cancelled) return; + setProject(loaded); + setActivities(activityList); + setDraft({ + name: loaded.name ?? "", + slug: loaded.slug ?? "", + oneLinePurpose: loaded.oneLinePurpose ?? "", + purposeMarkdown: loaded.purposeMarkdown ?? "", + boundaryMarkdown: loaded.boundaryMarkdown ?? "", + systemOverviewMarkdown: loaded.systemOverviewMarkdown ?? "", + phase: loaded.phase, + currentObjective: loaded.currentObjective ?? "", + nextStep: loaded.nextStep ?? "", + }); + setLabels((loaded.technologyLabels ?? []).join(", ")); + setError(""); + }, + () => { + if (!cancelled) setError("프로젝트를 불러오지 못했습니다."); + }, + ); + return () => { + cancelled = true; + }; + }, [managementGateway, projectId, generation]); + + const set = (key: string, value: string) => + setDraft((current) => ({ ...current, [key]: value })); + + const save = async (event: FormEvent) => { + event.preventDefault(); + if (pending || !project) return; + setPending(true); + setError(""); + try { + /* + slug 는 공개 주소다. 비워 두면 게시할 수 없으므로 이름에서 만들어 채운다 — 예전에는 조용히 + 빈 값으로 저장되었고, 게시 화면에 가서야 "slug 가 없다" 는 말을 들었다. + */ + const slug = draft.slug?.trim() || slugFromName(draft.name ?? ""); + const saved = await managementGateway.updateProject(project.id, { + expectedVersion: project.version, + name: (draft.name ?? "").trim(), + slug, + oneLinePurpose: draft.oneLinePurpose ?? "", + purposeMarkdown: draft.purposeMarkdown ?? "", + boundaryMarkdown: draft.boundaryMarkdown ?? "", + systemOverviewMarkdown: draft.systemOverviewMarkdown ?? "", + phase: draft.phase ?? "RESEARCH", + currentObjective: draft.currentObjective ?? "", + nextStep: draft.nextStep ?? "", + technologyLabels: labels + .split(",") + .map((label) => label.trim()) + .filter(Boolean), + targetVisibility: + project.targetVisibility === "PUBLIC" || project.targetVisibility === "UNLISTED" + ? project.targetVisibility + : "PRIVATE", + featuredOrder: project.featuredOrder, + }); + setProject(saved); + setRequestAnnouncement(`프로젝트를 버전 ${saved.version}으로 저장했습니다.`); + /* + 이미 공개된 프로젝트라면 공개 투영도 함께 갱신해야 한다. 투영은 게시할 때 세워지므로, + 저장만 하면 화면에는 예전 값이 남는다. + */ + if (saved.targetVisibility !== "PRIVATE") { + await managementGateway.publishProject( + saved.id, + saved.version, + saved.targetVisibility === "UNLISTED" ? "UNLISTED" : "PUBLIC", + ); + } + reload(); + } catch (failure) { + setError(managementFailureMessage(failure, "프로젝트를 저장하지 못했습니다.")); + } finally { + setPending(false); + } + }; + + const addActivity = async () => { + if (pending || !project) return; + setPending(true); + setError(""); + try { + await managementGateway.createProjectActivity(project.id, { + expectedProjectVersion: project.version, + activityType: "MILESTONE_REACHED", + title: "새 활동", + summary: "", + visibility: "PUBLIC", + occurredAt: new Date().toISOString(), + }); + setRequestAnnouncement("활동을 추가했습니다."); + reload(); + } catch (failure) { + setError(managementFailureMessage(failure, "활동을 추가하지 못했습니다.")); + } finally { + setPending(false); + } + }; + + const saveActivity = async ( + activity: ProjectActivityResponse, + changes: Partial>, + ) => { + if (pending || !project) return; + setPending(true); + setError(""); + try { + const saved = await managementGateway.updateProjectActivity(project.id, activity.id, { + expectedVersion: activity.version, + title: changes.title ?? activity.title, + summary: changes.summary ?? activity.summary ?? "", + visibility: (changes.visibility ?? activity.visibility) === "PUBLIC" ? "PUBLIC" : "PRIVATE", + occurredAt: changes.occurredAt ?? activity.occurredAt, + }); + setActivities((current) => + current.map((item) => (item.id === saved.id ? saved : item)), + ); + setRequestAnnouncement("활동을 저장했습니다."); + } catch (failure) { + setError(managementFailureMessage(failure, "활동을 저장하지 못했습니다.")); + } finally { + setPending(false); + } + }; + + const removeActivity = async (activity: ProjectActivityResponse) => { + if (pending || !project) return; + setPending(true); + setError(""); + try { + await managementGateway.deleteProjectActivity(project.id, activity.id, activity.version); + setActivities((current) => current.filter((item) => item.id !== activity.id)); + setRequestAnnouncement("활동을 삭제했습니다."); + } catch (failure) { + setError(managementFailureMessage(failure, "활동을 삭제하지 못했습니다.")); + } finally { + setPending(false); + } + }; + + const field = ( + key: string, + label: string, + hint?: string, + multiline = false, + ) => ( +

+ + {multiline ? ( +