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
This commit is contained in:
DongHyeonka
2026-08-26 22:50:14 +09:00
co-authored by Claude Opus 5
parent 344a163d84
commit ab4d822956
2 changed files with 165 additions and 16 deletions
@@ -25,6 +25,7 @@ import {
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";
@@ -234,13 +235,19 @@ export function createHttpPublicContentGateway(
}
const body = (detail.question as Readonly<Record<string, unknown>>) ?? {};
const points = (body.points as readonly Readonly<Record<string, unknown>>[] | undefined) ?? [];
const pointsOf = (group: string) =>
Object.freeze(
points
.filter((point) => point.group === group)
.flatMap((point) => (point.items as readonly string[] | undefined) ?? []),
);
/*
`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,
@@ -249,18 +256,33 @@ export function createHttpPublicContentGateway(
primaryTopic: body.primaryTopic as never,
primaryProject: body.primaryProject as never,
publishedAt: body.updatedAt as string,
relations: flattenRelations(groups, {
derivedCases: "이 질문에서 나온 기록",
projectDecisions: "이 질문이 이끈 결정",
relatedQuestions: "관련 질문",
}),
/*
계약이 주는 이름은 `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("KNOWN_FACT"),
assumptions: pointsOf("ASSUMPTION"),
unknowns: pointsOf("UNRESOLVED"),
constraints: pointsOf("CONSTRAINT"),
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 }>;