diff --git a/public/release-manifest.json b/public/release-manifest.json index 6dc21a0..3ace26e 100644 --- a/public/release-manifest.json +++ b/public/release-manifest.json @@ -38,28 +38,28 @@ }, "contractSet": { "setAlgorithm": "CA_CONTRACT_SET_V1", - "setDigest": "sha256:a7983cb56b824a32866f99f778ec92812e3d59d4fda1a3f632985e4b54560b8f", + "setDigest": "sha256:38329cfe645e1d6cbfc7a9bb3e20e23b579a286ad0d1254982c70de5c6055ae8", "packages": [ { "packageId": "@tech-log/management-contract", "version": "1.0.0", - "digest": "sha256:9efc7709b29404db96a7cc7acecdd9ec7b27cb95f3389e5da2c4953e2e3e23aa", + "digest": "sha256:d19ae7c4fbcac924a356bbb0cc1a46a4046ecec701158ca0a9b7cc089bbaf878", "runtimeProtocolVersion": 1, - "sourceRevision": "dc290b4" + "sourceRevision": "65a04fc" }, { "packageId": "@tech-log/public-contract", "version": "2.0.0", - "digest": "sha256:8ac71425b38658f34641102b4c2e6e21288c811efebdb92c0a46fb9d4790e23e", + "digest": "sha256:6575a09317a1ffe951747b12102ad2cf884110007426a45d6f59a53b65612d59", "runtimeProtocolVersion": 1, - "sourceRevision": "dc290b4" + "sourceRevision": "65a04fc" }, { "packageId": "@tech-log/studio-contract", "version": "3.0.0", "digest": "sha256:5229865c3d242f19d75030d3f524a44dfebbf444324068d6ae88e43b8047dba4", "runtimeProtocolVersion": 1, - "sourceRevision": "dc290b4" + "sourceRevision": "65a04fc" } ] } diff --git a/scripts/smoke/production-sweep.ts b/scripts/smoke/production-sweep.ts new file mode 100644 index 0000000..80aef4f --- /dev/null +++ b/scripts/smoke/production-sweep.ts @@ -0,0 +1,107 @@ +/** + * 배포본 전수 확인. + * + * 이 파일은 절차 실패에서 나왔다 — 고친 화면만 확인하고 배포해서, 나머지가 깨진 것은 매번 + * 사용자가 먼저 발견했다. 운영 환경이므로 배포 전에 모든 화면을 한 번씩 열어 보는 것이 맞다. + * + * 각 화면에서 보는 것: main 이 그려졌는지, 콘솔 오류, 4xx/5xx API 응답, 그리고 화면에 뜬 + * 오류 문구. 하나라도 있으면 그 화면을 실패로 적고 끝까지 진행한다. + * + * SPW=<비밀번호> node scripts/smoke/production-sweep.mjs [origin] + */ +import process from "node:process"; + +import { chromium, type Page } from "@playwright/test"; + +const ORIGIN = process.argv[2] ?? "https://hyeonworks.com"; +const PW = process.env.SPW; +const ERROR_TEXT = + /요청을 처리하지 못했습니다|지원 정보 확인|표시할 수 없습니다|불러오지 못했습니다|화면을 찾을 수 없습니다|Not Found/; + +const results: { label: string; path: string; problems: string[] }[] = []; + +async function visit( + page: Page, + label: string, + path: string, + { expectMain = true }: { expectMain?: boolean } = {}, +) { + const problems: string[] = []; + const onConsole = (m: { type(): string; text(): string }) => { if (m.type() === "error" && !/401/.test(m.text())) problems.push(`console: ${m.text().slice(0, 120)}`); }; + const onResponse = (r: { + url(): string; + status(): number; + request(): { method(): string }; + }) => { + const u = new URL(r.url()).pathname; + if (r.status() >= 400 && u.startsWith("/api") && !u.includes("/studio/session")) { + problems.push(`${r.status()} ${r.request().method()} ${u}`); + } + }; + page.on("console", onConsole); + page.on("response", onResponse); + try { + await page.goto(ORIGIN + path, { waitUntil: "domcontentloaded", timeout: 45000 }); + await page.waitForTimeout(3000); + const main = await page.locator("main").count(); + const body = (await page.locator("body").innerText().catch(() => "")).replace(/\s+/g, " "); + if (expectMain && main === 0) problems.push("main 없음"); + const shown = body.match(ERROR_TEXT); + if (shown) problems.push(`화면 문구: ${shown[0]}`); + } catch (error) { + problems.push(`이동 실패: ${String(error).slice(0, 90)}`); + } finally { + page.off("console", onConsole); + page.off("response", onResponse); + } + results.push({ label, path, problems }); + console.log(`${problems.length ? "✗" : "✓"} ${label.padEnd(22)} ${path}`); + for (const p of problems) console.log(` ${p}`); +} + +const browser = await chromium.launch(); +const page = await (await browser.newContext()).newPage(); + +console.log("=== 공개 ==="); +for (const [label, path] of [ + ["홈", "/"], ["탐색", "/explore"], ["Case 목록", "/explore/cases"], + ["프로젝트", "/projects"], ["변경 기록", "/releases"], ["릴리즈 상세", "/releases/0.1.0"], + ["검색", "/search"], ["프로필", "/profile"], +]) await visit(page, label, path); + +if (!PW) { console.log("\n(SPW 없음 — Studio 생략)"); await browser.close(); process.exit(0); } + +console.log("\n=== 로그인 ==="); +await page.goto(ORIGIN + "/studio", { waitUntil: "domcontentloaded", timeout: 60000 }); +await page.waitForTimeout(2500); +const start = page.getByRole("button", { name: /로그인 시작/ }).or(page.getByRole("link", { name: /로그인 시작/ })); +if (await start.count()) { await start.first().click(); await page.waitForTimeout(5000); } +await page.fill("#username", "hyeonworks"); +await page.fill("#password", PW); +await page.click("#kc-login, input[type=submit], button[type=submit]"); +await page.waitForTimeout(6000); +console.log(" 로그인 후:", page.url().replace(ORIGIN, "") || "/"); + +console.log("\n=== Studio ==="); +for (const [label, path] of [ + ["대시보드", "/studio"], ["작업본", "/studio/documents"], ["새 문서", "/studio/documents/new"], + ["게시 기록", "/studio/publications"], ["Asset", "/studio/assets"], + ["주제·프로젝트", "/studio/taxonomy"], ["릴리즈", "/studio/releases"], +]) await visit(page, label, path); + +// 작업본 하나를 골라 편집·검증·미리보기까지 연다 +await page.goto(ORIGIN + "/studio/documents", { waitUntil: "domcontentloaded" }); +await page.waitForTimeout(3000); +const href = await page.locator("a[href*='/studio/documents/'][href$='/edit']").first().getAttribute("href").catch(() => null); +if (href) { + const id = href.split("/")[3]; + console.log("\n=== 문서 흐름 ===", id); + for (const [label, suffix] of [["편집", "/edit"], ["검증", "/validation"], ["미리보기", "/preview"], ["게시", "/publish"]]) + await visit(page, label, `/studio/documents/${id}${suffix}`); +} else console.log("\n(편집 링크를 찾지 못해 문서 흐름 생략)"); + +await browser.close(); +const failed = results.filter((r) => r.problems.length); +console.log(`\n=== 결과 === ${results.length - failed.length}/${results.length} 통과`); +for (const r of failed) console.log(` ✗ ${r.label} (${r.path}): ${r.problems.join(" | ").slice(0, 160)}`); +process.exit(failed.length ? 1 : 0); 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 3c07078..360e119 100644 --- a/src/features/tech-log/adapters/http/http-management-gateway.ts +++ b/src/features/tech-log/adapters/http/http-management-gateway.ts @@ -98,6 +98,9 @@ export function createHttpManagementGateway( : "deleteQuestion"; await run(operationId, { id, expectedVersion }); }, + deleteDecision: async (projectId: string, decisionId: string, expectedVersion: number) => { + await run("deleteProjectDecision", { id: projectId, decisionId, 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 3e8d7b6..b7dc883 100644 --- a/src/features/tech-log/application/ports/management-gateway.ts +++ b/src/features/tech-log/application/ports/management-gateway.ts @@ -40,4 +40,9 @@ export type ManagementGateway = Readonly<{ * 그쪽 수명주기는 수락·기각·대체이고, 그건 지우는 것이 아니라 무슨 일이 있었는지 남기는 것이다. */ deleteDocument(kind: "CASE" | "REFERENCE" | "QUESTION", id: string, expectedVersion: number): Promise; + /** + * Decision 은 프로젝트에 속하므로 경로가 둘을 요구한다. 다른 종류처럼 한 번에 묶지 않는 이유는 + * 계약이 그렇게 선언했고, 실제로도 프로젝트 밖의 Decision 은 존재하지 않기 때문이다. + */ + deleteDecision(projectId: string, decisionId: 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 df54d0a..564c417 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:9efc7709b29404db96a7cc7acecdd9ec7b27cb95f3389e5da2c4953e2e3e23aa", - "sourceRevision": "dc290b4", + "digest": "sha256:d19ae7c4fbcac924a356bbb0cc1a46a4046ecec701158ca0a9b7cc089bbaf878", + "sourceRevision": "65a04fc", "operationIds": [ "createCaseDraft", "getCaseForEdit", @@ -54,6 +54,7 @@ "listStudioProjectDecisions", "getProjectDecision", "updateProjectDecision", + "deleteProjectDecision", "acceptProjectDecision", "rejectProjectDecision", "supersedeProjectDecision", diff --git a/src/features/tech-log/contracts/management/generated.ts b/src/features/tech-log/contracts/management/generated.ts index 8c08518..f1e1acd 100644 --- a/src/features/tech-log/contracts/management/generated.ts +++ b/src/features/tech-log/contracts/management/generated.ts @@ -590,7 +590,12 @@ export interface paths { get: operations["getProjectDecision"]; put: operations["updateProjectDecision"]; post?: never; - delete?: never; + /** @description 작업본 목록에서 Decision 을 지운다. Case·Reference·Question 에는 이 경로가 있었는데 + * Decision 에만 없어서, 작성자가 연 초안을 접을 방법이 없었다. + * + * 수락·기각·대체는 무슨 일이 있었는지 남기는 수명주기이고 이것은 그것과 다르다 — 아직 + * 아무 판단도 하지 않은 초안을 없애는 일이다. 그래서 이미 게시된 Decision 은 거절한다. */ + delete: operations["deleteProjectDecision"]; options?: never; head?: never; patch?: never; @@ -902,7 +907,7 @@ export interface components { * @description `INTERNAL_ERROR` 는 이 기능이 아니라 스켈레톤의 공통 처리기가 내는 코드다. 계약이 그것까지 열거해야 500 응답이 계약을 벗어나지 않는다. * @enum {string} */ - code: "AUTHENTICATION_REQUIRED" | "STUDIO_ACCESS_DENIED" | "REQUEST_VALIDATION_FAILED" | "VERSION_CONFLICT" | "TOPIC_NOT_FOUND" | "TOPIC_NAME_TAKEN" | "TOPIC_SLUG_TAKEN" | "TOPIC_IN_USE" | "PROJECT_NOT_FOUND" | "PROJECT_SLUG_TAKEN" | "PROJECT_IN_USE" | "RELEASE_NOT_FOUND" | "RELEASE_VERSION_TAKEN" | "RELEASE_NOT_PUBLISHABLE" | "DOCUMENT_NOT_FOUND" | "DOCUMENT_PUBLISHED" | "DOCUMENT_IN_USE" | "QUESTION_NOT_FOUND" | "QUESTION_IN_USE" | "INTERNAL_ERROR"; + code: "AUTHENTICATION_REQUIRED" | "STUDIO_ACCESS_DENIED" | "REQUEST_VALIDATION_FAILED" | "VERSION_CONFLICT" | "TOPIC_NOT_FOUND" | "TOPIC_NAME_TAKEN" | "TOPIC_SLUG_TAKEN" | "TOPIC_IN_USE" | "PROJECT_NOT_FOUND" | "PROJECT_SLUG_TAKEN" | "PROJECT_IN_USE" | "RELEASE_NOT_FOUND" | "RELEASE_VERSION_TAKEN" | "RELEASE_NOT_PUBLISHABLE" | "DOCUMENT_NOT_FOUND" | "DOCUMENT_PUBLISHED" | "DOCUMENT_IN_USE" | "QUESTION_NOT_FOUND" | "QUESTION_IN_USE" | "DECISION_NOT_FOUND" | "DECISION_IN_USE" | "INTERNAL_ERROR"; /** @enum {string} */ category: "VALIDATION" | "AUTH" | "AUTHZ" | "NOT_FOUND" | "CONFLICT" | "RATE_LIMIT" | "TRANSIENT_DEPENDENCY" | "PERMANENT_DEPENDENCY" | "DATA_INTEGRITY" | "INTERNAL"; message: string; @@ -5991,6 +5996,96 @@ export interface operations { }; }; }; + deleteProjectDecision: { + parameters: { + query?: never; + header: { + "X-CSRF-TOKEN": components["parameters"]["CsrfToken"]; + }; + path: { + id: string; + decisionId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ExpectedVersionRequest"]; + }; + }; + responses: { + /** @description No Content */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + /** @description Unprocessable Content */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + }; + }; acceptProjectDecision: { parameters: { query?: never; 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 36c1573..0103e4e 100644 --- a/src/features/tech-log/contracts/management/management-api.openapi.yaml +++ b/src/features/tech-log/contracts/management/management-api.openapi.yaml @@ -3390,6 +3390,83 @@ paths: $ref: '#/components/schemas/DecisionUpdateRequest' security: - sessionCookie: [] + delete: + operationId: deleteProjectDecision + tags: + - Decisions + description: |- + 작업본 목록에서 Decision 을 지운다. Case·Reference·Question 에는 이 경로가 있었는데 + Decision 에만 없어서, 작성자가 연 초안을 접을 방법이 없었다. + + 수락·기각·대체는 무슨 일이 있었는지 남기는 수명주기이고 이것은 그것과 다르다 — 아직 + 아무 판단도 하지 않은 초안을 없애는 일이다. 그래서 이미 게시된 Decision 은 거절한다. + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + - name: decisionId + in: path + required: true + schema: + type: string + format: uuid + - $ref: '#/components/parameters/CsrfToken' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ExpectedVersionRequest' + responses: + '204': + description: No Content + '400': + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '404': + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '409': + description: Conflict + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '422': + description: Unprocessable Content + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '500': + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + security: + - sessionCookie: [] /api/v1/studio/projects/{id}/decisions/{decisionId}/accept: post: operationId: acceptProjectDecision @@ -5276,6 +5353,8 @@ components: - DOCUMENT_IN_USE - QUESTION_NOT_FOUND - QUESTION_IN_USE + - DECISION_NOT_FOUND + - DECISION_IN_USE - INTERNAL_ERROR description: '`INTERNAL_ERROR` 는 이 기능이 아니라 스켈레톤의 공통 처리기가 내는 코드다. 계약이 그것까지 열거해야 500 응답이 계약을 벗어나지 않는다.' category: diff --git a/src/features/tech-log/contracts/public/canonical-source.json b/src/features/tech-log/contracts/public/canonical-source.json index 64f241c..02a7f07 100644 --- a/src/features/tech-log/contracts/public/canonical-source.json +++ b/src/features/tech-log/contracts/public/canonical-source.json @@ -1,8 +1,8 @@ { "packageId": "@tech-log/public-contract", "version": "2.0.0", - "digest": "sha256:8ac71425b38658f34641102b4c2e6e21288c811efebdb92c0a46fb9d4790e23e", - "sourceRevision": "dc290b4", + "digest": "sha256:6575a09317a1ffe951747b12102ad2cf884110007426a45d6f59a53b65612d59", + "sourceRevision": "65a04fc", "operationIds": [ "getPublicSite", "getPublicHome", @@ -21,6 +21,7 @@ "listPublicReleases", "getPublicRelease", "getPublicProfile", - "searchPublicResources" + "searchPublicResources", + "getPublicMedia" ] } diff --git a/src/features/tech-log/contracts/public/generated.ts b/src/features/tech-log/contracts/public/generated.ts index 55911b1..a0e3c39 100644 --- a/src/features/tech-log/contracts/public/generated.ts +++ b/src/features/tech-log/contracts/public/generated.ts @@ -307,6 +307,32 @@ export interface paths { patch?: never; trace?: never; }; + "/media/{assetId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** @description 업로드한 Asset 의 바이트를 그대로 돌려준다. 이 경로가 없어서 Asset 은 저장은 되지만 + * 어디에서도 보이지 않았다 — 문서와 공개 화면이 모두 이 주소를 가리키는데 서빙하는 곳이 + * 없었다. + * + * `READY` 인 Asset 만 나간다. "발행된 기록이 참조하는 것만" 으로 더 좁히지 않는 이유는 + * 그러면 Studio 미리보기가 깨지기 때문이다 — 미리보기는 아직 발행되지 않은 기록을 보는 + * 화면이다. 대신 주소가 추측 불가능한 UUID 다. + * + * 봉투를 쓰지 않는다. 바이트를 반환하므로 감쌀 것이 없고, `` 는 JSON 을 읽지 + * 않는다. */ + get: operations["getPublicMedia"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record; export interface components { @@ -1586,4 +1612,44 @@ export interface operations { }; }; }; + getPublicMedia: { + parameters: { + query?: never; + header?: never; + path: { + assetId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/octet-stream": string; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + }; + }; } diff --git a/src/features/tech-log/contracts/public/public-api.openapi.yaml b/src/features/tech-log/contracts/public/public-api.openapi.yaml index 9e19c10..42935c1 100644 --- a/src/features/tech-log/contracts/public/public-api.openapi.yaml +++ b/src/features/tech-log/contracts/public/public-api.openapi.yaml @@ -676,6 +676,50 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorEnvelope' + /media/{assetId}: + get: + operationId: getPublicMedia + tags: + - Media + description: |- + 업로드한 Asset 의 바이트를 그대로 돌려준다. 이 경로가 없어서 Asset 은 저장은 되지만 + 어디에서도 보이지 않았다 — 문서와 공개 화면이 모두 이 주소를 가리키는데 서빙하는 곳이 + 없었다. + + `READY` 인 Asset 만 나간다. "발행된 기록이 참조하는 것만" 으로 더 좁히지 않는 이유는 + 그러면 Studio 미리보기가 깨지기 때문이다 — 미리보기는 아직 발행되지 않은 기록을 보는 + 화면이다. 대신 주소가 추측 불가능한 UUID 다. + + 봉투를 쓰지 않는다. 바이트를 반환하므로 감쌀 것이 없고, `` 는 JSON 을 읽지 + 않는다. + parameters: + - name: assetId + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: OK + content: + application/octet-stream: + schema: + type: string + format: binary + '404': + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '500': + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + components: schemas: FieldError: diff --git a/src/features/tech-log/contracts/studio/canonical-source.json b/src/features/tech-log/contracts/studio/canonical-source.json index 5b3ad97..5d04ea1 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:5229865c3d242f19d75030d3f524a44dfebbf444324068d6ae88e43b8047dba4", - "sourceRevision": "dc290b4", + "sourceRevision": "65a04fc", "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 a31d523..6b10ba7 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 @@ -39,6 +39,8 @@ const MANAGEMENT_ERROR_CODES = Object.freeze([ "DOCUMENT_IN_USE", "QUESTION_NOT_FOUND", "QUESTION_IN_USE", + "DECISION_NOT_FOUND", + "DECISION_IN_USE", "INTERNAL_ERROR", ]); @@ -393,6 +395,29 @@ const HTTP_CONTRACTS = Object.freeze([ }); }, ), + writeOperation( + "deleteProjectDecision", + "DELETE", + `${P}/{id}/decisions/{decisionId}`, + { + acceptedStatuses: [204], + emptyBodyStatuses: [204], + requestByteLimit: 1_024, + responseByteLimit: 1_024, + }, + (input: never) => { + const value = input as unknown as Readonly<{ + id: string; + decisionId: string; + expectedVersion: number; + }>; + return Object.freeze({ + pathValues: Object.freeze({ id: value.id, decisionId: value.decisionId }), + queryEntries: NO_QUERY, + body: { expectedVersion: value.expectedVersion }, + }); + }, + ), ]); export const TECH_LOG_MANAGEMENT_OPERATION_IDS = Object.freeze( diff --git a/src/features/tech-log/presentation/studio/components/asset-picker.tsx b/src/features/tech-log/presentation/studio/components/asset-picker.tsx index cab9dc7..c0ea455 100644 --- a/src/features/tech-log/presentation/studio/components/asset-picker.tsx +++ b/src/features/tech-log/presentation/studio/components/asset-picker.tsx @@ -2,6 +2,8 @@ import { useEffect, useId, useState, type FormEvent } from "react"; import type { Asset } from "../../../contracts/studio/contract.ts"; import type { StudioAssetGateway } from "../../../application/ports/studio-asset-gateway.ts"; +import { isStudioGatewayError } from "../../../application/ports/studio-gateway-error.ts"; +import { createLocalId } from "../../../domain/studio/local-id.ts"; /** One screenful of candidates; searching, not scrolling, reaches the rest. */ const PAGE_SIZE = 50; @@ -57,6 +59,14 @@ export function AssetPicker({ // than fewer, and no trailing request after the author stops), and it is the // pair `document-list.tsx` already uses for the same job. const [searchDraft, setSearchDraft] = useState(""); + /** + * 삽입할 때 확대를 허용할지. 예전에는 {@code kind === "DIAGRAM"} 일 때만 켰는데, 작성자가 + * 스크린샷을 ATTACHMENT 나 IMAGE 로 올리면 확대가 꺼진 채로 들어갔고 켜는 방법도 없었다 — + * "줌이 왜 꺼져 있는지 모르겠다" 가 그것이다. 그림이면 켜 두고, 끄고 싶으면 여기서 끈다. + */ + const [allowZoom, setAllowZoom] = useState(true); + const [removingId, setRemovingId] = useState(null); + const [notice, setNotice] = useState(""); const [q, setQ] = useState(""); const searchId = useId(); @@ -99,6 +109,27 @@ export function AssetPicker({ setQ(searchDraft.trim()); }; + const removeAsset = async (asset: Asset) => { + if (removingId !== null) return; + setRemovingId(asset.id); + setNotice(""); + try { + await gateway.deleteAsset(asset.id, { + idempotencyKey: createLocalId(`studio-asset-picker-delete-${asset.id}`), + }); + setAssets((current) => current.filter((entry) => entry.id !== asset.id)); + setNotice(`${asset.assetKey} 을(를) 삭제했습니다.`); + } catch (error) { + setNotice( + isStudioGatewayError(error) + ? error.problem.detail + : "삭제하지 못했습니다. 문서에서 쓰이고 있을 수 있습니다.", + ); + } finally { + setRemovingId(null); + } + }; + const listMessage = status === "LOADING" ? "Asset 목록을 불러오는 중입니다." : selectable.length > 0 @@ -124,6 +155,15 @@ export function AssetPicker({ + + {notice ?

{notice}

: null} {status === "ERROR" ?

Asset 목록을 불러오지 못했습니다.

:

{listMessage}

} @@ -135,11 +175,23 @@ export function AssetPicker({ assetKey: asset.assetKey, alt: asset.decorative ? "" : (asset.altText ?? ""), caption: "", - zoom: asset.kind === "DIAGRAM", + zoom: allowZoom && asset.mediaType.startsWith("image/"), }))} > {asset.assetKey} + {/* + 문서를 쓰다가 잘못 올린 Asset 을 여기서 바로 지운다. 예전에는 Asset 화면으로 나가야 + 했고, 그러면 편집 중인 작업본을 떠나야 했다. 쓰이고 있는 Asset 은 서버가 거절한다. + */} + )} : null} ; 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 504b6af..3495db1 100644 --- a/src/features/tech-log/presentation/studio/components/document-list.tsx +++ b/src/features/tech-log/presentation/studio/components/document-list.tsx @@ -29,12 +29,6 @@ function isAbortError(error: unknown): boolean { return error instanceof DOMException && error.name === "AbortError"; } -/** - * Decision 은 지울 수 없다. 계약에 삭제 operation 이 없고, 그건 누락이 아니라 판단이다 — 결정의 - * 수명주기는 수락·기각·대체이고 그 셋은 무슨 일이 있었는지 남기는 반면 삭제는 없앤다. - */ -const DELETABLE_KINDS = new Set(["CASE", "REFERENCE", "QUESTION"]); - export function DocumentList() { const { gateway, managementGateway, setRequestAnnouncement } = useStudio(); const [deletingId, setDeletingId] = useState(null); @@ -61,13 +55,34 @@ export function DocumentList() { setDeleteError(""); try { const detail = await gateway.getDocument(item.id); - await managementGateway.deleteDocument( - item.kind as "CASE" | "REFERENCE" | "QUESTION", - item.id, - detail.document.version, - ); + if (item.kind === "PROJECT_DECISION") { + // Decision 은 프로젝트에 속하고 경로가 둘을 요구한다. 목록 행이 프로젝트를 들고 있지 + // 않으면 지울 주소를 만들 수 없다 — 그때는 프로젝트를 먼저 지정해야 한다. + if (!item.project) { + setDeleteError("프로젝트에 속하지 않은 결정은 여기서 지울 수 없습니다. 먼저 프로젝트를 지정해 주세요."); + return; + } + await managementGateway.deleteDecision( + item.project.id, + item.id, + detail.document.version, + ); + } else { + await managementGateway.deleteDocument( + item.kind as "CASE" | "REFERENCE" | "QUESTION", + item.id, + detail.document.version, + ); + } setRequestAnnouncement(`작업본 ${item.title || "제목 없음"} 을(를) 삭제했습니다.`); - setRetryGeneration((value) => value + 1); + // 다시 불러오지 않고 이 행만 지운다. 목록은 커서 페이지네이션이라 재조회하면 다음 + // 항목이 빈 자리를 즉시 채우고, 개수도 20 그대로다 — 작성자에게는 삭제가 아무 일도 + // 하지 않은 것처럼 보인다. 삭제가 성공한 뒤의 화면은 그 행이 없는 화면이 맞다. + setPage((current) => + current + ? { ...current, items: current.items.filter((row) => row.id !== item.id) } + : current, + ); } catch { setDeleteError( "삭제하지 못했습니다. 게시 중이거나, 이 기록을 참조하는 곳이 있거나, 다른 곳에서 먼저 수정되었을 수 있습니다.", @@ -203,7 +218,14 @@ export function DocumentList() { ) : null} {page && !loading && !error ? ( <> -

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

+ {/* + 이 숫자는 전체가 아니라 이 페이지에 실린 수다. 예전 문구는 그것을 전체처럼 읽히게 + 해서, 28건 중 20건이 보이는 동안 무엇을 지워도 "20개" 가 그대로였다. + */} +

+ {page.items.length}개 표시 중 + {page.nextCursor ? · 더 있습니다 : null} +

{deleteError ? (

{deleteError} @@ -242,16 +264,14 @@ export function DocumentList() { - {DELETABLE_KINDS.has(item.kind) ? ( - - ) : null} + ))} diff --git a/src/features/tech-log/presentation/styles/studio-editor.css b/src/features/tech-log/presentation/styles/studio-editor.css index 9178b6b..f2055d2 100644 --- a/src/features/tech-log/presentation/styles/studio-editor.css +++ b/src/features/tech-log/presentation/styles/studio-editor.css @@ -86,6 +86,10 @@ a flex container both default to an automatic minimum of their min-content size, so at 360px the search row sized itself to 366px inside a 328px column and pushed the submit button off-screen (document scrollWidth 382). */ +/* Picker 자체의 옵션이지 문서의 입력 칸이 아니다 — `studio-field` 를 쓰면 편집기의 칸 목록에 + 섞여 들어간다. 모양은 그 칸들과 같게 두고 이름만 분리한다. */ +.studio-app .asset-picker-option { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; color: var(--muted); font-size: 12px; font-weight: 650; } +.studio-app .asset-picker-option input { width: 18px; min-width: 18px; min-height: 18px; padding: 0; } .studio-app .asset-picker-search { display: grid; grid-template-columns: minmax(0, 1fr); max-width: 420px; gap: 8px; margin-bottom: 14px; color: var(--muted); font-size: 12px; font-weight: 650; } .studio-app .asset-picker-search div { display: flex; min-width: 0; gap: 8px; } .studio-app .asset-picker-search input { min-width: 0; flex: 1; min-height: 44px; padding-inline: 12px; border: 1px solid var(--line-strong); border-radius: 5px; background: var(--paper); color: var(--ink); font-size: 14px; } diff --git a/tests/features/tech-log/studio-screens-smoke.test.tsx b/tests/features/tech-log/studio-screens-smoke.test.tsx index 3e3a6b3..47b173b 100644 --- a/tests/features/tech-log/studio-screens-smoke.test.tsx +++ b/tests/features/tech-log/studio-screens-smoke.test.tsx @@ -87,14 +87,14 @@ describe("TechLog Studio index screens", () => { const gateway = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT).input.createStudioGateway(); renderScreen(, gateway); - expect(await screen.findByText("7개의 작업본")).toBeVisible(); + expect(await screen.findByText("7개 표시 중")).toBeVisible(); fireEvent.change(screen.getByLabelText("검색"), { target: { value: "Fetch Join" } }); fireEvent.submit(screen.getByRole("search")); - expect(await screen.findByText("1개의 작업본")).toBeVisible(); + expect(await screen.findByText("1개 표시 중")).toBeVisible(); expect(screen.getByText("컬렉션 Fetch Join과 페이징은 왜 충돌하는가")).toBeVisible(); fireEvent.change(screen.getByLabelText("종류"), { target: { value: "QUESTION" } }); - expect(await screen.findByText("0개의 작업본")).toBeVisible(); + expect(await screen.findByText("0개 표시 중")).toBeVisible(); expect(screen.getByRole("heading", { level: 2, name: "조건에 맞는 작업본이 없습니다" })).toBeVisible(); }); @@ -116,7 +116,7 @@ describe("TechLog Studio index screens", () => { fireEvent.change(screen.getByLabelText("종류"), { target: { value: "CASE" } }); await waitFor(() => expect(obsoleteSignal?.aborted).toBe(true)); - expect(await screen.findByText("1개의 작업본")).toBeVisible(); + expect(await screen.findByText("1개 표시 중")).toBeVisible(); await userEvent.click(screen.getByRole("button", { name: "다음 작업본" })); await waitFor(() => expect(listDocuments).toHaveBeenCalledTimes(3)); expect(screen.getByText(secondPage.items[0]!.title)).toBeVisible(); @@ -138,7 +138,7 @@ describe("TechLog Studio index screens", () => { expect(await screen.findByRole("alert")).toHaveTextContent("작업본을 불러오지 못했습니다."); await user.click(screen.getByRole("button", { name: "다시 시도" })); - expect(await screen.findByText("7개의 작업본")).toBeVisible(); + expect(await screen.findByText("7개 표시 중")).toBeVisible(); expect(gateway.listDocuments).toHaveBeenCalledTimes(2); });