Files
tech-log-frontend/src/features/tech-log/adapters/http/http-public-content-gateway.ts
T
DongHyeonkaandClaude Opus 5 ab4d822956 fix: 게시된 Open Question 의 공개 상세가 열리게 한다
게시한 질문의 공개 상세가 「요청을 처리하지 못했습니다」만 띄웠다. 게이트웨이가 `points`
를 `{group, items}` 배열로 읽고 `.filter` 를 불렀는데, 계약의 `QuestionPointGroup` 은
`facts`/`assumptions`/`unknowns`/`constraints` 를 키로 갖는 객체다. 객체에는 `.filter` 가
없으니 매핑이 통째로 터졌다.

목록은 이 칸들을 빈 배열로 두고 만들기 때문에 탐색에서는 멀쩡히 보였다. 그래서 "게시했는데
public 에 안 뜬다" 로만 드러났고 어느 층이 깨졌는지는 보이지 않았다. `as` 캐스트가 그
어긋남을 타입 검사에서 가렸다 — 이제 계약의 타입을 그대로 써서 모양이 바뀌면 컴파일이
먼저 막는다.

관계도 같은 종류로 어긋나 있었다. 계약이 주는 이름은 `resultCase`/`producedDecision`/
`derivedReferences` 인데 매퍼는 `derivedCases`/`projectDecisions`/`relatedQuestions` 를
찾고 있었고, 하나도 맞지 않아 이유 자리에 영문 키가 그대로 나왔다. `primaryProject` 는
관계가 아니라 이 질문이 속한 프로젝트이므로 관계 목록에서 뺀다 — 머리말이 이미 보여 준다.

이 사고가 지나간 이유는 HTTP 게이트웨이의 질문 상세 매핑을 지나는 테스트가 없었기
때문이다. 화면 테스트는 정적 픽스처 어댑터를 쓰므로 계약 모양을 한 번도 통과시키지
않는다. 계약 모양 그대로의 응답을 진짜 게이트웨이에 넣고 네 칸이 채워져 나오는지 묻는
테스트를 넣는다 — 되돌려 보면 운영에서 난 것과 같은 `points.filter is not a function`
으로 실패한다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0189NzCryfeqDzS81EWidnBx
2026-08-26 22:50:14 +09:00

534 lines
22 KiB
TypeScript

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<T>(operationId: string, input: unknown): Promise<T | typeof NOT_FOUND> {
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<T>(operationId: string, input: unknown): Promise<T> {
const value = await read<T>(operationId, input);
if (value === NOT_FOUND) throw gatewayError(operationId, "NOT_FOUND");
return value;
}
type Page = Readonly<{ items?: readonly Readonly<Record<string, unknown>>[] }>;
/**
* `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<PublicRecord[]> {
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<Page>("exploreKnowledge", {
...query,
...(filters.kind && filters.kind !== "QUESTION" ? { type: filters.kind } : {}),
})
: Promise.resolve({ items: [] } as Page),
wantsQuestions
? readOrThrow<Page>("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<PublicRecord, {kind: K}>`
* 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<K extends RecordKind>(
kind: K,
slug: string,
): Promise<Extract<PublicRecord, { kind: K }> | undefined> {
const operationId =
kind === "CASE" ? "getPublicCase" : kind === "REFERENCE" ? "getPublicReference" : "getPublicQuestion";
const detail = await read<Readonly<Record<string, unknown>>>(operationId, { slug });
if (detail === NOT_FOUND) return undefined;
const canonicalPath = String(detail.canonicalPath ?? "");
const groups = (detail.relations as Readonly<Record<string, never>>) ?? {};
if (kind === "CASE") {
const body = (detail.case as Readonly<Record<string, unknown>>) ?? {};
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<Record<string, unknown>>[]) ?? []).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<PublicRecord, { kind: K }>;
}
if (kind === "REFERENCE") {
const body = (detail.reference as Readonly<Record<string, unknown>>) ?? {};
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<Record<string, unknown>>[] | 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<PublicRecord, { kind: K }>;
}
const body = (detail.question as Readonly<Record<string, unknown>>) ?? {};
/*
`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<PublicRecord, { kind: K }>;
}
async function getProject(slug: string): Promise<Project | undefined> {
const detail = await read<Readonly<Record<string, unknown>>>("getPublicProject", { slug });
if (detail === NOT_FOUND) return undefined;
const body = (detail.project as Readonly<Record<string, unknown>>) ?? {};
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<ProjectDecision[]> {
const page = await read<Page>("listPublicProjectDecisions", { slug: projectSlug });
if (page === NOT_FOUND) return [];
return (page.items ?? []).map(decisionItemToDecision);
}
async function getProjectActivity(projectSlug: string): Promise<ProjectActivity[]> {
const page = await read<Page>("listPublicProjectActivities", { slug: projectSlug });
if (page === NOT_FOUND) return [];
return (page.items ?? []).map(activityItemToActivity);
}
async function getProjectRecords(projectSlug: string): Promise<PublicRecord[]> {
const page = await read<Page>("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<Release | undefined> {
const detail = await read<Readonly<Record<string, unknown>>>("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<PublicTopic[]> {
const page = await read<Page>("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<LatestRecordEntry[]> {
const home = await read<Readonly<{ latestEntries?: readonly Readonly<Record<string, unknown>>[] }>>(
"getPublicHome",
{},
);
if (home === NOT_FOUND) return [];
return (home.latestEntries ?? []).map((entry) => {
const topic = entry.primaryTopic as Readonly<Record<string, unknown>> | null | undefined;
const project = entry.primaryProject as Readonly<Record<string, unknown>> | 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<HomeFocusItem[]> {
const home = await read<Readonly<{ focus?: Readonly<Record<string, never>> }>>(
"getPublicHome",
{},
);
if (home === NOT_FOUND) return [];
const focus = (home.focus ?? {}) as Readonly<Record<string, Readonly<Record<string, unknown>>>>;
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<SearchablePublicEntity[]> {
const trimmed = query.trim();
if (trimmed.length > 0) {
const page = await read<Page>("searchPublicResources", { q: trimmed });
if (page === NOT_FOUND) return [];
return (page.items ?? []).map(searchItemToEntity);
}
const [knowledge, questions, projects, releases] = await Promise.all([
read<Page>("exploreKnowledge", {}),
read<Page>("exploreQuestions", {}),
read<Page>("listPublicProjects", {}),
read<Page>("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,
});
}