import type { HomeFocusItem, LatestRecordEntry, ProjectActivity, ProjectDecision, Project, PublicContentQueries, PublicRecord, PublicTopic, QuestionRecord, RecordFilters, RecordKind, Release, SearchablePublicEntity, } from "../../application/ports/public-content-queries.ts"; import { activityItemToActivity, baseOf, dateLabel, decisionItemToDecision, flattenRelations, knowledgeListItemToRecord, markdownSections, questionListItemToRecord, releaseDetailToRelease, searchItemToEntity, } from "./public-content-mapping.ts"; import type { components } from "../../contracts/public/generated.ts"; import type { StudioOperationExecutor } from "./http-studio-gateway.ts"; const ROUTE_ID = "TECH_LOG_PUBLIC"; /** * A missing slug is an answer, not a failure. * * The port returns `undefined` for a record that is not published, and the * screens turn that into their not-found route. So a 404 is unwrapped here * rather than thrown — throwing would put the terminal-error surface on a page * whose real state is "this does not exist". */ const NOT_FOUND = Symbol("not-found"); export type PublicContentGatewayError = Error & { readonly failure?: unknown }; function gatewayError(operationId: string, detail: string): PublicContentGatewayError { const error = new Error(`${operationId}: ${detail}`) as PublicContentGatewayError; error.name = "PublicContentGatewayError"; return error; } /** * Reads the backend's error code out of either response shape — RFC7807 puts it * at the top level, the ADR-006 envelope nests it under `error`. Without this * every failure was reported as the literal "PROBLEM", which told a reader * nothing and matched no i18n key. */ function problemCode(problem: unknown): string { if (!problem || typeof problem !== "object") return "PROBLEM"; const body = problem as Readonly<{ code?: unknown; error?: Readonly<{ code?: unknown }> }>; if (typeof body.code === "string") return body.code; if (typeof body.error?.code === "string") return body.error.code; return "PROBLEM"; } export function createHttpPublicContentGateway( deps: Readonly<{ operations: StudioOperationExecutor }>, ): PublicContentQueries { async function read(operationId: string, input: unknown): Promise { const outcome = await deps.operations.execute(operationId, input, { routeId: ROUTE_ID }); if (outcome.kind === "SUCCESS") return outcome.value as T; if (outcome.kind === "PROBLEM") { // The HTTP status is the authoritative signal, and the only one that // holds across both shapes this surface answers with. RFC7807 carries // `status` in the body; the ADR-006 envelope does not — it puts the // reason in `error.category` and a backend-specific string in // `error.code` (PUBLIC_RESOURCE_NOT_FOUND, not NOT_FOUND). The old // body-only check matched neither, so every 404 raised the terminal // error surface on a page whose real state was "this does not exist". if (outcome.metadata.status === 404) return NOT_FOUND; throw gatewayError(operationId, problemCode(outcome.problem)); } throw gatewayError(operationId, outcome.kind); } async function readOrThrow(operationId: string, input: unknown): Promise { const value = await read(operationId, input); if (value === NOT_FOUND) throw gatewayError(operationId, "NOT_FOUND"); return value; } type Page = Readonly<{ items?: readonly Readonly>[] }>; /** * `listRecords` is one port method over two endpoints: the contract splits * knowledge (Case, Reference) from questions because they page and filter * differently. A caller that asks for one kind must not pay for the other, so * the unfiltered call is the only one that fans out. */ async function listRecords(filters: RecordFilters = {}): Promise { const wantsQuestions = !filters.kind || filters.kind === "QUESTION"; const wantsKnowledge = !filters.kind || filters.kind !== "QUESTION"; const query = { ...(filters.topic ? { topic: filters.topic } : {}), ...(filters.project ? { project: filters.project } : {}), }; const [knowledge, questions] = await Promise.all([ wantsKnowledge ? readOrThrow("exploreKnowledge", { ...query, ...(filters.kind && filters.kind !== "QUESTION" ? { type: filters.kind } : {}), }) : Promise.resolve({ items: [] } as Page), wantsQuestions ? readOrThrow("exploreQuestions", { ...query, ...(filters.openQuestionsOnly ? { status: "OPEN" } : {}), }) : Promise.resolve({ items: [] } as Page), ]); const records = [ ...(knowledge.items ?? []).map(knowledgeListItemToRecord).filter((r): r is PublicRecord => r !== null), ...(questions.items ?? []).map(questionListItemToRecord), ]; return records.sort((left, right) => right.publishedAt.localeCompare(left.publishedAt)); } /** * The cast at each return is not laziness. `kind` is a generic parameter, so * narrowing it inside the body does not narrow `Extract` * with it — the compiler cannot know the branch it took corresponds to the K it * was given. The discriminant on each object is a literal, so the shape is * checked; only the tie back to K is asserted. */ async function getRecord( kind: K, slug: string, ): Promise | undefined> { const operationId = kind === "CASE" ? "getPublicCase" : kind === "REFERENCE" ? "getPublicReference" : "getPublicQuestion"; const detail = await read>>(operationId, { slug }); if (detail === NOT_FOUND) return undefined; const canonicalPath = String(detail.canonicalPath ?? ""); const groups = (detail.relations as Readonly>) ?? {}; if (kind === "CASE") { const body = (detail.case as Readonly>) ?? {}; return Object.freeze({ ...baseOf("CASE", slug, { title: body.title as string, // 제목 바로 아래에 오는 것은 문서의 요약이다. 유형별 요약(문제/범위)을 쓰면 바로 아래 // 블록과 같은 글을 두 번 말한다. summary: (body.summary as string) ?? "", path: canonicalPath, primaryTopic: body.primaryTopic as never, primaryProject: body.primaryProject as never, publishedAt: body.publishedAt as string, relations: flattenRelations(groups, { originQuestion: "이 기록이 시작된 질문", projectDecisions: "이 기록이 뒷받침하는 결정", derivedReferences: "이 기록에서 정리된 기준", relatedCases: "관련 기록", }), }), kind: "CASE", problem: (body.problemSummary as string) ?? "", conclusion: (body.conclusionSummary as string) ?? "", // `environmentSummary` 는 검증 환경과 재현 조건을 그 순서로 담는다 — 서버가 비어 있지 // 않은 것만 순서대로 넣는다. 예전에는 둘을 쉼표로 이어 붙여 한 칸에 넣고 재현 조건 칸은 // "계약에 없다"며 비워 두었는데, 계약에는 있었고 채우는 쪽이 없었을 뿐이다. environment: ((body.environmentSummary as readonly string[]) ?? [])[0] ?? "", verification: ((body.environmentSummary as readonly string[]) ?? [])[1] ?? "", lastVerifiedLabel: dateLabel(body.lastVerifiedAt as string), content: (body.content as string) ?? "", bodyAssets: Object.freeze( ((body.bodyAssets as readonly Readonly>[]) ?? []).map((asset) => Object.freeze({ assetKey: asset.assetKey as string, assetId: asset.assetId as string, url: asset.url as string, contentType: asset.contentType as string, altText: (asset.altText as string) ?? "", width: (asset.width as number) ?? null, height: (asset.height as number) ?? null, decorative: Boolean(asset.decorative), }), ), ), sections: markdownSections(body.content as string), }) as unknown as Extract; } if (kind === "REFERENCE") { const body = (detail.reference as Readonly>) ?? {}; return Object.freeze({ ...baseOf("REFERENCE", slug, { title: body.title as string, summary: (body.summary as string) ?? "", path: canonicalPath, primaryTopic: body.primaryTopic as never, primaryProject: body.primaryProject as never, publishedAt: body.publishedAt as string, relations: flattenRelations(groups, { originCases: "이 기준이 나온 기록", projectDecisions: "이 기준을 따르는 결정", relatedReferences: "관련 기준", }), }), kind: "REFERENCE", /* 여기서 읽는 이름은 계약이 실제로 주는 이름이어야 한다. 한때 `purposeSummary`, `applyWhenMarkdown`, `exceptionsMarkdown`, `examplesMarkdown` 을 읽었는데 계약에는 그런 칸이 없다 — 전부 undefined 로 떨어져 공개 Reference 화면이 통째로 비었다. Studio 에서는 같은 글이 다 보이므로 "공개 쪽만 안 나온다" 로 드러났다. 규칙과 예시는 `content` 마크다운을 잘라 만드는 것이 아니라 계약이 구조로 준다. Studio 의 편집기가 제목과 본문을 따로 받기 때문이다. */ purpose: (body.scopeSummary as string) ?? "", rules: Object.freeze( ((body.rules as readonly Readonly>[] | undefined) ?? []).map( (rule) => ({ title: String(rule.title ?? ""), body: String(rule.body ?? ""), }), ), ), applyWhen: Object.freeze(((body.appliesTo as readonly string[] | undefined) ?? []).map(String)), exceptions: Object.freeze( ((body.excludedScope as readonly string[] | undefined) ?? []).map(String), ), examples: Object.freeze(((body.examples as readonly string[] | undefined) ?? []).map(String)), verifiedAt: dateLabel(body.lastVerifiedAt as string), }) as unknown as Extract; } const body = (detail.question as Readonly>) ?? {}; /* `points` 는 그룹 이름을 키로 갖는 객체다 — 계약의 `QuestionPointGroup`. 여기서는 `{group, items}` 배열로 읽으면서 `.filter` 를 불렀고, 객체에는 그런 것이 없으니 상세 화면이 통째로 「요청을 처리하지 못했습니다」가 됐다. 목록은 이 칸을 비워 두고 만들기 때문에 탐색에서는 멀쩡히 보였고, 그래서 "게시했는데 안 뜬다" 로만 드러났다. `as` 캐스트가 그 어긋남을 타입 검사에서 가렸다. 계약의 타입을 그대로 쓰면 다음에 모양이 바뀔 때 컴파일이 먼저 막는다. */ type QuestionPoints = components["schemas"]["QuestionPointGroup"]; const points = body.points as QuestionPoints | undefined; const pointsOf = (group: keyof QuestionPoints) => Object.freeze([...(points?.[group] ?? [])].map(String)); return Object.freeze({ ...baseOf("QUESTION", slug, { title: body.question as string, summary: body.summary as string, path: canonicalPath, primaryTopic: body.primaryTopic as never, primaryProject: body.primaryProject as never, publishedAt: body.updatedAt as string, /* 계약이 주는 이름은 `resultCase` / `producedDecision` / `derivedReferences` 다. 여기서는 `derivedCases` / `projectDecisions` / `relatedQuestions` 를 찾고 있었고, 하나도 맞지 않아 이유 자리에 영문 키가 그대로 나왔다. `primaryProject` 는 관계가 아니라 이 질문이 속한 프로젝트다 — 머리말이 이미 보여 주므로 관계 목록에 넣지 않는다. */ relations: flattenRelations( { resultCase: groups.resultCase, producedDecision: groups.producedDecision, derivedReferences: groups.derivedReferences, }, { resultCase: "이 질문에서 나온 기록", producedDecision: "이 질문이 이끈 결정", derivedReferences: "이 질문에서 정리된 기준", }, ), }), kind: "QUESTION", questionStatus: (body.status as QuestionRecord["questionStatus"]) ?? "OPEN", facts: pointsOf("facts"), assumptions: pointsOf("assumptions"), unknowns: pointsOf("unknowns"), constraints: pointsOf("constraints"), options: Object.freeze([]), nextValidation: (body.nextVerification as string) ?? "", }) as unknown as Extract; } async function getProject(slug: string): Promise { const detail = await read>>("getPublicProject", { slug }); if (detail === NOT_FOUND) return undefined; const body = (detail.project as Readonly>) ?? {}; const [decisions, activity] = await Promise.all([ getProjectDecisions(slug), getProjectActivity(slug), ]); return Object.freeze({ slug, title: String(body.name ?? ""), summary: String(body.oneLinePurpose ?? ""), thesis: String(body.purpose ?? body.oneLinePurpose ?? ""), stage: body.phase === "VALIDATION" ? "VALIDATION" : "DESIGN", currentGoal: String(body.currentObjective ?? ""), nextStep: String(body.nextStep ?? ""), topics: Object.freeze( ((body.topics as readonly Readonly<{ name?: string }>[] | undefined) ?? []) .map((topic) => topic.name ?? "") .filter((name) => name.length > 0), ), decisions: Object.freeze(decisions), activity: Object.freeze(activity), }); } async function getProjectDecisions(projectSlug: string): Promise { const page = await read("listPublicProjectDecisions", { slug: projectSlug }); if (page === NOT_FOUND) return []; return (page.items ?? []).map(decisionItemToDecision); } async function getProjectActivity(projectSlug: string): Promise { const page = await read("listPublicProjectActivities", { slug: projectSlug }); if (page === NOT_FOUND) return []; return (page.items ?? []).map(activityItemToActivity); } async function getProjectRecords(projectSlug: string): Promise { const page = await read("listPublicProjectRecords", { slug: projectSlug }); if (page === NOT_FOUND) return []; return (page.items ?? []) .map(knowledgeListItemToRecord) .filter((record): record is PublicRecord => record !== null); } async function getRelease(version: string): Promise { const detail = await read>>("getPublicRelease", { version }); if (detail === NOT_FOUND) return undefined; return releaseDetailToRelease(detail, version); } /** * The home screen shows up to three focus cards. The contract returns them as * 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), }), ); } /** * 공개 투영이 고른 최근 기록. `entryType` 은 계약이 네 값만 허용하므로 그대로 믿고 쓴다 — * 서버가 이미 걸러 보낸다. */ async function getLatestEntries(): Promise { const home = await read>[] }>>( "getPublicHome", {}, ); if (home === NOT_FOUND) return []; return (home.latestEntries ?? []).map((entry) => { const topic = entry.primaryTopic as Readonly> | null | undefined; const project = entry.primaryProject as Readonly> | null | undefined; const path = String(entry.path ?? ""); return Object.freeze({ id: `${String(entry.entryType ?? "")}:${path}`, entryType: String(entry.entryType ?? "CASE") as LatestRecordEntry["entryType"], title: String(entry.title ?? ""), summary: String(entry.summary ?? ""), path, publishedAt: String(entry.publishedAt ?? ""), topic: topic ? String(topic.name ?? "") : "", project: project ? String(project.name ?? "") : "", }); }); } async function getHomeFocusItems(): Promise { const home = await read> }>>( "getPublicHome", {}, ); if (home === NOT_FOUND) return []; const focus = (home.focus ?? {}) as Readonly>>>; const items: HomeFocusItem[] = []; const work = focus.currentWork; if (work) { items.push( Object.freeze({ key: "current", label: "지금 하는 일", title: String(work.projectName ?? ""), summary: String(work.purpose ?? ""), details: Object.freeze([ { label: "단계", value: String(work.phase ?? "") }, { label: "현재 목표", value: String(work.currentObjective ?? "") }, { label: "다음 작업", value: String(work.nextStep ?? "") }, ]), targetPath: String(work.projectPath ?? "/projects"), }), ); } const question = focus.openQuestion; if (question) { items.push( Object.freeze({ key: "question", label: "열린 질문", title: String(question.question ?? ""), summary: String(question.summary ?? ""), details: Object.freeze([ { label: "확인한 사실", value: ((question.knownFacts as readonly string[]) ?? []).join(" · ") }, { label: "미해결", value: ((question.unresolvedPoints as readonly string[]) ?? []).join(" · ") }, { label: "다음 검증", value: String(question.nextVerification ?? "") }, ]), targetPath: String(question.questionPath ?? "/explore/questions"), }), ); } const decision = focus.recentDecision; if (decision) { items.push( Object.freeze({ key: "decision", label: "최근 결정", title: String(decision.statement ?? ""), summary: String(decision.rationale ?? ""), details: Object.freeze([ { label: "결정일", value: dateLabel(decision.decidedAt as string) }, { label: "영향", value: ((decision.consequences as readonly string[]) ?? []).join(" · ") }, ]), targetPath: String(decision.decisionPath ?? "/projects"), }), ); } return items; } /** * 빈 검색어는 검색이 아니라 "카탈로그 전부"라는 뜻이다. * * 픽스처가 그렇게 동작했고 화면들이 그 의미에 기대어 쓰고 있다 — 홈 타임라인, * 프로젝트 목록, 릴리즈 목록, 탐색 필터가 전부 `searchPublicContent("")` 로 카탈로그를 * 받아 간다. 계약에는 그런 의미가 없고 `q` 는 필수라, 그대로 보내면 400 * (`PUBLIC_REQUEST_INVALID`) 이 오고 홈을 포함한 네 화면이 통째로 오류 화면이 된다. * * 그래서 빈 검색어는 검색 엔드포인트로 보내지 않고, 계약이 이미 가진 목록 * 엔드포인트에서 조립한다. 검색어가 있으면 그때는 서버 검색을 쓴다 — 클라이언트에서 * 거르면 페이지 밖의 결과를 영영 못 찾는다. */ async function searchPublicContent(query: string): Promise { const trimmed = query.trim(); if (trimmed.length > 0) { const page = await read("searchPublicResources", { q: trimmed }); if (page === NOT_FOUND) return []; return (page.items ?? []).map(searchItemToEntity); } const [knowledge, questions, projects, releases] = await Promise.all([ read("exploreKnowledge", {}), read("exploreQuestions", {}), read("listPublicProjects", {}), read("listPublicReleases", {}), ]); const items = (page: Page | typeof NOT_FOUND) => page === NOT_FOUND ? [] : (page.items ?? []); const entities: SearchablePublicEntity[] = []; for (const item of items(knowledge)) { const record = knowledgeListItemToRecord(item); if (record) entities.push(recordToEntity(record)); } for (const item of items(questions)) { entities.push(recordToEntity(questionListItemToRecord(item))); } for (const item of items(projects)) { entities.push( Object.freeze({ contentType: "PROJECT", title: String(item.name ?? ""), summary: String(item.oneLinePurpose ?? ""), path: String(item.path ?? `/projects/${String(item.slug ?? "")}`), }), ); } for (const item of items(releases)) { entities.push( Object.freeze({ contentType: "RELEASE", title: String(item.title ?? ""), summary: String(item.summary ?? ""), path: String(item.path ?? `/releases/${String(item.version ?? "")}`), }), ); } return entities; } function recordToEntity(record: PublicRecord): SearchablePublicEntity { return Object.freeze({ contentType: record.kind, title: record.title, summary: record.summary, path: record.path, ...(record.topic ? { topic: record.topic } : {}), ...(record.projectTitle ? { project: record.projectTitle } : {}), ...(record.publishedAt ? { publishedAt: record.publishedAt } : {}), }); } return Object.freeze({ listRecords, getRecord, getProject, getRelease, listTopics, getProjectRecords, getProjectDecisions, getProjectActivity, getHomeFocusItems, getLatestEntries, searchPublicContent, }); }