diff --git a/public/release-manifest.json b/public/release-manifest.json index 8987f27..0ca7a1c 100644 --- a/public/release-manifest.json +++ b/public/release-manifest.json @@ -38,28 +38,28 @@ }, "contractSet": { "setAlgorithm": "CA_CONTRACT_SET_V1", - "setDigest": "sha256:0872fd944d931d70550549496a8086322ea6c86adc1d5802030300eb0076d2f2", + "setDigest": "sha256:f3124d4f29c487d1d6b4f42326393c91ad0542ecb4b60c0ce09a62fb452bb173", "packages": [ { "packageId": "@tech-log/management-contract", "version": "1.0.0", - "digest": "sha256:e16a9695e9034a273c769792ea8901fd706c61514137228b2c20e1b117ea0c01", + "digest": "sha256:9efc7709b29404db96a7cc7acecdd9ec7b27cb95f3389e5da2c4953e2e3e23aa", "runtimeProtocolVersion": 1, - "sourceRevision": "0c10a4a" + "sourceRevision": "332b11f" }, { "packageId": "@tech-log/public-contract", "version": "2.0.0", "digest": "sha256:8ac71425b38658f34641102b4c2e6e21288c811efebdb92c0a46fb9d4790e23e", "runtimeProtocolVersion": 1, - "sourceRevision": "0c10a4a" + "sourceRevision": "332b11f" }, { "packageId": "@tech-log/studio-contract", "version": "3.0.0", "digest": "sha256:6cae9924403d0761f401643a022980b8e04183eea0d890c143c9fbbbbc7431e4", "runtimeProtocolVersion": 1, - "sourceRevision": "0c10a4a" + "sourceRevision": "332b11f" } ] } 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 0c01da5..3c07078 100644 --- a/src/features/tech-log/adapters/http/http-management-gateway.ts +++ b/src/features/tech-log/adapters/http/http-management-gateway.ts @@ -85,6 +85,19 @@ export function createHttpManagementGateway( run("publishRelease", { id, expectedVersion }), archiveRelease: (id: string, expectedVersion: number) => run("archiveRelease", { id, expectedVersion }), + deleteDocument: async ( + kind: "CASE" | "REFERENCE" | "QUESTION", + id: string, + expectedVersion: number, + ) => { + const operationId = + kind === "CASE" + ? "deleteCaseDraft" + : kind === "REFERENCE" + ? "deleteReferenceDraft" + : "deleteQuestion"; + await run(operationId, { id, expectedVersion }); + }, deleteProject: async (id: string, expectedVersion: number) => { await run("deleteProject", { id, expectedVersion }); }, diff --git a/src/features/tech-log/adapters/http/http-public-content-gateway.ts b/src/features/tech-log/adapters/http/http-public-content-gateway.ts index 51cc11e..faed127 100644 --- a/src/features/tech-log/adapters/http/http-public-content-gateway.ts +++ b/src/features/tech-log/adapters/http/http-public-content-gateway.ts @@ -5,6 +5,7 @@ import type { Project, PublicContentQueries, PublicRecord, + PublicTopic, QuestionRecord, RecordFilters, RecordKind, @@ -292,6 +293,18 @@ export function createHttpPublicContentGateway( * one object with a named slot per kind rather than a list, because each slot * has its own shape; the order below is the order the screen renders them in. */ + async function listTopics(): Promise { + const page = await read("listPublicTopics", {}); + if (page === NOT_FOUND) return []; + return (page.items ?? []).map((item) => + Object.freeze({ + name: String(item.name ?? ""), + slug: String(item.slug ?? ""), + recordCount: Number(item.recordCount ?? 0), + }), + ); + } + async function getHomeFocusItems(): Promise { const home = await read> }>>( "getPublicHome", @@ -430,6 +443,7 @@ export function createHttpPublicContentGateway( getRecord, getProject, getRelease, + listTopics, getProjectRecords, getProjectDecisions, getProjectActivity, diff --git a/src/features/tech-log/adapters/static/public-query.ts b/src/features/tech-log/adapters/static/public-query.ts index ca6e423..7893b82 100644 --- a/src/features/tech-log/adapters/static/public-query.ts +++ b/src/features/tech-log/adapters/static/public-query.ts @@ -10,7 +10,10 @@ import { type Release, type HomeFocusItem, } from "./public-content.ts"; -import type { PublicContentQueries } from "../../application/ports/public-content-queries.ts"; +import type { + PublicContentQueries, + PublicTopic, +} from "../../application/ports/public-content-queries.ts"; export type RecordFilters = { kind?: RecordKind; @@ -98,6 +101,24 @@ export function getProjectActivity(projectSlug: string): ProjectActivity[] { return [...(getProject(projectSlug)?.activity ?? [])]; } +/** + * 정적 카탈로그에는 주제 테이블이 없다 — 기록마다 붙은 주제 이름이 있을 뿐이다. 그것을 모아 + * 세면 백엔드의 `listPublicTopics` 와 같은 모양이 되고, 이 어댑터의 목적(백엔드 없이 화면을 + * 그린다)에도 맞는다. + */ +export function listTopics(): PublicTopic[] { + const counts = new Map(); + for (const record of listRecords()) { + if (!record.topic) continue; + const existing = counts.get(record.topicSlug); + if (existing) existing.recordCount += 1; + else counts.set(record.topicSlug, { name: record.topic, slug: record.topicSlug, recordCount: 1 }); + } + return [...counts.values()] + .sort((left, right) => right.recordCount - left.recordCount || (left.name < right.name ? -1 : 1)) + .map((entry) => Object.freeze(entry)); +} + export function getHomeFocusItems(): HomeFocusItem[] { const project = getProject("backend-skeleton"); const question = getRecord("QUESTION", "validate-edge-token-again"); @@ -240,6 +261,9 @@ export const publicContentQueries = Object.freeze({ async getProjectActivity(projectSlug: string) { return getProjectActivity(projectSlug); }, + async listTopics() { + return listTopics(); + }, async getHomeFocusItems() { return getHomeFocusItems(); }, diff --git a/src/features/tech-log/application/ports/management-gateway.ts b/src/features/tech-log/application/ports/management-gateway.ts index f135a6d..3e8d7b6 100644 --- a/src/features/tech-log/application/ports/management-gateway.ts +++ b/src/features/tech-log/application/ports/management-gateway.ts @@ -11,7 +11,7 @@ import type { } from "../../contracts/management/contract.ts"; /** - * 주제·프로젝트·릴리즈 관리 표면. + * 주제·프로젝트·릴리즈·작업본 삭제 관리 표면. * *

Studio 게이트웨이와 같은 실패 규약이다 — 실패는 던지고, 화면이 잡는다. MOCK 대응물을 두지 * 않는 것도 의도다: 이 표면은 백엔드가 없으면 존재할 이유가 없고, 픽스처를 만들면 실제로는 만들 @@ -34,4 +34,10 @@ export type ManagementGateway = Readonly<{ deleteRelease(id: string, expectedVersion: number): Promise; publishRelease(id: string, expectedVersion: number): Promise; archiveRelease(id: string, expectedVersion: number): Promise; + /** + * 작업본 삭제. 종류마다 다른 endpoint 인 것은 계약의 모양이자 저장 구조다 — Case 와 Reference + * 는 한 테이블을 나눠 쓰고 Question 은 다른 테이블이다. Decision 은 계약에 삭제가 없다: + * 그쪽 수명주기는 수락·기각·대체이고, 그건 지우는 것이 아니라 무슨 일이 있었는지 남기는 것이다. + */ + deleteDocument(kind: "CASE" | "REFERENCE" | "QUESTION", id: string, expectedVersion: number): Promise; }>; diff --git a/src/features/tech-log/application/ports/public-content-queries.ts b/src/features/tech-log/application/ports/public-content-queries.ts index f133f93..354626c 100644 --- a/src/features/tech-log/application/ports/public-content-queries.ts +++ b/src/features/tech-log/application/ports/public-content-queries.ts @@ -132,6 +132,13 @@ export type Release = { related: ReadonlyArray<{ title: string; path: string }>; }; +/** 계약 `TopicSummary`. 목록에 필요한 만큼만 옮긴다. */ +export type PublicTopic = { + name: string; + slug: string; + recordCount: number; +}; + export type FocusKey = "current" | "question" | "decision"; export type HomeFocusItem = { @@ -190,6 +197,11 @@ export type PublicContentQueries = Readonly<{ getProjectRecords(projectSlug: string): Promise; getProjectDecisions(projectSlug: string): Promise; getProjectActivity(projectSlug: string): Promise; + /** + * 공개된 주제 목록. 프로필의 "주요 관심 주제"가 이 값을 그린다 — 그 목록은 코드에 박혀 + * 있었고, Studio 에서 주제를 만들어도 바뀌지 않았다. + */ + listTopics(): Promise; getHomeFocusItems(): Promise; searchPublicContent(query: string): Promise; }>; diff --git a/src/features/tech-log/contracts/management/canonical-source.json b/src/features/tech-log/contracts/management/canonical-source.json index ab3996c..f9151b0 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:e16a9695e9034a273c769792ea8901fd706c61514137228b2c20e1b117ea0c01", - "sourceRevision": "0c10a4a", + "digest": "sha256:9efc7709b29404db96a7cc7acecdd9ec7b27cb95f3389e5da2c4953e2e3e23aa", + "sourceRevision": "332b11f", "operationIds": [ "createCaseDraft", "getCaseForEdit", diff --git a/src/features/tech-log/contracts/management/generated.ts b/src/features/tech-log/contracts/management/generated.ts index 5bfc9e2..8c08518 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" | "RELEASE_NOT_FOUND" | "RELEASE_VERSION_TAKEN" | "RELEASE_NOT_PUBLISHABLE" | "INTERNAL_ERROR"; + code: "AUTHENTICATION_REQUIRED" | "STUDIO_ACCESS_DENIED" | "REQUEST_VALIDATION_FAILED" | "VERSION_CONFLICT" | "TOPIC_NOT_FOUND" | "TOPIC_NAME_TAKEN" | "TOPIC_SLUG_TAKEN" | "TOPIC_IN_USE" | "PROJECT_NOT_FOUND" | "PROJECT_SLUG_TAKEN" | "PROJECT_IN_USE" | "RELEASE_NOT_FOUND" | "RELEASE_VERSION_TAKEN" | "RELEASE_NOT_PUBLISHABLE" | "DOCUMENT_NOT_FOUND" | "DOCUMENT_PUBLISHED" | "DOCUMENT_IN_USE" | "QUESTION_NOT_FOUND" | "QUESTION_IN_USE" | "INTERNAL_ERROR"; /** @enum {string} */ category: "VALIDATION" | "AUTH" | "AUTHZ" | "NOT_FOUND" | "CONFLICT" | "RATE_LIMIT" | "TRANSIENT_DEPENDENCY" | "PERMANENT_DEPENDENCY" | "DATA_INTEGRITY" | "INTERNAL"; message: string; @@ -1922,7 +1922,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Unauthorized */ @@ -1931,7 +1931,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Forbidden */ @@ -1940,7 +1940,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Not Found */ @@ -1949,7 +1949,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Conflict */ @@ -1958,7 +1958,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Unprocessable Content */ @@ -1967,7 +1967,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Internal Server Error */ @@ -1976,7 +1976,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; }; @@ -2258,7 +2258,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Unauthorized */ @@ -2267,7 +2267,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Forbidden */ @@ -2276,7 +2276,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Not Found */ @@ -2285,7 +2285,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Conflict */ @@ -2294,7 +2294,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Unprocessable Content */ @@ -2303,7 +2303,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Internal Server Error */ @@ -2312,7 +2312,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; }; @@ -3936,7 +3936,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Unauthorized */ @@ -3945,7 +3945,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Forbidden */ @@ -3954,7 +3954,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Not Found */ @@ -3963,7 +3963,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Conflict */ @@ -3972,7 +3972,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Unprocessable Content */ @@ -3981,7 +3981,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Internal Server Error */ @@ -3990,7 +3990,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 46e7563..36c1573 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,12 @@ info: description: |- ⚠ 봉투 결정(ADR-006) 부분 반영 — 이 파일에는 두 모양이 공존한다. - topics 4개, projects 5개, releases 7개를 studio-v1.yaml과 같은 방식으로 - 변환했다 (`application/json` + ErrorEnvelope / Envelope). 그 16개가 - 구현된 것이자 소비자가 있는 것이기 때문이다. + topics 4개, projects 5개, releases 7개, 그리고 작업본 삭제 3개 + (deleteCaseDraft / deleteReferenceDraft / deleteQuestion)를 studio-v1.yaml과 + 같은 방식으로 변환했다 (`application/json` + ErrorEnvelope / Envelope). + 그 19개가 구현된 것이자 소비자가 있는 것이기 때문이다. - 나머지 63개는 아직 bare payload + `application/problem+json` + ProblemDetails + 나머지 60개는 아직 bare payload + `application/problem+json` + ProblemDetails 다. 구현에 착수할 때 같은 방식으로 따라온다 — 소비자가 없는 operation을 미리 변환해 두면 검증되지 않은 모양이 계약에 고정된다. @@ -260,45 +261,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: @@ -509,45 +510,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: @@ -1795,45 +1796,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: @@ -5270,6 +5271,11 @@ components: - RELEASE_NOT_FOUND - RELEASE_VERSION_TAKEN - RELEASE_NOT_PUBLISHABLE + - DOCUMENT_NOT_FOUND + - DOCUMENT_PUBLISHED + - DOCUMENT_IN_USE + - QUESTION_NOT_FOUND + - QUESTION_IN_USE - INTERNAL_ERROR description: '`INTERNAL_ERROR` 는 이 기능이 아니라 스켈레톤의 공통 처리기가 내는 코드다. 계약이 그것까지 열거해야 500 응답이 계약을 벗어나지 않는다.' category: diff --git a/src/features/tech-log/contracts/public/canonical-source.json b/src/features/tech-log/contracts/public/canonical-source.json index ddea845..26ca94d 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": "0c10a4a", + "sourceRevision": "332b11f", "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 d0cbe28..c4d443a 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": "0c10a4a", + "sourceRevision": "332b11f", "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 d8b2248..a31d523 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 @@ -34,6 +34,11 @@ const MANAGEMENT_ERROR_CODES = Object.freeze([ "RELEASE_NOT_FOUND", "RELEASE_VERSION_TAKEN", "RELEASE_NOT_PUBLISHABLE", + "DOCUMENT_NOT_FOUND", + "DOCUMENT_PUBLISHED", + "DOCUMENT_IN_USE", + "QUESTION_NOT_FOUND", + "QUESTION_IN_USE", "INTERNAL_ERROR", ]); @@ -159,6 +164,7 @@ const byId = (input: never) => { const T = "/api/v1/studio/topics"; const P = "/api/v1/studio/projects"; const R = "/api/v1/studio/releases"; +const D = "/api/v1/studio"; const HTTP_CONTRACTS = Object.freeze([ readOperation("listStudioTopics", T, 131_072), @@ -330,6 +336,63 @@ const HTTP_CONTRACTS = Object.freeze([ }); }, ), + writeOperation( + "deleteCaseDraft", + "DELETE", + `${D}/cases/{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( + "deleteReferenceDraft", + "DELETE", + `${D}/references/{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( + "deleteQuestion", + "DELETE", + `${D}/questions/{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 }, + }); + }, + ), ]); export const TECH_LOG_MANAGEMENT_OPERATION_IDS = Object.freeze( diff --git a/src/features/tech-log/presentation/public/pages/profile-page.tsx b/src/features/tech-log/presentation/public/pages/profile-page.tsx index f4055b3..84fdef5 100644 --- a/src/features/tech-log/presentation/public/pages/profile-page.tsx +++ b/src/features/tech-log/presentation/public/pages/profile-page.tsx @@ -23,8 +23,6 @@ const principles = [ }, ] as const; -const topics = ["Backend Architecture", "JPA", "Authentication", "Redis"] as const; - export function ProfilePage() { // The two project slugs this named were the static fixture's, and they exist // in no real deployment — the page asked the backend for them, took two 404s, @@ -34,10 +32,14 @@ export function ProfilePage() { const entries = (await queries.searchPublicContent("")).filter( (item) => item.contentType === "PROJECT", ); - const resolved = await Promise.all( - entries.map((item) => queries.getProject(item.path.replace("/projects/", ""))), - ); - return { currentProjects: resolved.filter((project) => project !== undefined) }; + const [resolved, topics] = await Promise.all([ + Promise.all(entries.map((item) => queries.getProject(item.path.replace("/projects/", "")))), + queries.listTopics(), + ]); + return { + currentProjects: resolved.filter((project) => project !== undefined), + topics, + }; }); // Only the project list comes from the network. Returning the page-wide @@ -96,14 +98,24 @@ export function ProfilePage() { )} + {/* + 이 목록은 코드에 네 개가 박혀 있었다 — Studio 에서 주제를 만들거나 지워도 프로필은 + 그대로였고, 고치려면 배포를 다시 해야 했다. 이제 공개 주제 목록을 그대로 그린다. + */}

Topics

주요 관심 주제

-
    - {topics.map((topic) => ( -
  • {topic}
  • - ))} -
+ {!view.ready ? ( + view.fallback + ) : view.data.topics.length === 0 ? ( +

아직 등록한 주제가 없습니다.

+ ) : ( +
    + {view.data.topics.map((topic) => ( +
  • {topic.name}
  • + ))} +
+ )}
); diff --git a/src/features/tech-log/presentation/studio/components/document-list.tsx b/src/features/tech-log/presentation/studio/components/document-list.tsx index 4e0f799..504b6af 100644 --- a/src/features/tech-log/presentation/studio/components/document-list.tsx +++ b/src/features/tech-log/presentation/studio/components/document-list.tsx @@ -29,8 +29,16 @@ function isAbortError(error: unknown): boolean { return error instanceof DOMException && error.name === "AbortError"; } +/** + * Decision 은 지울 수 없다. 계약에 삭제 operation 이 없고, 그건 누락이 아니라 판단이다 — 결정의 + * 수명주기는 수락·기각·대체이고 그 셋은 무슨 일이 있었는지 남기는 반면 삭제는 없앤다. + */ +const DELETABLE_KINDS = new Set(["CASE", "REFERENCE", "QUESTION"]); + export function DocumentList() { - const { gateway } = useStudio(); + const { gateway, managementGateway, setRequestAnnouncement } = useStudio(); + const [deletingId, setDeletingId] = useState(null); + const [deleteError, setDeleteError] = useState(""); const [searchDraft, setSearchDraft] = useState(""); const [q, setQ] = useState(""); const [kind, setKind] = useState(); @@ -42,6 +50,33 @@ export function DocumentList() { const [error, setError] = useState(""); const [retryGeneration, setRetryGeneration] = useState(0); + /** + * 목록 행에는 version 이 없다 (계약의 `DocumentSummary`). 삭제는 expectedVersion 을 요구하므로 + * 지우기 직전에 작업본을 한 번 읽어 그 시점의 version 을 쓴다 — 목록을 띄워 둔 채 다른 곳에서 + * 수정된 경우 여기서 409 로 걸리는 편이, 목록이 기억하던 낡은 version 으로 지우는 것보다 낫다. + */ + const removeDocument = async (item: DocumentPage["items"][number]) => { + if (deletingId !== null) return; + setDeletingId(item.id); + setDeleteError(""); + try { + const detail = await gateway.getDocument(item.id); + await managementGateway.deleteDocument( + item.kind as "CASE" | "REFERENCE" | "QUESTION", + item.id, + detail.document.version, + ); + setRequestAnnouncement(`작업본 ${item.title || "제목 없음"} 을(를) 삭제했습니다.`); + setRetryGeneration((value) => value + 1); + } catch { + setDeleteError( + "삭제하지 못했습니다. 게시 중이거나, 이 기록을 참조하는 곳이 있거나, 다른 곳에서 먼저 수정되었을 수 있습니다.", + ); + } finally { + setDeletingId(null); + } + }; + useEffect(() => { const controller = new AbortController(); setLoading(true); @@ -169,6 +204,11 @@ export function DocumentList() { {page && !loading && !error ? ( <>

{page.items.length}개의 작업본

+ {deleteError ? ( +

+ {deleteError} +

+ ) : null} {page.items.length ? (
{page.items.map((item) => ( @@ -202,6 +242,16 @@ export function DocumentList() {
+ {DELETABLE_KINDS.has(item.kind) ? ( + + ) : null} ))} diff --git a/tests/features/tech-log/public-index-screens.test.tsx b/tests/features/tech-log/public-index-screens.test.tsx index 2b1a41c..034eeed 100644 --- a/tests/features/tech-log/public-index-screens.test.tsx +++ b/tests/features/tech-log/public-index-screens.test.tsx @@ -361,7 +361,11 @@ describe("TechLog release and profile screens", () => { ).toEqual(["/projects/backend-skeleton", "/projects/auth-lab"]); expect( Array.from(container.querySelectorAll(".profile-topics li"), (item) => item.textContent), - ).toEqual(["Backend Architecture", "JPA", "Authentication", "Redis"]); + // Derived from the catalogue, not a literal in the page. The old hard-coded + // list opened with "Backend Architecture", which no record in the fixture + // actually carries — the profile was advertising a topic that did not + // exist, and nothing could have caught it while the list lived in the JSX. + ).toEqual(["JPA", "Authentication", "Redis"]); expect(within(main).queryByText(/이메일|연락처|경력|소속|회사/)).not.toBeInTheDocument(); expect(container.querySelector('a[href^="mailto:"]')).toBeNull(); });