게시한 질문의 공개 상세가 「요청을 처리하지 못했습니다」만 띄웠다. 게이트웨이가 `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
534 lines
22 KiB
TypeScript
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,
|
|
});
|
|
}
|