Compare commits
24
Commits
eb86708076
...
89a73c13c6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89a73c13c6 | ||
|
|
21f8425f1e | ||
|
|
7093d84ab5 | ||
|
|
d2c289c650 | ||
|
|
5cffe30200 | ||
|
|
197b2c7e72 | ||
|
|
c5e8735041 | ||
|
|
ab8c6c14db | ||
|
|
3754269118 | ||
|
|
760071156d | ||
|
|
03986da3d6 | ||
|
|
31dca00857 | ||
|
|
11c2713139 | ||
|
|
4b62bf3b1f | ||
|
|
6784eb1ce6 | ||
|
|
24c01aedf2 | ||
|
|
4566f2d7a8 | ||
|
|
c362ec6100 | ||
|
|
83409bef7a | ||
|
|
5e2b1a5586 | ||
|
|
f1498feee5 | ||
|
|
5fe355483e | ||
|
|
44caa477e3 | ||
|
|
fff5e6f59e |
+96
@@ -0,0 +1,96 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
#
|
||||||
|
# The frontend deployment artifact. The repository had none — `dist/server.mjs`
|
||||||
|
# is a preview server that applies neither the security headers nor the cache
|
||||||
|
# policy `config/hosting/` declares — so a deployment had nothing to run.
|
||||||
|
#
|
||||||
|
# Two stages: the build produces `dist/` and, from the serving contract, the
|
||||||
|
# nginx configuration that matches it; the runtime is nginx with both.
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# build
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Pinned by digest: the release-provenance gate requires an immutable runner
|
||||||
|
# identity, and a floating tag cannot give one.
|
||||||
|
FROM node@sha256:3638d9a6fe4030bd716be989438248074489337ba3275657f93595428be4fc03 AS build
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
# The profile is baked at build time (scripts/generate-runtime-config.ts), so it
|
||||||
|
# has to be chosen here rather than at `docker run`. `dist/config.json` stays a
|
||||||
|
# separate file in the image, which is what makes build-once/promote possible:
|
||||||
|
# a deployment can replace just that file without rebuilding the bundle.
|
||||||
|
ARG APP_PROFILE=production
|
||||||
|
ENV APP_PROFILE=${APP_PROFILE}
|
||||||
|
|
||||||
|
# The bundle and the nginx locations must agree on the prefix the deployment
|
||||||
|
# serves this under: "/" at a domain root, "/dev/" behind a path prefix.
|
||||||
|
ARG VITE_ROUTER_BASE_PATH=/
|
||||||
|
ENV VITE_ROUTER_BASE_PATH=${VITE_ROUTER_BASE_PATH}
|
||||||
|
|
||||||
|
# `CI=true` turns on the release-provenance gate (scripts/lib/build-environment.ts),
|
||||||
|
# which refuses to build without an identity for the artifact. That is the point:
|
||||||
|
# a deployed bundle that cannot say which commit it came from is not traceable,
|
||||||
|
# and the checklist asks exactly that. Supplied as build args so the caller —
|
||||||
|
# a pipeline or the deploy script — owns the values.
|
||||||
|
ENV CI=true
|
||||||
|
ARG VITE_BUILD_ID
|
||||||
|
ARG VITE_COMMIT_SHA
|
||||||
|
ARG RELEASE_ID
|
||||||
|
ARG CI_RUNNER_IMAGE
|
||||||
|
ARG SOURCE_DATE_EPOCH
|
||||||
|
ENV VITE_BUILD_ID=${VITE_BUILD_ID}
|
||||||
|
ENV VITE_COMMIT_SHA=${VITE_COMMIT_SHA}
|
||||||
|
ENV RELEASE_ID=${RELEASE_ID}
|
||||||
|
ENV CI_RUNNER_IMAGE=${CI_RUNNER_IMAGE}
|
||||||
|
ENV SOURCE_DATE_EPOCH=${SOURCE_DATE_EPOCH}
|
||||||
|
|
||||||
|
# The two values a deployment is allowed to supply (scripts/generate-runtime-
|
||||||
|
# config.ts OVERRIDES); everything else is fixed by the profile. API_BASE_URL
|
||||||
|
# has to be absolute — the runtime canonicalises it with `new URL(value)` — so
|
||||||
|
# even a same-origin deployment names its own origin here. The committed
|
||||||
|
# production profile ships a placeholder (https://api.example.com/), which is
|
||||||
|
# what a deployment that forgets this would silently serve.
|
||||||
|
ARG RUNTIME_API_BASE_URL
|
||||||
|
ARG RUNTIME_TELEMETRY_ENDPOINT
|
||||||
|
ENV RUNTIME_API_BASE_URL=${RUNTIME_API_BASE_URL}
|
||||||
|
ENV RUNTIME_TELEMETRY_ENDPOINT=${RUNTIME_TELEMETRY_ENDPOINT}
|
||||||
|
|
||||||
|
RUN corepack enable
|
||||||
|
|
||||||
|
# Dependencies first so a source-only change does not re-resolve them.
|
||||||
|
COPY package.json pnpm-lock.yaml ./
|
||||||
|
RUN corepack pnpm install --frozen-lockfile --ignore-scripts
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN corepack pnpm build \
|
||||||
|
&& node scripts/generate-nginx-config.ts
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# runtime
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
FROM nginx@sha256:65645c7bb6a0661892a8b03b89d0743208a18dd2f3f17a54ef4b76fb8e2f2a10 AS runtime
|
||||||
|
|
||||||
|
# Replaces the packaged default server block; the generated file is the whole
|
||||||
|
# server definition, including the BFF proxy locations.
|
||||||
|
RUN rm /etc/nginx/conf.d/default.conf
|
||||||
|
COPY --from=build /src/dist/nginx.conf /etc/nginx/conf.d/tech-log.conf
|
||||||
|
COPY --from=build /src/dist/ /usr/share/nginx/html/
|
||||||
|
|
||||||
|
# The generated config is served from /usr/share/nginx/html as root, so the two
|
||||||
|
# copies above would also publish nginx.conf itself. It is not secret, but it is
|
||||||
|
# not a page either.
|
||||||
|
RUN rm -f /usr/share/nginx/html/nginx.conf /usr/share/nginx/html/server.mjs \
|
||||||
|
&& rm -rf /usr/share/nginx/html/.vite \
|
||||||
|
# The build writes config.json 0600, which nginx (running as `nginx`) cannot
|
||||||
|
# read — the container came up healthy and answered 403 for the one file the
|
||||||
|
# SPA needs before it can boot. Normalise what is served to world-readable.
|
||||||
|
&& chmod -R a+rX /usr/share/nginx/html
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
# No `nginx -t` here: proxy_pass names are resolved when the config loads, and
|
||||||
|
# `backend`/`keycloak` only exist on the compose network. The container's own
|
||||||
|
# startup is the check, and it fails loudly.
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||||
|
CMD wget --no-verbose --tries=1 --spider http://127.0.0.1${VITE_ROUTER_BASE_PATH:-/}config.json || exit 1
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# TECH_LOG_STUDIO_RELEASES accessibility review
|
||||||
|
|
||||||
|
Status: pending-manual-review
|
||||||
|
Route ID: TECH_LOG_STUDIO_RELEASES
|
||||||
|
Release ID:
|
||||||
|
Reviewer:
|
||||||
|
Reviewed at:
|
||||||
|
Signature:
|
||||||
|
Attestation: pending
|
||||||
|
M1 Keyboard: pending
|
||||||
|
M2 Visible focus: pending
|
||||||
|
M3 Route focus: pending
|
||||||
|
M4 Modal focus: pending
|
||||||
|
M5 Error association: pending
|
||||||
|
M6 Color signal: pending
|
||||||
|
M7 Reduced motion: pending
|
||||||
|
Screen reader: pending
|
||||||
|
Notes: Human review pending; automated evidence does not replace signed keyboard, focus, and screen-reader review.
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# TECH_LOG_STUDIO_TAXONOMY accessibility review
|
||||||
|
|
||||||
|
Status: pending-manual-review
|
||||||
|
Route ID: TECH_LOG_STUDIO_TAXONOMY
|
||||||
|
Release ID:
|
||||||
|
Reviewer:
|
||||||
|
Reviewed at:
|
||||||
|
Signature:
|
||||||
|
Attestation: pending
|
||||||
|
M1 Keyboard: pending
|
||||||
|
M2 Visible focus: pending
|
||||||
|
M3 Route focus: pending
|
||||||
|
M4 Modal focus: pending
|
||||||
|
M5 Error association: pending
|
||||||
|
M6 Color signal: pending
|
||||||
|
M7 Reduced motion: pending
|
||||||
|
Screen reader: pending
|
||||||
|
Notes: Human review pending; automated evidence does not replace signed keyboard, focus, and screen-reader review.
|
||||||
@@ -1196,6 +1196,18 @@
|
|||||||
"schemaId": "markdown",
|
"schemaId": "markdown",
|
||||||
"production": "source-controlled"
|
"production": "source-controlled"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-TAXONOMY-md",
|
||||||
|
"path": "artifacts/tests/a11y-manual/TECH_LOG_STUDIO_TAXONOMY.md",
|
||||||
|
"schemaId": "markdown",
|
||||||
|
"production": "source-controlled"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-RELEASES-md",
|
||||||
|
"path": "artifacts/tests/a11y-manual/TECH_LOG_STUDIO_RELEASES.md",
|
||||||
|
"schemaId": "markdown",
|
||||||
|
"production": "source-controlled"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-NOT-FOUND-md",
|
"id": "artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-NOT-FOUND-md",
|
||||||
"path": "artifacts/tests/a11y-manual/TECH_LOG_STUDIO_NOT_FOUND.md",
|
"path": "artifacts/tests/a11y-manual/TECH_LOG_STUDIO_NOT_FOUND.md",
|
||||||
@@ -1949,6 +1961,8 @@
|
|||||||
"artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-PUBLICATIONS-md",
|
"artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-PUBLICATIONS-md",
|
||||||
"artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-PUBLICATION-PREVIEW-md",
|
"artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-PUBLICATION-PREVIEW-md",
|
||||||
"artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-ASSETS-md",
|
"artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-ASSETS-md",
|
||||||
|
"artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-TAXONOMY-md",
|
||||||
|
"artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-RELEASES-md",
|
||||||
"artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-NOT-FOUND-md",
|
"artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-NOT-FOUND-md",
|
||||||
"artifact-artifacts-tests-a11y-manual-NOT-FOUND-md",
|
"artifact-artifacts-tests-a11y-manual-NOT-FOUND-md",
|
||||||
"artifact-artifacts-tests-a11y-manual-report-json"
|
"artifact-artifacts-tests-a11y-manual-report-json"
|
||||||
|
|||||||
@@ -280,6 +280,86 @@
|
|||||||
"compatibilityWindow": "Existing valid envelopes continue to decode; invalid values fail closed.",
|
"compatibilityWindow": "Existing valid envelopes continue to decode; invalid values fail closed.",
|
||||||
"rollback": "Remove the required codec field and runtime codec dispatch together.",
|
"rollback": "Remove the required codec field and runtime codec dispatch together.",
|
||||||
"owner": "frontend-platform"
|
"owner": "frontend-platform"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"changeId": "FE-REG-ROUTE:TECH_LOG_STUDIO_HOME:access:field-changed",
|
||||||
|
"versionBump": "Studio routes move from access \"public\" to \"session-required\". Every route was registered public, so the router's auth gate was a no-op and a production build served the Studio shell to signed-out visitors.",
|
||||||
|
"migration": "None for callers. The route ids, paths, params, and search schemas are unchanged; only the access classification moves, and the SPA resolves it from the route's own layoutGroup rather than a per-route literal.",
|
||||||
|
"compatibilityWindow": "The deployed release consumes only its same-release registry snapshot; a signed-out visitor is redirected to the public shell instead of rendering Studio chrome.",
|
||||||
|
"rollback": "Roll back the atomic release to 93ce86e; the route contract derives access from layoutGroup in one expression, so the previous value returns with the release.",
|
||||||
|
"owner": "tech-log-frontend"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"changeId": "FE-REG-ROUTE:TECH_LOG_STUDIO_DOCUMENTS:access:field-changed",
|
||||||
|
"versionBump": "Studio routes move from access \"public\" to \"session-required\". Every route was registered public, so the router's auth gate was a no-op and a production build served the Studio shell to signed-out visitors.",
|
||||||
|
"migration": "None for callers. The route ids, paths, params, and search schemas are unchanged; only the access classification moves, and the SPA resolves it from the route's own layoutGroup rather than a per-route literal.",
|
||||||
|
"compatibilityWindow": "The deployed release consumes only its same-release registry snapshot; a signed-out visitor is redirected to the public shell instead of rendering Studio chrome.",
|
||||||
|
"rollback": "Roll back the atomic release to 93ce86e; the route contract derives access from layoutGroup in one expression, so the previous value returns with the release.",
|
||||||
|
"owner": "tech-log-frontend"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"changeId": "FE-REG-ROUTE:TECH_LOG_STUDIO_DOCUMENT_NEW:access:field-changed",
|
||||||
|
"versionBump": "Studio routes move from access \"public\" to \"session-required\". Every route was registered public, so the router's auth gate was a no-op and a production build served the Studio shell to signed-out visitors.",
|
||||||
|
"migration": "None for callers. The route ids, paths, params, and search schemas are unchanged; only the access classification moves, and the SPA resolves it from the route's own layoutGroup rather than a per-route literal.",
|
||||||
|
"compatibilityWindow": "The deployed release consumes only its same-release registry snapshot; a signed-out visitor is redirected to the public shell instead of rendering Studio chrome.",
|
||||||
|
"rollback": "Roll back the atomic release to 93ce86e; the route contract derives access from layoutGroup in one expression, so the previous value returns with the release.",
|
||||||
|
"owner": "tech-log-frontend"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"changeId": "FE-REG-ROUTE:TECH_LOG_STUDIO_DOCUMENT_EDIT:access:field-changed",
|
||||||
|
"versionBump": "Studio routes move from access \"public\" to \"session-required\". Every route was registered public, so the router's auth gate was a no-op and a production build served the Studio shell to signed-out visitors.",
|
||||||
|
"migration": "None for callers. The route ids, paths, params, and search schemas are unchanged; only the access classification moves, and the SPA resolves it from the route's own layoutGroup rather than a per-route literal.",
|
||||||
|
"compatibilityWindow": "The deployed release consumes only its same-release registry snapshot; a signed-out visitor is redirected to the public shell instead of rendering Studio chrome.",
|
||||||
|
"rollback": "Roll back the atomic release to 93ce86e; the route contract derives access from layoutGroup in one expression, so the previous value returns with the release.",
|
||||||
|
"owner": "tech-log-frontend"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"changeId": "FE-REG-ROUTE:TECH_LOG_STUDIO_DOCUMENT_VALIDATION:access:field-changed",
|
||||||
|
"versionBump": "Studio routes move from access \"public\" to \"session-required\". Every route was registered public, so the router's auth gate was a no-op and a production build served the Studio shell to signed-out visitors.",
|
||||||
|
"migration": "None for callers. The route ids, paths, params, and search schemas are unchanged; only the access classification moves, and the SPA resolves it from the route's own layoutGroup rather than a per-route literal.",
|
||||||
|
"compatibilityWindow": "The deployed release consumes only its same-release registry snapshot; a signed-out visitor is redirected to the public shell instead of rendering Studio chrome.",
|
||||||
|
"rollback": "Roll back the atomic release to 93ce86e; the route contract derives access from layoutGroup in one expression, so the previous value returns with the release.",
|
||||||
|
"owner": "tech-log-frontend"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"changeId": "FE-REG-ROUTE:TECH_LOG_STUDIO_DOCUMENT_PREVIEW:access:field-changed",
|
||||||
|
"versionBump": "Studio routes move from access \"public\" to \"session-required\". Every route was registered public, so the router's auth gate was a no-op and a production build served the Studio shell to signed-out visitors.",
|
||||||
|
"migration": "None for callers. The route ids, paths, params, and search schemas are unchanged; only the access classification moves, and the SPA resolves it from the route's own layoutGroup rather than a per-route literal.",
|
||||||
|
"compatibilityWindow": "The deployed release consumes only its same-release registry snapshot; a signed-out visitor is redirected to the public shell instead of rendering Studio chrome.",
|
||||||
|
"rollback": "Roll back the atomic release to 93ce86e; the route contract derives access from layoutGroup in one expression, so the previous value returns with the release.",
|
||||||
|
"owner": "tech-log-frontend"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"changeId": "FE-REG-ROUTE:TECH_LOG_STUDIO_DOCUMENT_PUBLISH:access:field-changed",
|
||||||
|
"versionBump": "Studio routes move from access \"public\" to \"session-required\". Every route was registered public, so the router's auth gate was a no-op and a production build served the Studio shell to signed-out visitors.",
|
||||||
|
"migration": "None for callers. The route ids, paths, params, and search schemas are unchanged; only the access classification moves, and the SPA resolves it from the route's own layoutGroup rather than a per-route literal.",
|
||||||
|
"compatibilityWindow": "The deployed release consumes only its same-release registry snapshot; a signed-out visitor is redirected to the public shell instead of rendering Studio chrome.",
|
||||||
|
"rollback": "Roll back the atomic release to 93ce86e; the route contract derives access from layoutGroup in one expression, so the previous value returns with the release.",
|
||||||
|
"owner": "tech-log-frontend"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"changeId": "FE-REG-ROUTE:TECH_LOG_STUDIO_PUBLICATIONS:access:field-changed",
|
||||||
|
"versionBump": "Studio routes move from access \"public\" to \"session-required\". Every route was registered public, so the router's auth gate was a no-op and a production build served the Studio shell to signed-out visitors.",
|
||||||
|
"migration": "None for callers. The route ids, paths, params, and search schemas are unchanged; only the access classification moves, and the SPA resolves it from the route's own layoutGroup rather than a per-route literal.",
|
||||||
|
"compatibilityWindow": "The deployed release consumes only its same-release registry snapshot; a signed-out visitor is redirected to the public shell instead of rendering Studio chrome.",
|
||||||
|
"rollback": "Roll back the atomic release to 93ce86e; the route contract derives access from layoutGroup in one expression, so the previous value returns with the release.",
|
||||||
|
"owner": "tech-log-frontend"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"changeId": "FE-REG-ROUTE:TECH_LOG_STUDIO_PUBLICATION_PREVIEW:access:field-changed",
|
||||||
|
"versionBump": "Studio routes move from access \"public\" to \"session-required\". Every route was registered public, so the router's auth gate was a no-op and a production build served the Studio shell to signed-out visitors.",
|
||||||
|
"migration": "None for callers. The route ids, paths, params, and search schemas are unchanged; only the access classification moves, and the SPA resolves it from the route's own layoutGroup rather than a per-route literal.",
|
||||||
|
"compatibilityWindow": "The deployed release consumes only its same-release registry snapshot; a signed-out visitor is redirected to the public shell instead of rendering Studio chrome.",
|
||||||
|
"rollback": "Roll back the atomic release to 93ce86e; the route contract derives access from layoutGroup in one expression, so the previous value returns with the release.",
|
||||||
|
"owner": "tech-log-frontend"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"changeId": "FE-REG-ROUTE:TECH_LOG_STUDIO_NOT_FOUND:access:field-changed",
|
||||||
|
"versionBump": "Studio routes move from access \"public\" to \"session-required\". Every route was registered public, so the router's auth gate was a no-op and a production build served the Studio shell to signed-out visitors.",
|
||||||
|
"migration": "None for callers. The route ids, paths, params, and search schemas are unchanged; only the access classification moves, and the SPA resolves it from the route's own layoutGroup rather than a per-route literal.",
|
||||||
|
"compatibilityWindow": "The deployed release consumes only its same-release registry snapshot; a signed-out visitor is redirected to the public shell instead of rendering Studio chrome.",
|
||||||
|
"rollback": "Roll back the atomic release to 93ce86e; the route contract derives access from layoutGroup in one expression, so the previous value returns with the release.",
|
||||||
|
"owner": "tech-log-frontend"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,8 @@
|
|||||||
"SERVICE_WORKER": "DEFAULT",
|
"SERVICE_WORKER": "DEFAULT",
|
||||||
"OFFLINE_COMMANDS": "DEFAULT"
|
"OFFLINE_COMMANDS": "DEFAULT"
|
||||||
},
|
},
|
||||||
"TECH_LOG_STUDIO_SOURCE": "MOCK",
|
"TECH_LOG_STUDIO_SOURCE": "HTTP",
|
||||||
|
"TECH_LOG_PUBLIC_SOURCE": "HTTP",
|
||||||
"FEATURE_OVERRIDES": {
|
"FEATURE_OVERRIDES": {
|
||||||
"reference-feature": "DEFAULT"
|
"reference-feature": "DEFAULT"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"OFFLINE_COMMANDS": "DEFAULT"
|
"OFFLINE_COMMANDS": "DEFAULT"
|
||||||
},
|
},
|
||||||
"TECH_LOG_STUDIO_SOURCE": "MOCK",
|
"TECH_LOG_STUDIO_SOURCE": "MOCK",
|
||||||
|
"TECH_LOG_PUBLIC_SOURCE": "MOCK",
|
||||||
"FEATURE_OVERRIDES": {
|
"FEATURE_OVERRIDES": {
|
||||||
"reference-feature": "DEFAULT"
|
"reference-feature": "DEFAULT"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"OFFLINE_COMMANDS": "DEFAULT"
|
"OFFLINE_COMMANDS": "DEFAULT"
|
||||||
},
|
},
|
||||||
"TECH_LOG_STUDIO_SOURCE": "HTTP",
|
"TECH_LOG_STUDIO_SOURCE": "HTTP",
|
||||||
|
"TECH_LOG_PUBLIC_SOURCE": "HTTP",
|
||||||
"FEATURE_OVERRIDES": {
|
"FEATURE_OVERRIDES": {
|
||||||
"reference-feature": "DEFAULT"
|
"reference-feature": "DEFAULT"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"OFFLINE_COMMANDS": "DEFAULT"
|
"OFFLINE_COMMANDS": "DEFAULT"
|
||||||
},
|
},
|
||||||
"TECH_LOG_STUDIO_SOURCE": "HTTP",
|
"TECH_LOG_STUDIO_SOURCE": "HTTP",
|
||||||
|
"TECH_LOG_PUBLIC_SOURCE": "HTTP",
|
||||||
"FEATURE_OVERRIDES": {
|
"FEATURE_OVERRIDES": {
|
||||||
"reference-feature": "DEFAULT"
|
"reference-feature": "DEFAULT"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Keycloak realm
|
||||||
|
|
||||||
|
`tech-log-realm.json` is imported by the `keycloak` service at start
|
||||||
|
(`--import-realm`). It exists because the realm was previously created by hand,
|
||||||
|
which meant §27 of the release checklist — "Keycloak Realm 설정을 복원할 수
|
||||||
|
있다" — had no answer: nothing in either repository described the realm.
|
||||||
|
|
||||||
|
What it declares, and why each part is load-bearing:
|
||||||
|
|
||||||
|
- **`studio-author` realm role.** `StudioAuthzEnvironmentPostProcessor` maps this
|
||||||
|
name to `studio:read` and `studio:write`. The name is configurable through
|
||||||
|
`APP_STUDIO_AUTHOR_ROLE`; if you change it here, change it there too.
|
||||||
|
- **`tech-log-bff` confidential client.** The Authorization Code flow belongs to
|
||||||
|
the backend, not the browser — the SPA never holds a token. `redirectUris` is
|
||||||
|
relative so the same realm works on any origin the deployment is served from.
|
||||||
|
- **`realm-roles` protocol mapper.** Without it the roles never reach the token,
|
||||||
|
the registry resolves zero permissions, and every Studio call answers 403.
|
||||||
|
|
||||||
|
## Values that must be replaced
|
||||||
|
|
||||||
|
`CHANGE_ME_BFF_SECRET` and `CHANGE_ME_STUDIO_PASSWORD` are placeholders, and the
|
||||||
|
deploy script substitutes them from the environment before import. They are left
|
||||||
|
visible rather than pre-filled so a realm file committed with a real secret is an
|
||||||
|
obvious mistake rather than a quiet one.
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
{
|
||||||
|
"realm": "tech-log",
|
||||||
|
"enabled": true,
|
||||||
|
"sslRequired": "none",
|
||||||
|
"registrationAllowed": false,
|
||||||
|
"loginTheme": "keycloak",
|
||||||
|
"accessTokenLifespan": 300,
|
||||||
|
"ssoSessionIdleTimeout": 1800,
|
||||||
|
"ssoSessionMaxLifespan": 36000,
|
||||||
|
"roles": {
|
||||||
|
"realm": [
|
||||||
|
{ "name": "studio-author", "description": "Tech Log Studio 편집 권한 (studio:read + studio:write)" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"clients": [
|
||||||
|
{
|
||||||
|
"clientId": "tech-log-bff",
|
||||||
|
"name": "Tech Log BFF",
|
||||||
|
"description": "백엔드가 소유하는 Authorization Code 클라이언트. SPA 는 토큰을 직접 들지 않는다.",
|
||||||
|
"enabled": true,
|
||||||
|
"publicClient": false,
|
||||||
|
"secret": "CHANGE_ME_BFF_SECRET",
|
||||||
|
"standardFlowEnabled": true,
|
||||||
|
"directAccessGrantsEnabled": false,
|
||||||
|
"serviceAccountsEnabled": false,
|
||||||
|
"redirectUris": ["/login/oauth2/code/*"],
|
||||||
|
"webOrigins": ["+"],
|
||||||
|
"protocolMappers": [
|
||||||
|
{
|
||||||
|
"name": "realm-roles",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"protocolMapper": "oidc-usermodel-realm-role-mapper",
|
||||||
|
"config": {
|
||||||
|
"claim.name": "realm_access.roles",
|
||||||
|
"jsonType.label": "String",
|
||||||
|
"multivalued": "true",
|
||||||
|
"access.token.claim": "true",
|
||||||
|
"id.token.claim": "true",
|
||||||
|
"userinfo.token.claim": "true"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"users": [
|
||||||
|
{
|
||||||
|
"username": "studio",
|
||||||
|
"enabled": true,
|
||||||
|
"emailVerified": true,
|
||||||
|
"email": "studio@tech-log.local",
|
||||||
|
"firstName": "Studio",
|
||||||
|
"lastName": "Author",
|
||||||
|
"credentials": [{ "type": "password", "value": "CHANGE_ME_STUDIO_PASSWORD", "temporary": false }],
|
||||||
|
"realmRoles": ["default-roles-tech-log", "studio-author"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
# The Tech Log dev stack: one origin, five services.
|
||||||
|
#
|
||||||
|
# nginx is the only published port. Everything the browser touches — the SPA,
|
||||||
|
# /api, the OIDC redirect chain, and Keycloak under /auth — arrives on the same
|
||||||
|
# origin, which is what lets the session be a plain first-party httpOnly cookie
|
||||||
|
# instead of a cross-site one needing SameSite=None.
|
||||||
|
#
|
||||||
|
# browser ──> frontend(nginx) ──┬─> / SPA bundle
|
||||||
|
# ├─> /api backend
|
||||||
|
# ├─> /oauth2 /login /logout backend (BFF)
|
||||||
|
# └─> /auth keycloak
|
||||||
|
#
|
||||||
|
# Secrets here are development values and are meant to be replaced by the
|
||||||
|
# deployment; they are named in .env so nothing is baked into an image.
|
||||||
|
|
||||||
|
name: tech-log
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-tech_log}
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-tech_log}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}
|
||||||
|
TZ: UTC
|
||||||
|
volumes:
|
||||||
|
- postgres-data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-tech_log} -d ${POSTGRES_DB:-tech_log}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 20s
|
||||||
|
restart: unless-stopped
|
||||||
|
networks: [tech-log]
|
||||||
|
|
||||||
|
redis:
|
||||||
|
# Holds the Studio session. Losing it signs everyone out; it holds nothing
|
||||||
|
# else, so it is not backed by a volume on purpose.
|
||||||
|
image: redis:7-alpine
|
||||||
|
command: ["redis-server", "--save", "", "--appendonly", "no"]
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 5
|
||||||
|
restart: unless-stopped
|
||||||
|
networks: [tech-log]
|
||||||
|
|
||||||
|
keycloak:
|
||||||
|
image: quay.io/keycloak/keycloak:26.7.0
|
||||||
|
command: ["start-dev", "--import-realm", "--http-relative-path=/auth"]
|
||||||
|
environment:
|
||||||
|
KC_BOOTSTRAP_ADMIN_USERNAME: ${KEYCLOAK_ADMIN:-admin}
|
||||||
|
KC_BOOTSTRAP_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:?set KEYCLOAK_ADMIN_PASSWORD}
|
||||||
|
KC_HTTP_ENABLED: "true"
|
||||||
|
# Behind nginx: Keycloak must build its URLs from the forwarded host, or
|
||||||
|
# the redirect back from the login page points at the container.
|
||||||
|
KC_HOSTNAME: ${PUBLIC_ORIGIN:?set PUBLIC_ORIGIN}/auth
|
||||||
|
KC_HOSTNAME_STRICT: "false"
|
||||||
|
KC_PROXY_HEADERS: xforwarded
|
||||||
|
KC_HEALTH_ENABLED: "true"
|
||||||
|
volumes:
|
||||||
|
- ${KEYCLOAK_IMPORT_DIR:-./deploy/keycloak}:/opt/keycloak/data/import:ro
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/9000 && echo -e 'GET /auth/health/ready HTTP/1.1\\r\\nHost: localhost\\r\\nConnection: close\\r\\n\\r\\n' >&3 && cat <&3 | grep -q '\"status\": \"UP\"'"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 20
|
||||||
|
start_period: 40s
|
||||||
|
restart: unless-stopped
|
||||||
|
networks: [tech-log]
|
||||||
|
|
||||||
|
backend:
|
||||||
|
image: ${BACKEND_IMAGE:-tech-log-backend:local}
|
||||||
|
volumes:
|
||||||
|
- tls-public:/tls-public:ro
|
||||||
|
# The image entrypoint is `java -jar /app/app.jar`; this wraps it so the
|
||||||
|
# frontend's certificate lands in the JVM truststore first. Without it the
|
||||||
|
# OIDC metadata fetch fails PKIX validation and the process crash-loops.
|
||||||
|
entrypoint:
|
||||||
|
- /bin/sh
|
||||||
|
- -c
|
||||||
|
- |
|
||||||
|
until [ -f /tls-public/server.crt ]; do sleep 1; done
|
||||||
|
# The image runs as a non-root user, so the JVM's own cacerts is not
|
||||||
|
# writable — importing there silently did nothing and the metadata fetch
|
||||||
|
# kept failing PKIX. Copy it somewhere writable, add the edge
|
||||||
|
# certificate, and point the JVM at that.
|
||||||
|
cp "/opt/java/openjdk/lib/security/cacerts" /tmp/truststore.jks
|
||||||
|
keytool -importcert -noprompt -trustcacerts -alias tech-log-edge \
|
||||||
|
-file /tls-public/server.crt \
|
||||||
|
-keystore /tmp/truststore.jks -storepass changeit
|
||||||
|
exec java \
|
||||||
|
-Djavax.net.ssl.trustStore=/tmp/truststore.jks \
|
||||||
|
-Djavax.net.ssl.trustStorePassword=changeit \
|
||||||
|
-jar /app/app.jar
|
||||||
|
environment:
|
||||||
|
SPRING_PROFILES_ACTIVE: local
|
||||||
|
# Persistence
|
||||||
|
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB:-tech_log}
|
||||||
|
SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER:-tech_log}
|
||||||
|
SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
SPRING_DATASOURCE_DRIVER_CLASS_NAME: org.postgresql.Driver
|
||||||
|
SPRING_FLYWAY_ENABLED: "true"
|
||||||
|
SPRING_JPA_HIBERNATE_DDL_AUTO: none
|
||||||
|
CA_SKELETON_PERSISTENCE_VENDOR: postgresql
|
||||||
|
# BFF session
|
||||||
|
CA_SKELETON_SECURITY_AUTH_MODE: redis-session
|
||||||
|
CA_SKELETON_SECURITY_SESSION_COOKIE_NAME: TECHLOG_SESSION
|
||||||
|
APP_REDIS_ENABLED: "true"
|
||||||
|
APP_REDIS_AUTHENTICATION_ANONYMOUS_ACCESS_ACCEPTED: "true"
|
||||||
|
SPRING_DATA_REDIS_HOST: redis
|
||||||
|
SPRING_DATA_REDIS_PORT: "6379"
|
||||||
|
# OIDC. The issuer is the browser-facing URL because the tokens carry it
|
||||||
|
# and the browser is redirected there; the container reaches the same
|
||||||
|
# Keycloak through nginx on the compose network.
|
||||||
|
SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_KEYCLOAK_CLIENT_ID: ${OIDC_CLIENT_ID:-tech-log-bff}
|
||||||
|
SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_KEYCLOAK_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:?set OIDC_CLIENT_SECRET}
|
||||||
|
SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_KEYCLOAK_SCOPE: openid,profile,email
|
||||||
|
SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_KEYCLOAK_AUTHORIZATION_GRANT_TYPE: authorization_code
|
||||||
|
SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_KEYCLOAK_REDIRECT_URI: "${PUBLIC_ORIGIN}/login/oauth2/code/keycloak"
|
||||||
|
SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_KEYCLOAK_ISSUER_URI: ${PUBLIC_ORIGIN}/auth/realms/${KEYCLOAK_REALM:-tech-log}
|
||||||
|
APP_STUDIO_AUTHOR_ROLE: ${STUDIO_AUTHOR_ROLE:-studio-author}
|
||||||
|
APP_STUDIO_POST_LOGIN_REDIRECT: "${PUBLIC_ORIGIN}/studio"
|
||||||
|
# Behind a proxy: trust the forwarded headers nginx sets, so redirect URLs
|
||||||
|
# and client IPs are the browser's, not the container's.
|
||||||
|
APP_SERVER_FORWARD_HEADERS_STRATEGY: framework
|
||||||
|
TZ: UTC
|
||||||
|
# The issuer in a token is the browser-facing URL, and the backend has to
|
||||||
|
# both validate that exact string and fetch the realm's metadata from it.
|
||||||
|
# Inside the container that host does not resolve, so discovery failed and
|
||||||
|
# the process crash-looped. Mapping the public host to the docker gateway
|
||||||
|
# makes one URL work from both sides — the browser reaches nginx directly,
|
||||||
|
# the backend reaches the same nginx through the published port.
|
||||||
|
extra_hosts:
|
||||||
|
- "${PUBLIC_HOST:?set PUBLIC_HOST}:host-gateway"
|
||||||
|
depends_on:
|
||||||
|
postgres: { condition: service_healthy }
|
||||||
|
redis: { condition: service_healthy }
|
||||||
|
keycloak: { condition: service_healthy }
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/api/healthcheck"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
start_period: 45s
|
||||||
|
restart: unless-stopped
|
||||||
|
networks: [tech-log]
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
image: ${FRONTEND_IMAGE:-tech-log-frontend:local}
|
||||||
|
# The backend fetches the realm metadata from the same HTTPS origin the
|
||||||
|
# browser uses, so it has to trust this certificate. Publishing it to a
|
||||||
|
# shared volume keeps one certificate for both sides; a deployment that
|
||||||
|
# mounts a CA-issued certificate over /etc/nginx/tls needs neither this nor
|
||||||
|
# the backend's import step.
|
||||||
|
volumes:
|
||||||
|
- tls-public:/tls-public
|
||||||
|
command:
|
||||||
|
- /bin/sh
|
||||||
|
- -c
|
||||||
|
- "cp /etc/nginx/tls/server.crt /tls-public/server.crt && exec nginx -g 'daemon off;'"
|
||||||
|
|
||||||
|
ports:
|
||||||
|
- "${PUBLIC_HTTP_PORT:-8088}:80"
|
||||||
|
- "${PUBLIC_PORT:-8443}:443"
|
||||||
|
depends_on:
|
||||||
|
backend: { condition: service_started }
|
||||||
|
keycloak: { condition: service_started }
|
||||||
|
restart: unless-stopped
|
||||||
|
networks: [tech-log]
|
||||||
|
|
||||||
|
networks:
|
||||||
|
tech-log:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres-data:
|
||||||
|
tls-public:
|
||||||
@@ -0,0 +1,394 @@
|
|||||||
|
# Tech Log 운영 출시 전 체크리스트 — 실측 검증 보고서
|
||||||
|
|
||||||
|
**검증일** 2026-08-19 · **방식** 두 저장소를 로컬에서 실제 기동해 엔드포인트·브라우저 단위로 실측
|
||||||
|
|
||||||
|
| 대상 | 위치 | 리비전 |
|
||||||
|
|---|---|---|
|
||||||
|
| Frontend | `tech-log-frontend` | `main` eb86708 → `fix/release-gate-frontend` fff5e6f |
|
||||||
|
| Backend | `tech-log-backend` | `develop` ab0447a |
|
||||||
|
| Keycloak | 로컬 컨테이너 `local-keycloak` | 26.7.0 (`:18080`) |
|
||||||
|
| PostgreSQL | 로컬 컨테이너 `techlog-pg` | 16.15 (`:5433`) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 요약 판정: **출시 보류 (P0 미충족)**
|
||||||
|
|
||||||
|
체크리스트 §31의 P0 항목 중 **인증 우회 불가 · 인가 우회 불가 · Studio 주요 기능 정상 · Publish 정상**이
|
||||||
|
현재 충족되지 않는다. 아래 근거는 전부 실행 결과다.
|
||||||
|
|
||||||
|
### 가장 중요한 구조적 사실
|
||||||
|
|
||||||
|
백엔드는 계약(`studio-v1.yaml`)이 선언한 **18개 오퍼레이션 중 2개**만 구현되어 있다.
|
||||||
|
|
||||||
|
| 상태 | 오퍼레이션 |
|
||||||
|
|---|---|
|
||||||
|
| 구현됨 (2) | `getStudioSession`, `listStudioCatalog` |
|
||||||
|
| 미구현 (16) | `getStudioDashboard`, `listStudioDocuments`, `createStudioDocument`, `getStudioDocument`, `saveStudioDocument`, `validateStudioDocument`, `getCurrentStudioPreview`, `createStudioPreview`, `publishStudioDocument`, `listStudioPublications`, `unpublishStudioPublication`, `getStudioPublicationSnapshot`, `listStudioAssets`, `uploadStudioAsset`, `getStudioAsset`, `updateStudioAsset`, `deleteStudioAsset` |
|
||||||
|
|
||||||
|
또한 **Public 읽기 엔드포인트는 계약에 아예 없다.** `studio-v1.yaml`은 Studio 전용이고,
|
||||||
|
프론트엔드의 Public 화면(`/`, `/explore`, `/projects`, `/releases`, 문서 상세)은
|
||||||
|
`src/features/tech-log/adapters/static/public-content.ts`의 **번들에 컴파일된 정적 콘텐츠**를 읽는다.
|
||||||
|
|
||||||
|
따라서 체크리스트의 다음 절은 검증 대상 자체가 존재하지 않는다:
|
||||||
|
§2(탐색·검색·프로젝트·변경기록의 백엔드 연동), §3.1~3.3(문서 작성·관계·Publish),
|
||||||
|
§12(Public/Private 데이터 경계), §17(파일/Object Storage), §29(E2E 시나리오).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P0 — 출시 차단 결함
|
||||||
|
|
||||||
|
### P0-1. Studio 라우트가 인증을 검사하지 않았다 — **수정 완료**
|
||||||
|
|
||||||
|
`TECH_LOG_ROUTE_REGISTRY`가 모든 TechLog 라우트를 `access: "public"`으로 등록하고 있었다.
|
||||||
|
라우터에 `decideRouteAccessForDefinition` 가드가 존재하지만 Studio에 대해 무력화된 상태였다.
|
||||||
|
|
||||||
|
운영 프로파일 빌드(`AUTH_MODE=external`)로 실측한 수정 전:
|
||||||
|
|
||||||
|
```
|
||||||
|
/studio http=200 h1="작업 흐름" ← 비로그인 상태에서 Studio UI 렌더링
|
||||||
|
/studio/documents http=200 h1="작업본"
|
||||||
|
/studio/assets http=200 h1="Asset"
|
||||||
|
```
|
||||||
|
|
||||||
|
`spec.layoutGroup === "STUDIO"`에서 `access`를 유도하도록 수정한 뒤:
|
||||||
|
|
||||||
|
```
|
||||||
|
/studio http=200 h1="로그인 연동이 필요합니다."
|
||||||
|
/studio/documents http=200 h1="로그인 연동이 필요합니다."
|
||||||
|
/ , /explore 변화 없음
|
||||||
|
```
|
||||||
|
|
||||||
|
로그인 후 원래 요청 화면으로 복귀하는 것도 확인했다(`/studio/documents` → 로그인 → `작업본`).
|
||||||
|
|
||||||
|
커밋 `fff5e6f`.
|
||||||
|
|
||||||
|
### P0-2. 백엔드 Studio API에 인가 검사가 없다 — **미해결**
|
||||||
|
|
||||||
|
`SecurityConfig`는 `anyRequest().authenticated()`로 끝나고, Studio 컨트롤러에
|
||||||
|
`@RequiresPermission` 계열 애노테이션이 **하나도 없다**.
|
||||||
|
|
||||||
|
Keycloak에 Studio 권한이 없는 사용자(`plain`, realm role `plain-user`)를 만들어 확인:
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/studio/catalog?type=TOPIC
|
||||||
|
studio 사용자 (studio-author) → HTTP 200
|
||||||
|
plain 사용자 (권한 없음) → HTTP 200 ← 인가 우회
|
||||||
|
```
|
||||||
|
|
||||||
|
체크리스트 §11 "인증된 사용자라고 해서 무조건 Studio API를 호출할 수 있지 않다",
|
||||||
|
§31 P0 "인가 우회 불가" 미충족.
|
||||||
|
|
||||||
|
### P0-3. 모든 Studio 경로가 `/api/api/v1/...`에 매핑된다 (Double Prefix) — **미해결**
|
||||||
|
|
||||||
|
`PresentationWebConfig`가 `configurer.addPathPrefix("/api", c -> true)`로 전 컨트롤러에
|
||||||
|
`/api`를 붙이는데, Studio 컨트롤러는 `@GetMapping("/api/v1/studio/...")`로 이미 `/api`를 포함해 선언한다.
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/studio/catalog → 404 ROUTE_NOT_FOUND
|
||||||
|
GET /api/api/v1/studio/catalog → 200
|
||||||
|
GET /api/v1/studio/session → 404 ROUTE_NOT_FOUND
|
||||||
|
GET /api/api/v1/studio/session → 503
|
||||||
|
```
|
||||||
|
|
||||||
|
프론트엔드는 계약대로 `/api/v1/studio/...`를 호출하므로 **현재 상태로는 단 한 건도 연결되지 않는다.**
|
||||||
|
체크리스트 §25 "`/api` Prefix 처리에서 Double Prefix가 발생하지 않는다" 미충족.
|
||||||
|
|
||||||
|
### P0-4. `getStudioSession`이 항상 503을 반환한다 — **미해결**
|
||||||
|
|
||||||
|
`auth-mode: jwt`(저장소 기본값, `src/.env:115`)에서 `SecurityConfig`가 `csrf.disable()`로
|
||||||
|
`CsrfFilter`를 제거하므로 `CsrfToken` 파라미터가 항상 `null`이고, 컨트롤러는 이를
|
||||||
|
`STUDIO_UNAVAILABLE`(503)로 정직하게 보고한다.
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/api/v1/studio/session (유효한 studio 토큰)
|
||||||
|
→ 503 {"code":"STUDIO_UNAVAILABLE","category":"TRANSIENT_DEPENDENCY","retryable":true}
|
||||||
|
로그: "CSRF token unavailable: CSRF protection is disabled for the active auth-mode"
|
||||||
|
```
|
||||||
|
|
||||||
|
프론트엔드 HTTP 모드는 `getStudioSession`으로 CSRF 토큰을 받아 부트스트랩하므로,
|
||||||
|
**이 한 건 때문에 Studio HTTP 경로 전체가 시작조차 못 한다.**
|
||||||
|
`auth-mode: redis-session`에 필요한 세션 빈이 저장소에 없다는 점은 백엔드 HANDOFF.md도 명시하고 있다.
|
||||||
|
|
||||||
|
### P0-5. `main` 브랜치의 dev 부팅이 깨져 있었다 — **수정 완료**
|
||||||
|
|
||||||
|
`eb86708`(계약 3.0.0 머지) 이후 `public/release-manifest.json`이 2.0.0으로 남아
|
||||||
|
부팅 시 contract-set 검증이 fail-closed → **빈 화면**. 이전에 한 번 겪은 것과 같은 실패 양식이다.
|
||||||
|
|
||||||
|
```
|
||||||
|
setDigest drift: manifest sha256:e0da7765…, build sha256:261ac630…
|
||||||
|
package drift: manifest 2.0.0 / ce2e748 vs build 3.0.0 / b20d7a2
|
||||||
|
```
|
||||||
|
|
||||||
|
`generate:dev-release-manifest`로 재생성하고, 같은 값을 하드코딩하던
|
||||||
|
`tests/runtime-schema/release-manifest.test.ts`도 함께 갱신했다. 커밋 `fff5e6f`.
|
||||||
|
|
||||||
|
### P0-6. 커밋된 `.env`로는 prod 프로파일이 부팅하지 않는다 — **미해결**
|
||||||
|
|
||||||
|
`src/.env:140`이 `APP_DATASOURCE_DDL_AUTO=update`인데, `application-prod.yml`이 문서화한
|
||||||
|
`JpaSchemaSafetyValidator`는 prod에서 `none|validate`만 허용하고 위반 시 exit 71로 종료한다.
|
||||||
|
|
||||||
|
### P0-7. `ddl-auto=validate`로는 PostgreSQL에서 부팅하지 않는다 — **미해결**
|
||||||
|
|
||||||
|
```
|
||||||
|
SchemaManagementException: Schema-validation: missing table [fs_cleanup_item]
|
||||||
|
```
|
||||||
|
|
||||||
|
`PostgreSqlPersistenceConfig`가 Flyway 위치를 `classpath:db/migration/postgresql`로 고정해
|
||||||
|
`db/migration/jpa/fileserver` 트리가 **한 번도 적용되지 않는데**, 해당 JPA 엔티티는 스캔된다.
|
||||||
|
`ddl-auto=update`가 이 사실을 가려 온 것이고, prod가 요구하는 `validate`로 바꾸는 순간 드러난다.
|
||||||
|
(본 검증은 `ddl-auto=none`으로 우회해 진행했다.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P1 — 출시 전 해결 권장
|
||||||
|
|
||||||
|
| # | 항목 | 실측 근거 |
|
||||||
|
|---|---|---|
|
||||||
|
| P1-1 | Keycloak realm 구성이 두 저장소 어디에도 없다 | compose에 keycloak 서비스 없음, realm export 파일 없음. 검증을 위해 `ca-skeleton` realm·클라이언트·audience 매퍼·테스트 사용자를 수기로 생성해야 했다. §27 "Keycloak Realm 설정을 복원할 수 있다" 미충족 |
|
||||||
|
| P1-2 | 프론트엔드에 로그인 구현이 없다 | OIDC/Keycloak 클라이언트 코드 0건. `AUTH_MODE=external`은 호스팅 페이지가 `window.__CA_FRONTEND_AUTH_OWNER__`를 주입하기를 기대하며, 없으면 `createUnavailableSessionAdapter`가 "로그인 연동이 필요합니다"를 띄운다. §1.4 인증 항목 전부 검증 불가 |
|
||||||
|
| P1-3 | production 런타임 설정이 플레이스홀더 | `API_BASE_URL: https://api.example.com/`, `TELEMETRY_ENDPOINT: https://telemetry.example.com/v1/events` |
|
||||||
|
| P1-4 | Rate Limit 비활성 | `APP_RATE_LIMIT_ENABLED=false`, `APP_RATE_LIMIT_PROVIDER=disabled`. 60회 연속 호출 전부 200 |
|
||||||
|
| P1-5 | 보안 헤더를 적용하는 주체가 없다 | `config/hosting/security-headers.json`에 CSP·HSTS·X-Frame-Options 등이 정의돼 있으나 `dist/server.mjs`는 **하나도 적용하지 않는다**. `verify:hosting-headers`는 기본적으로 fixture 모드로 동작해 실 서버를 검사하지 않는다 |
|
||||||
|
| P1-6 | 캐시 정책도 미적용 | `cache-policy.json`은 `/assets/*`에 `public, max-age=31536000, immutable`을 요구하나 실제 응답은 전부 `no-cache` |
|
||||||
|
| P1-7 | 프론트엔드 배포 아티팩트 부재 | Dockerfile·nginx conf·compose 없음. `dist/server.mjs`는 프리뷰용이지 운영 파일 서버가 아니다 |
|
||||||
|
| P1-8 | robots.txt / sitemap.xml 없음 | **Studio 경로가 검색 엔진에 차단되지 않는다.** §7 미충족 |
|
||||||
|
| P1-9 | Open Graph·canonical 메타데이터 없음 | `dist/index.html`에 `og:*`·canonical 없음. `<title>`은 라우트별로 정상 동작하나 **런타임에 설정**되므로 JS를 실행하지 않는 공유 미리보기 크롤러에는 "Tech Log" 고정값만 노출된다 |
|
||||||
|
| P1-10 | DB 타임아웃 30초 | `APP_DATASOURCE_CONNECTION_TIMEOUT=30000`. `application.yml`이 문서화한 D2 fail-fast 의도(기본 5s)와 어긋난다. 프론트엔드 `REQUEST_TIMEOUT_MS=10000`이므로 DB 장애 시 프론트가 항상 먼저 끊겨 `DB_UNAVAILABLE` 503을 보지 못한다 |
|
||||||
|
| P1-11 | Tech Log Asset의 Object Storage 배선 없음 | objectstorage 어댑터는 템플릿 자산으로 존재하나 techlog 참조 0건, MinIO/S3 환경변수 0건, `uploadStudioAsset` 엔드포인트 미구현 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 검증되어 통과한 항목
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
| 항목 | 결과 |
|
||||||
|
|---|---|
|
||||||
|
| Production Build | PASS (local·production 프로파일 모두) |
|
||||||
|
| TypeScript compile | PASS (`check:types` 6개 프로젝트) |
|
||||||
|
| ESLint | PASS (수정 후 0 error) |
|
||||||
|
| 전체 테스트 | 1,818 passed / 16 skipped / **1 기존 flake** (`provider-guardian-transaction` — 단독 실행 2회 모두 PASS, 부하 의존) |
|
||||||
|
| architecture / contract / dev-release-manifest / browser-security 게이트 | PASS |
|
||||||
|
| Production 번들에 dev·localhost URL 없음 | PASS (`localhost`·`127.0.0.1` 0건, `.local` 매치는 전부 `locale`/`localeCompare`) |
|
||||||
|
| Production 번들에 Mock API 미포함 | PASS (`createMockStudioGateway` 0건) |
|
||||||
|
| Source Map 비공개 | PASS (`.map` 0개) |
|
||||||
|
| Route 단위 Lazy Loading | PASS (30 청크, 총 954 KB / 최대 569 KB) |
|
||||||
|
| SPA 라우팅·새로고침 | PASS (열거형 allowlist 방식. 존재하지 않는 문서 경로는 의도적으로 404) |
|
||||||
|
| Route별 `<title>` | PASS (`탐색 · Tech Log`, `프로젝트 · Tech Log` …) |
|
||||||
|
| 반응형 | PASS — 360/414/768/1440 × 6개 Public 라우트 **24개 조합 전부 가로 스크롤 없음** |
|
||||||
|
| 접근성 | PASS — axe(wcag2a/2aa/21a/21aa) **serious+critical 0건** (Public 6 + Studio 4 라우트). h1 정확히 1개, heading 건너뜀 없음, alt 누락 0, 레이블 없는 icon button 0 |
|
||||||
|
| 로그인 흐름 | PASS (게이트 → 로그인 → 원래 화면 복귀) |
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
| 항목 | 결과 |
|
||||||
|
|---|---|
|
||||||
|
| Production Profile Build | PASS — `:app-bootstrap:bootJar` 성공 |
|
||||||
|
| 전체 테스트 | PASS — **3,530 tests / 0 failures / 7 skipped** (BUILD SUCCESSFUL 8m 9s). app-bootstrap 797 · application-core 568 · cache-redis 423 · fileserver 398 · inbound-web 341 · httpclient 283 · shared-contract 224 · objectstorage 140 · persistence-jpa 122 · 그 외 |
|
||||||
|
| Docker Image Build | PASS — 623MB. `BUILD_VERSION`/`GIT_SHA`/`SOURCE_URL` build-arg를 강제하는 provenance 게이트가 있어 인자 없이는 의도적으로 실패한다 |
|
||||||
|
| Production Image 실제 실행 | PASS — 컨테이너에서 14.5초 기동, `healthcheck` 200 · `readiness` 200 · `catalog` 200(실데이터 2건) |
|
||||||
|
| Flyway 마이그레이션 (신규 DB, 처음부터) | PASS — 6개 적용, V7 techlog core 포함, 테이블 33개 생성 |
|
||||||
|
| 응답 봉투 일관성 | PASS — `{success,data,error,meta}` 전 경로 동일 |
|
||||||
|
| HTTP 상태 코드 | PASS — 401 / 404 / 405 / 422 / 500 / 503 모두 적절 |
|
||||||
|
| 인증 오류 코드 분리 | PASS — `AUTH_TOKEN_MISSING` / `AUTH_TOKEN_MALFORMED` / `AUTH_TOKEN_INVALID_SIGNATURE` / `AUTH_TOKEN_EXPIRED` |
|
||||||
|
| Validation | PASS — 잘못된 enum·필수 누락은 422 + `fieldErrors`, `limit` 상·하한 강제 |
|
||||||
|
| SQL Injection | PASS — `' OR 1=1--` 파라미터 바인딩되어 빈 결과 |
|
||||||
|
| Visibility 필터 | PASS — `ARCHIVED` 토픽이 catalog 결과에서 제외됨 |
|
||||||
|
| CORS | PASS — 허용 origin 200 + `Allow-Credentials: true`, 미허용 origin 403, 와일드카드 없음 |
|
||||||
|
| 보안 헤더 | PASS — `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Cache-Control: no-store` |
|
||||||
|
| 오류 정보 노출 | PASS — Stack trace·SQL·내부 클래스명 모두 미노출 (`details: null`) |
|
||||||
|
| 로그 위생 | PASS — 토큰·Authorization·쿠키·비밀번호 **0건**. `user=`는 가명화 해시 |
|
||||||
|
| 추적성 | PASS — 모든 요청 로그에 `req=`·`trace=`, `http_request method= uri_template= status= duration_ms=` |
|
||||||
|
| Metrics | PASS — Prometheus 127개 메트릭 패밀리 (`http_server_requests_seconds_bucket`, `jvm_gc_*`, `jvm_memory_*`, `hikaricp_connections_*`) |
|
||||||
|
| Liveness / Readiness 분리 | PASS — DB 중단 시 readiness 503 DOWN, liveness 200 UP 유지 |
|
||||||
|
| 의존성 장애 대응 | PASS(동작) — DB 중단 시 무한 대기 없이 **503 `DB_UNAVAILABLE` (retryable)** 반환, DB 복구 후 27ms/정상 데이터로 자동 회복. 단 응답까지 30초 소요(P1-10) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 다음 단계 (권장 순서)
|
||||||
|
|
||||||
|
1. **Double Prefix 해소** (P0-3) — 컨트롤러 매핑에서 `/api`를 제거하거나 `addPathPrefix` 대상에서 제외. 이걸 고치기 전에는 프론트-백엔드가 한 건도 연결되지 않으므로 최우선.
|
||||||
|
2. **세션 인프라** (P0-4) — `redis-session` 배선. 백엔드 HANDOFF.md도 Plan 02보다 앞선 선행 작업으로 지목하고 있다.
|
||||||
|
3. **Studio 인가** (P0-2) — Studio 컨트롤러에 권한 검사 추가 + 권한 없는 사용자 403 회귀 테스트.
|
||||||
|
4. **prod 부팅 설정** (P0-6, P0-7) — `.env`의 `ddl-auto`, fileserver 마이그레이션 위치.
|
||||||
|
5. **나머지 16개 오퍼레이션** — 백엔드 HANDOFF.md가 지적한 생성 union 5종의 Jackson 파손 전략 결정이 선행.
|
||||||
|
6. 배포 레이어 (P1-5·6·7) — nginx/CDN에 보안 헤더·캐시 정책 적용, 프론트엔드 이미지.
|
||||||
|
7. robots.txt로 Studio 차단 (P1-8).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 2차 검증 — 로컬에서 가능한 항목 완주 (2026-08-19)
|
||||||
|
|
||||||
|
1차에서 "물리적으로 불가능"이라 분류했던 항목 중 상당수가 실제로는 검증 가능했다.
|
||||||
|
Studio는 mock 게이트웨이가 18개 오퍼레이션을 **전부** 구현하고 있고(낙관적 락·검증
|
||||||
|
staleness·미리보기 만료·경고 승인·멱등성 포함), Public은 정적 콘텐츠지만 UI 동작
|
||||||
|
항목은 그대로 검증된다. 아래는 그 재검증 결과다.
|
||||||
|
|
||||||
|
## 1차 판정 정정
|
||||||
|
|
||||||
|
### 정정 1 — P0-6 은 결함이 아니다
|
||||||
|
|
||||||
|
`prod` 프로파일이 커밋된 `src/.env`로 부팅하지 않는 것은 **의도된 설계**다.
|
||||||
|
`application-prod.yml`이 문서화한 5개 startup validator가 개발용 값을 거부한다:
|
||||||
|
|
||||||
|
```
|
||||||
|
JpaSchemaSafetyValidator ddl-auto must be none|validate (exit 71)
|
||||||
|
FlywayProdSafetyValidator baseline-on-migrate / out-of-order / clean 비활성
|
||||||
|
StartupSafetyValidator error-detail 노출 · body-capture 로깅 off
|
||||||
|
PostgreSqlTransportSecurityValidator pgJDBC sslmode=verify-full
|
||||||
|
PersistenceVendorProdSafetyValidator vendor·URL 모두 H2 금지
|
||||||
|
```
|
||||||
|
|
||||||
|
실측으로 2개가 순서대로 발화하는 것을 확인했다:
|
||||||
|
|
||||||
|
```
|
||||||
|
exit=71 error.code=PROFILE_MISMATCH
|
||||||
|
"prod profile requires APP_DATASOURCE_DDL_AUTO ... to be none or validate,
|
||||||
|
but was update; Flyway is the production schema writer"
|
||||||
|
|
||||||
|
ddl-auto=none 으로 넘긴 뒤:
|
||||||
|
"prod PostgreSQL transport requires pgJDBC sslmode=verify-full"
|
||||||
|
```
|
||||||
|
|
||||||
|
즉 **§9 "운영 환경에서 개발용 설정이 활성화되지 않는다"는 PASS**다.
|
||||||
|
남는 진짜 갭은 별개다 — **운영 값 세트가 저장소에도 배포 시스템에도 아직 없다**(P1로 이동).
|
||||||
|
|
||||||
|
### 정정 2 — P0-7 의 심각도 하향
|
||||||
|
|
||||||
|
`ddl-auto=validate`가 `fs_cleanup_item` 누락으로 실패하는 것은 사실이나,
|
||||||
|
prod는 `none|validate` **둘 다** 허용하므로 `none`으로 부팅할 수 있다(실제로 그렇게 기동해 검증했다).
|
||||||
|
따라서 출시 차단은 아니고, **스키마 검증을 포기해야 한다는 제약**으로 남는다 → P1.
|
||||||
|
|
||||||
|
### 정정 3 — 1차의 오탐 2건
|
||||||
|
|
||||||
|
- **"Public UI에 Studio 노출"** — 오탐. 매칭된 "Studio"는 전부 게시된 릴리스 노트의 본문
|
||||||
|
텍스트였다("TechLog Public·Studio 경계를 확정했습니다"). 실제 `a[href^="/studio"]`는
|
||||||
|
모든 Public 화면에서 **0건**. §1.3 PASS.
|
||||||
|
- **"코드 블록 미표시"** — 오탐. `code-block.tsx`가 `<figure class="code-block">` +
|
||||||
|
`<pre role="region" tabindex=0>`을 렌더하고 CSS가 `overflow-x:auto`·`max-width:100%`를
|
||||||
|
준다. 정적 공개 문서에 CODE_BLOCK이 0건이라 발견하지 못한 것이며, Studio 편집기에
|
||||||
|
직접 넣어 확인하니 정상 렌더되고 페이지 가로 오버플로도 없었다. §4 PASS.
|
||||||
|
|
||||||
|
## 새로 발견한 결함
|
||||||
|
|
||||||
|
| # | 항목 | 근거 |
|
||||||
|
|---|---|---|
|
||||||
|
| **N-1** | **로그아웃할 방법이 없다** | `signOut` 포트와 `app-shell.tsx`의 세션 버튼(`action.signOut`="로그아웃")은 존재하지만, **TechLog는 자체 셸(`public-shell.tsx` + Studio 셸)을 쓰고 `AppShell`을 렌더하지 않는다.** 로그인 후 Public·Studio 어느 화면에서도 로그아웃 버튼이 없다. §1.4 "로그아웃", "로그아웃 후 보호된 데이터가 UI 상태에 남지 않는다" 미충족 |
|
||||||
|
| **N-2** | **중복 관계 생성이 방지되지 않는다** | 같은 대상을 두 번 연결해 저장해도 경고가 없다. 계약에 `uniqueItems` 제약이 없고(`relations: maxItems 20`뿐), `validate-working-copy.ts`도 slug 중복만 검사한다(`SLUG_DUPLICATE`). **백엔드를 구현해도 계약이 허용하므로 같은 결과가 난다.** §3.2 미충족 |
|
||||||
|
| **N-3** | **CLS 0.192 (기준 0.1)** | 원인 단일: `FOOTER.site-footer`가 t=538ms에 0.1922 이동. 나머지 shift는 0.0001. 세 라우트 모두 동일 값 → 앱 셸 마운트 시점의 footer 점프. §4 "주요 화면의 Layout Shift가 없다" 미충족 |
|
||||||
|
| **N-4** | **`navigation_path`(slug 조회)에 인덱스가 없다** | 20,000행 기준 `Seq Scan`, `Rows Removed by Filter: 19999`, **236ms**. `enable_seqscan=off`로도 인덱스를 못 쓴다 → 존재하지 않는다. 체크리스트가 명시한 "Slug 조회" 쿼리 패턴 |
|
||||||
|
| **N-5** | 검색 trgm 인덱스가 플래너에 선택되지 않음 | GIN trgm 인덱스는 존재하고 강제하면 3.96ms로 동작하나, 20k 규모에서 플래너가 Seq Scan(10.3ms)을 고른다. 운영 규모에서 재확인 필요 |
|
||||||
|
| **N-6** | `/api/v3/api-docs`가 500 | `/swagger-ui`·`/v3/api-docs`는 404로 미배포(정상)인데, path prefix가 붙은 `/api/v3/api-docs`만 500 INTERNAL_ERROR |
|
||||||
|
|
||||||
|
## 검증 결과 — 절별
|
||||||
|
|
||||||
|
### §1.2 Routing · §1.3 경계 · §2 기능 — 27/27 PASS
|
||||||
|
|
||||||
|
```
|
||||||
|
§1.2 존재하지 않는 Case / 잘못된 explore kind / 없는 프로젝트 / 없는 릴리스
|
||||||
|
→ 전부 "페이지를 찾을 수 없습니다."
|
||||||
|
§1.2 Not Found 화면, Back/Forward (/explore→/projects→back→forward) 정상
|
||||||
|
§1.3 Public 6개 화면에 studio 링크 0건, Draft 표식 0건
|
||||||
|
§2.1 탐색 목록 6건 · 중복 0 · 필터 적용 6→2건
|
||||||
|
§2.2 검색창 열림 / Focus 이동 / Overlay 겹침 없음 / 입력 중 과요청 0
|
||||||
|
결과 없음 UI / ESC 닫기 / 빈 검색어 정책 / 결과 클릭 → 상세 이동
|
||||||
|
§2.3 프로젝트 목록 2건 · 상세("Backend Skeleton") · 포함 문서 6건
|
||||||
|
§2.4 변경 기록 목록·상세, 연결 문서 7건, 시간순 정렬 일관
|
||||||
|
```
|
||||||
|
|
||||||
|
### §3 Studio — 20/22 PASS (mock 기준)
|
||||||
|
|
||||||
|
```
|
||||||
|
§3.1 새 문서(유형 4종) → 편집 진입 → 저장 → 상태 전달 PASS
|
||||||
|
§3.1 저장 버튼 3연타 → 문서 수 8→9 (증가 1) PASS ← 멱등성 실동작
|
||||||
|
§3.1 미저장 변경 이동 경고 [머무르기/변경 버리기/저장 후 이동] PASS
|
||||||
|
§3.1 머무르기 후 입력값 보존 PASS
|
||||||
|
§3.1 검증 화면("저장본 검증") / 게시 화면("게시 준비") PASS
|
||||||
|
§3.2 관계 추가·순서 이동·삭제, 대상 카탈로그 4건 PASS
|
||||||
|
§3.2 중복 관계 방지 FAIL (N-2)
|
||||||
|
§3.3 즉시 미리보기 렌더 / Public Preview 화면 PASS
|
||||||
|
§3.3 게시 기록 8건 · 게시 취소 버튼 3개 PASS
|
||||||
|
§20 저장 충돌(409) 사용자 안내 PASS
|
||||||
|
콘솔 오류 0건
|
||||||
|
```
|
||||||
|
|
||||||
|
문서 **삭제**는 계약에 오퍼레이션 자체가 없다(`deleteStudioAsset`만 존재). §3.1의 "삭제"는 설계 범위 밖.
|
||||||
|
|
||||||
|
### §4 UX/UI · §5 접근성 · §6 성능 — 15/18 PASS
|
||||||
|
|
||||||
|
```
|
||||||
|
§4 Layout Shift FAIL CLS=0.1924 (N-3)
|
||||||
|
§4 Header가 콘텐츠를 가리지 않음 PASS
|
||||||
|
§4 긴 제목(150자)/긴 본문/긴 URL PASS scrollWidth==clientWidth 1440
|
||||||
|
§4 코드 블록 (pre overflow-x:auto) PASS
|
||||||
|
§5 Modal Focus 이동 / role=dialog / Focus Trap / 닫은 뒤 복귀 PASS
|
||||||
|
§5 키보드 순회 19개 요소 · Focus 표시 전부 존재 PASS
|
||||||
|
§6 긴 문서 렌더링 296ms PASS
|
||||||
|
§6 이미지 lazy loading · width/height 명시 PASS
|
||||||
|
§6 동일 요청 중복 0 · 2초간 DOM 변경 0건(render loop 없음) PASS
|
||||||
|
§6 검색 21자 입력+반영 847ms PASS
|
||||||
|
```
|
||||||
|
|
||||||
|
### §14 데이터베이스
|
||||||
|
|
||||||
|
```
|
||||||
|
Constraint PK 33 · FK 36 · UNIQUE 18 · CHECK 89 · NOT NULL 267 · PK 없는 테이블 0 PASS
|
||||||
|
Index 실행계획 (20,000행 기준)
|
||||||
|
Public 목록(최신순) Index Scan idx_public_latest 0.113ms PASS
|
||||||
|
유형별 조회 Index Scan idx_public_type 0.129ms PASS
|
||||||
|
Topic별 조회 Bitmap Index Scan idx_public_topic 0.229ms PASS
|
||||||
|
검색(trgm) Seq Scan (인덱스 미선택) 10.3ms 주의 (N-5)
|
||||||
|
slug 조회 Seq Scan (인덱스 부재) 236ms FAIL (N-4)
|
||||||
|
```
|
||||||
|
|
||||||
|
`public_resource_projection`의 인덱스들이 `WHERE publication_state='ACTIVE' AND
|
||||||
|
visibility='PUBLIC'` 부분 인덱스로 정의되어 있다 — Public/Private 경계를 인덱스 수준에서
|
||||||
|
강제하는 좋은 설계다(§12를 구현할 때 그대로 활용 가능).
|
||||||
|
|
||||||
|
### §19 악용 방지 · §28 Swagger
|
||||||
|
|
||||||
|
```
|
||||||
|
pagination 최대 크기 (limit=1000) 422 REQUEST_VALIDATION_FAILED PASS
|
||||||
|
q 길이 제한 (500자) 422 REQUEST_VALIDATION_FAILED PASS
|
||||||
|
Rate Limit APP_RATE_LIMIT_ENABLED=false 미적용
|
||||||
|
대용량 Body 쓰기 엔드포인트 부재로 검증 불가
|
||||||
|
/swagger-ui, /v3/api-docs 404 (미배포) PASS
|
||||||
|
/api/v3/api-docs 500 주의 (N-6)
|
||||||
|
```
|
||||||
|
|
||||||
|
### §26 의존성 장애
|
||||||
|
|
||||||
|
```
|
||||||
|
PostgreSQL Down catalog 503 DB_UNAVAILABLE(retryable) 30s · readiness 503 DOWN
|
||||||
|
liveness 200 UP 유지 · 복구 후 27ms 정상 PASS
|
||||||
|
Keycloak Down JWKS 캐시로 기존 토큰 32ms/200 · 잘못된 서명 21ms/401
|
||||||
|
readiness 200 UP 유지(외부 IdP를 readiness에 걸지 않음)
|
||||||
|
복구 후 정상 PASS
|
||||||
|
Backend 단절 Public 화면 정상 유지(정적 소스) PASS
|
||||||
|
MinIO / Redis 해당 없음(미배선)
|
||||||
|
```
|
||||||
|
|
||||||
|
### §0 · §9 설정
|
||||||
|
|
||||||
|
```
|
||||||
|
src/.env 가 git에 커밋되어 있다 — 값은 local 프로파일용이지만 .gitignore에 .env가 없어
|
||||||
|
구조적으로 막혀 있지 않다. Redis HMAC은 secret://environment/... 간접 참조를 쓴다(좋은 패턴).
|
||||||
|
prod 5개 validator 실동작 확인 (정정 1)
|
||||||
|
show-sql=false · 로그에 토큰/쿠키/비밀번호 0건 · user= 는 가명화 해시
|
||||||
|
```
|
||||||
|
|
||||||
|
## 남은 것 — 로컬에서 불가능
|
||||||
|
|
||||||
|
| 절 | 이유 |
|
||||||
|
|---|---|
|
||||||
|
| §12 Public/Private 경계 | Public 엔드포인트·문서 엔드포인트 부재 |
|
||||||
|
| §15 N+1 / JPA Query | Tech Log에 JPA 리포지토리 0건 (catalog는 raw JDBC 단일 쿼리) |
|
||||||
|
| §16 Transaction | 쓰기 유스케이스 부재 |
|
||||||
|
| §17 파일/Object Storage | 업로드 엔드포인트·스토리지 배선 부재 |
|
||||||
|
| §18 HTTPS/HSTS/Redirect | TLS 종단 필요 |
|
||||||
|
| §22 Grafana·Loki 대시보드 | 관측 스택 필요 (수집 측 127개 메트릭은 확인 완료) |
|
||||||
|
| §24 Kubernetes | 매니페스트·오케스트레이터 부재 |
|
||||||
|
| §25 Ingress 라우팅 · X-Forwarded-* | 리버스 프록시 필요 |
|
||||||
|
| §27 Backup / Restore | 실제 볼륨·운영 DB 필요 |
|
||||||
|
| §30 Production Smoke Test | 운영 환경 부재 |
|
||||||
|
| §1.4 세션 만료 · 토큰 만료 후 프론트 동작 | demo 어댑터에 만료 개념이 없음 (외부 IdP 연동 필요) |
|
||||||
@@ -5,6 +5,11 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="description" content="Tech Log frontend" />
|
<meta name="description" content="Tech Log frontend" />
|
||||||
<title>Tech Log</title>
|
<title>Tech Log</title>
|
||||||
|
<!-- public/favicon.svg 는 빌드가 dist 루트로 복사하고 nginx 도 서빙하지만,
|
||||||
|
참조가 없어 브라우저는 /favicon.ico 를 찾다가 404 를 받고 기본 아이콘을
|
||||||
|
띄우고 있었다. %BASE_URL% 은 Vite 가 base 로 치환한다 — 경로 프리픽스
|
||||||
|
배포(/dev/)에서도 같은 파일을 가리키게 하려면 절대경로여선 안 된다. -->
|
||||||
|
<link rel="icon" type="image/svg+xml" href="%BASE_URL%favicon.svg" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -38,14 +38,28 @@
|
|||||||
},
|
},
|
||||||
"contractSet": {
|
"contractSet": {
|
||||||
"setAlgorithm": "CA_CONTRACT_SET_V1",
|
"setAlgorithm": "CA_CONTRACT_SET_V1",
|
||||||
"setDigest": "sha256:e0da77655f51592ece583826d5fc6b092f57dd2bf63307e45e7e77283e6bf437",
|
"setDigest": "sha256:38329cfe645e1d6cbfc7a9bb3e20e23b579a286ad0d1254982c70de5c6055ae8",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"packageId": "@tech-log/studio-contract",
|
"packageId": "@tech-log/management-contract",
|
||||||
"version": "2.0.0",
|
"version": "1.0.0",
|
||||||
"digest": "sha256:99f54f56ea0c582eafdbdf9be5653e3384bef0a1b08bff67f3147ee0292019ea",
|
"digest": "sha256:d19ae7c4fbcac924a356bbb0cc1a46a4046ecec701158ca0a9b7cc089bbaf878",
|
||||||
"runtimeProtocolVersion": 1,
|
"runtimeProtocolVersion": 1,
|
||||||
"sourceRevision": "ce2e748"
|
"sourceRevision": "65a04fc"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"packageId": "@tech-log/public-contract",
|
||||||
|
"version": "2.0.0",
|
||||||
|
"digest": "sha256:6575a09317a1ffe951747b12102ad2cf884110007426a45d6f59a53b65612d59",
|
||||||
|
"runtimeProtocolVersion": 1,
|
||||||
|
"sourceRevision": "65a04fc"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"packageId": "@tech-log/studio-contract",
|
||||||
|
"version": "3.0.0",
|
||||||
|
"digest": "sha256:5229865c3d242f19d75030d3f524a44dfebbf444324068d6ae88e43b8047dba4",
|
||||||
|
"runtimeProtocolVersion": 1,
|
||||||
|
"sourceRevision": "65a04fc"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -459,7 +459,16 @@ const CANONICAL_GATE_SHAPE_SHA256 =
|
|||||||
// Dev release manifest drift fix, item 2: recomputed again after FE-GATE-010
|
// Dev release manifest drift fix, item 2: recomputed again after FE-GATE-010
|
||||||
// gained `check-dev-release-manifest`. Same method — 98d19911… was first
|
// gained `check-dev-release-manifest`. Same method — 98d19911… was first
|
||||||
// reproduced from the previous gates.json before this value was hashed.
|
// reproduced from the previous gates.json before this value was hashed.
|
||||||
"b40962448e617883060e09eb7183837cfea6833519f435f11713efd083210dbe";
|
// Taxonomy route: recomputed again after FE-GATE-009 gained the
|
||||||
|
// TECH_LOG_STUDIO_TAXONOMY manual accessibility evidence artifact. The gate
|
||||||
|
// lists one evidence artifact per installed route and refuses a set that does
|
||||||
|
// not match the route scope exactly, so adding a route necessarily moves this
|
||||||
|
// digest — that is the point of pinning it.
|
||||||
|
// Release authoring: recomputed again after FE-GATE-009 gained the
|
||||||
|
// TECH_LOG_STUDIO_RELEASES evidence artifact, by the same method — 8c73d447…
|
||||||
|
// was first reproduced from the previous gates.json, so the computation that
|
||||||
|
// produced this value is known to be the one the constant was pinned under.
|
||||||
|
"187dbd9676b7b3444409b5af4143d49927b1eacc67f57a099f4a3765bb64d865";
|
||||||
|
|
||||||
function canonicalGateShapeSha256(gates: CiGateContract["gates"]): string {
|
function canonicalGateShapeSha256(gates: CiGateContract["gates"]): string {
|
||||||
const normalized = gates.map(
|
const normalized = gates.map(
|
||||||
@@ -512,8 +521,10 @@ function canonicalAuthorityBaselineFailures(contract: CiGateContract): string[]
|
|||||||
// Template merge. 126 product artifacts plus the two the template added.
|
// Template merge. 126 product artifacts plus the two the template added.
|
||||||
// Task 11 added one more: the TECH_LOG_STUDIO_ASSETS manual a11y evidence file.
|
// Task 11 added one more: the TECH_LOG_STUDIO_ASSETS manual a11y evidence file.
|
||||||
// Alignment follow-up, item 2 added the TechLog junit report.
|
// Alignment follow-up, item 2 added the TechLog junit report.
|
||||||
if (contract.artifacts.length !== 130) {
|
// The taxonomy route added its own manual a11y evidence file — every installed
|
||||||
failures.push(`artifact authority baseline must contain exactly 130 artifacts; received ${contract.artifacts.length}`);
|
// route carries one, and the gate checks that the two sets match exactly.
|
||||||
|
if (contract.artifacts.length !== 132) {
|
||||||
|
failures.push(`artifact authority baseline must contain exactly 132 artifacts; received ${contract.artifacts.length}`);
|
||||||
}
|
}
|
||||||
if (contract.stages.length !== 5) {
|
if (contract.stages.length !== 5) {
|
||||||
failures.push(`stage authority baseline must contain exactly 5 stages; received ${contract.stages.length}`);
|
failures.push(`stage authority baseline must contain exactly 5 stages; received ${contract.stages.length}`);
|
||||||
|
|||||||
@@ -0,0 +1,231 @@
|
|||||||
|
import { readFile, writeFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emits the nginx configuration the deployed frontend is served with.
|
||||||
|
*
|
||||||
|
* Generated rather than hand-written because three files already decide what it
|
||||||
|
* must say, and a copy of them would drift: `dist/tech-log-serving-contract.json`
|
||||||
|
* (which paths are SPA routes and what a miss answers with),
|
||||||
|
* `config/hosting/security-headers.json`, and `config/hosting/cache-policy.json`.
|
||||||
|
* The repository had no frontend deployment artifact at all — no Dockerfile, no
|
||||||
|
* server config — so those two hosting files described a contract nothing
|
||||||
|
* fulfilled: `dist/server.mjs` applies neither, answering `no-cache` for hashed
|
||||||
|
* assets and sending no security headers.
|
||||||
|
*
|
||||||
|
* This serves static files only. TLS and the BFF paths belong to the edge: the
|
||||||
|
* deployment's own nginx terminates HTTPS and sends `/api`, the OIDC redirect
|
||||||
|
* chain and the identity provider to the backend directly (in Kubernetes,
|
||||||
|
* Traefik does). A second proxy hop here would only add a place for the two
|
||||||
|
* routing tables to disagree.
|
||||||
|
*
|
||||||
|
* The base path comes from `VITE_ROUTER_BASE_PATH`, the same value the bundle is
|
||||||
|
* built with: served under a prefix, every route and asset lives under it too.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const DIST = "dist";
|
||||||
|
const OUT = path.join(DIST, "nginx.conf");
|
||||||
|
|
||||||
|
type ServingContract = Readonly<{
|
||||||
|
publicSpaPathPatterns: readonly string[];
|
||||||
|
studioPathPrefix: string;
|
||||||
|
studioSpaPathPatterns: readonly string[];
|
||||||
|
notFound: Readonly<{ status: number; contentType: string; body: string }>;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
type HostingHeaders = Readonly<{ headers: Readonly<Record<string, string>> }>;
|
||||||
|
|
||||||
|
type CachePolicy = Readonly<{
|
||||||
|
surfaces: Readonly<
|
||||||
|
Record<
|
||||||
|
string,
|
||||||
|
Readonly<{
|
||||||
|
path?: string;
|
||||||
|
pathPattern?: string;
|
||||||
|
cacheControl?: string;
|
||||||
|
securityHeaders?: boolean;
|
||||||
|
}>
|
||||||
|
>
|
||||||
|
>;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
async function readJson<T>(file: string): Promise<T> {
|
||||||
|
return JSON.parse(await readFile(file, "utf8")) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** nginx location matching is not regex-escaped for us; only `=` exact paths are literal. */
|
||||||
|
function exactLocation(pathname: string): string {
|
||||||
|
return pathname;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A JS regex from the contract translated for nginx. Both use PCRE-ish syntax
|
||||||
|
* for what the contract uses (`^`, `$`, `[^/]+`, alternation), so the pattern
|
||||||
|
* carries over unchanged — asserted rather than assumed, because a pattern that
|
||||||
|
* silently failed to translate would open a Studio route to the 404 branch.
|
||||||
|
*/
|
||||||
|
function studioRegex(pattern: string): string {
|
||||||
|
// nginx uses PCRE, so anchors, character classes, alternation and plain groups
|
||||||
|
// carry over as written. Lookaround and backreferences do not translate the
|
||||||
|
// same way and would silently change which paths match, so they are refused.
|
||||||
|
if (/\(\?[=!<]|\\[1-9]/.test(pattern)) {
|
||||||
|
throw new Error(
|
||||||
|
`studio SPA pattern uses a construct this generator does not translate: ${pattern}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return pattern;
|
||||||
|
}
|
||||||
|
|
||||||
|
function headerDirectives(
|
||||||
|
headers: Readonly<Record<string, string>>,
|
||||||
|
indent: string,
|
||||||
|
): string {
|
||||||
|
return Object.entries(headers)
|
||||||
|
.map(([name, value]) => `${indent}add_header ${name} "${value}" always;`)
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const contract = await readJson<ServingContract>(
|
||||||
|
path.join(DIST, "tech-log-serving-contract.json"),
|
||||||
|
);
|
||||||
|
const security = await readJson<HostingHeaders>(
|
||||||
|
"config/hosting/security-headers.json",
|
||||||
|
);
|
||||||
|
const cache = await readJson<CachePolicy>("config/hosting/cache-policy.json");
|
||||||
|
|
||||||
|
const surfaces = cache.surfaces;
|
||||||
|
const indexCache = surfaces["index"]?.cacheControl ?? "no-cache";
|
||||||
|
const configCache = surfaces["runtimeConfig"]?.cacheControl ?? "no-store";
|
||||||
|
const manifestCache = surfaces["releaseManifest"]?.cacheControl ?? "no-store";
|
||||||
|
const assetCache = surfaces["hashedAsset"]?.cacheControl ?? "no-cache";
|
||||||
|
|
||||||
|
const secure = headerDirectives(security.headers, " ");
|
||||||
|
|
||||||
|
// The bundle's own base path. `/` for a deployment at the domain root, `/dev/`
|
||||||
|
// for one served under a prefix — the routes below have to carry it or nginx
|
||||||
|
// matches paths the browser never asks for.
|
||||||
|
const rawBase = process.env["VITE_ROUTER_BASE_PATH"] ?? "/";
|
||||||
|
const basePath = rawBase.endsWith("/") ? rawBase.slice(0, -1) : rawBase;
|
||||||
|
|
||||||
|
const [notFoundType, notFoundCharsetParam] = contract.notFound.contentType
|
||||||
|
.split(";")
|
||||||
|
.map((part) => part.trim());
|
||||||
|
const notFoundCharset = (notFoundCharsetParam ?? "charset=utf-8")
|
||||||
|
.replace(/^charset=/i, "")
|
||||||
|
.toLowerCase();
|
||||||
|
|
||||||
|
// Regex locations now, matching the Studio half: the contract declares which
|
||||||
|
// paths the router serves, not which ones the fixture happened to contain, so
|
||||||
|
// a record published after this build is served instead of 404ed at the edge.
|
||||||
|
const publicLocations = contract.publicSpaPathPatterns
|
||||||
|
.map(
|
||||||
|
(pattern: string) => ` location ~ ^${basePath}${studioRegex(pattern).slice(1)} {
|
||||||
|
${secure}
|
||||||
|
add_header Cache-Control "${indexCache}" always;
|
||||||
|
try_files /index.html =404;
|
||||||
|
}`,
|
||||||
|
)
|
||||||
|
.join("\n\n");
|
||||||
|
|
||||||
|
const studioLocations = contract.studioSpaPathPatterns
|
||||||
|
.map(
|
||||||
|
(pattern) => ` location ~ ^${basePath}${studioRegex(pattern).slice(1)} {
|
||||||
|
${secure}
|
||||||
|
add_header Cache-Control "${indexCache}" always;
|
||||||
|
try_files /index.html =404;
|
||||||
|
}`,
|
||||||
|
)
|
||||||
|
.join("\n\n");
|
||||||
|
|
||||||
|
const conf = `# Generated by scripts/generate-nginx-config.ts — do not edit.
|
||||||
|
# Sources: dist/tech-log-serving-contract.json, config/hosting/security-headers.json,
|
||||||
|
# config/hosting/cache-policy.json
|
||||||
|
#
|
||||||
|
# Plain HTTP on purpose: the edge terminates TLS and this container is only ever
|
||||||
|
# reached from inside the deployment network.
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
|
||||||
|
# The bundle is small and already compressed at rest by the build; gzip here
|
||||||
|
# covers the JSON surfaces and index.html.
|
||||||
|
gzip on;
|
||||||
|
gzip_types text/css application/javascript text/javascript application/json;
|
||||||
|
gzip_min_length 1024;
|
||||||
|
|
||||||
|
# Static surfaces, per config/hosting/cache-policy.json
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
location = ${basePath}/config.json {
|
||||||
|
alias /usr/share/nginx/html/config.json;
|
||||||
|
${secure}
|
||||||
|
add_header Cache-Control "${configCache}" always;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = ${basePath}/release-manifest.json {
|
||||||
|
alias /usr/share/nginx/html/release-manifest.json;
|
||||||
|
${secure}
|
||||||
|
add_header Cache-Control "${manifestCache}" always;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Content-hashed filenames, so the long TTL is safe and revalidation is waste.
|
||||||
|
location ${basePath}/assets/ {
|
||||||
|
# alias, not root + URI: under a base path the request is /dev/assets/x.js
|
||||||
|
# while the file is dist/assets/x.js, so root would look for
|
||||||
|
# dist/dev/assets/x.js and answer 404 for every script on the page.
|
||||||
|
alias /usr/share/nginx/html/assets/;
|
||||||
|
add_header Cache-Control "${assetCache}" always;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Source maps are not published (cache-policy sourceMap.public = false).
|
||||||
|
location ~ \\.map$ {
|
||||||
|
return 404;
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# SPA routes. Enumerated from the serving contract rather than a catch-all:
|
||||||
|
# a path that is not a real route answers 404 instead of a 200 shell, which is
|
||||||
|
# what tells a crawler the difference.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
${publicLocations}
|
||||||
|
|
||||||
|
${studioLocations}
|
||||||
|
|
||||||
|
location = ${basePath}/favicon.svg {
|
||||||
|
alias /usr/share/nginx/html/favicon.svg;
|
||||||
|
add_header Cache-Control "${assetCache}" always;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = ${basePath}/media/ {
|
||||||
|
return 404;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ${basePath}/media/ {
|
||||||
|
alias /usr/share/nginx/html/media/;
|
||||||
|
add_header Cache-Control "${assetCache}" always;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Anything else is not a route this deployment serves.
|
||||||
|
location / {
|
||||||
|
# The contract states the content type with its charset attached
|
||||||
|
# (text/plain;charset=UTF-8), but nginx takes the two separately —
|
||||||
|
# default_type rejects a parameter outright.
|
||||||
|
default_type ${notFoundType};
|
||||||
|
charset ${notFoundCharset};
|
||||||
|
return ${contract.notFound.status} "${contract.notFound.body}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
await writeFile(OUT, conf, "utf8");
|
||||||
|
process.stdout.write(
|
||||||
|
`nginx config: ${OUT} (${contract.publicSpaPathPatterns.length} public routes, ` +
|
||||||
|
`${contract.studioSpaPathPatterns.length} studio patterns)\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await main();
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* canonical studio-v1.yaml을 vendor하고 타입을 생성한다.
|
* canonical 계약(studio-v1, public-v1)을 vendor하고 타입을 생성한다.
|
||||||
*
|
*
|
||||||
* 생성기는 저장소 의존성에 넣지 않는다. `openapi-typescript`는 TypeScript 5의
|
* 생성기는 저장소 의존성에 넣지 않는다. `openapi-typescript`는 TypeScript 5의
|
||||||
* classic compiler API를 요구하는데 이 저장소는 TypeScript 7.0.2를 고정하고
|
* classic compiler API를 요구하는데 이 저장소는 TypeScript 7.0.2를 고정하고
|
||||||
@@ -12,15 +12,53 @@
|
|||||||
*/
|
*/
|
||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import { execFileSync } from "node:child_process";
|
import { execFileSync } from "node:child_process";
|
||||||
import { readFileSync, writeFileSync } from "node:fs";
|
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import { dirname } from "node:path";
|
||||||
import { argv, env, exit } from "node:process";
|
import { argv, env, exit } from "node:process";
|
||||||
|
|
||||||
const CANONICAL_ROOT =
|
const CANONICAL_ROOT =
|
||||||
env.TECH_LOG_DESIGN_PACKAGE ?? "/home/donghyeon/workspace/tech-log-design-package";
|
env.TECH_LOG_DESIGN_PACKAGE ?? "/home/donghyeon/workspace/tech-log-design-package";
|
||||||
const CANONICAL_YAML = `${CANONICAL_ROOT}/contracts/openapi/studio-v1.yaml`;
|
|
||||||
const VENDOR_YAML = "src/features/tech-log/contracts/studio/studio-api.openapi.yaml";
|
/**
|
||||||
const GENERATED = "src/features/tech-log/contracts/studio/generated.ts";
|
* 계약은 둘이고 서로 독립이다. Studio는 인증된 작성 표면이고, Public은 인증
|
||||||
const SOURCE_RECORD = "src/features/tech-log/contracts/studio/canonical-source.json";
|
* 없는 조회 표면이다. 각자 자기 canonical yaml에서 나오고 자기 digest를 들고
|
||||||
|
* 다니므로, 한쪽이 갱신돼도 다른 쪽 drift 게이트는 조용하다.
|
||||||
|
*/
|
||||||
|
type ContractTarget = Readonly<{
|
||||||
|
name: string;
|
||||||
|
packageId: string;
|
||||||
|
canonicalYaml: string;
|
||||||
|
vendorYaml: string;
|
||||||
|
generated: string;
|
||||||
|
sourceRecord: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
const CONTRACTS: readonly ContractTarget[] = Object.freeze([
|
||||||
|
Object.freeze({
|
||||||
|
name: "studio",
|
||||||
|
packageId: "@tech-log/studio-contract",
|
||||||
|
canonicalYaml: `${CANONICAL_ROOT}/contracts/openapi/studio-v1.yaml`,
|
||||||
|
vendorYaml: "src/features/tech-log/contracts/studio/studio-api.openapi.yaml",
|
||||||
|
generated: "src/features/tech-log/contracts/studio/generated.ts",
|
||||||
|
sourceRecord: "src/features/tech-log/contracts/studio/canonical-source.json",
|
||||||
|
}),
|
||||||
|
Object.freeze({
|
||||||
|
name: "public",
|
||||||
|
packageId: "@tech-log/public-contract",
|
||||||
|
canonicalYaml: `${CANONICAL_ROOT}/contracts/openapi/public-v1.yaml`,
|
||||||
|
vendorYaml: "src/features/tech-log/contracts/public/public-api.openapi.yaml",
|
||||||
|
generated: "src/features/tech-log/contracts/public/generated.ts",
|
||||||
|
sourceRecord: "src/features/tech-log/contracts/public/canonical-source.json",
|
||||||
|
}),
|
||||||
|
Object.freeze({
|
||||||
|
name: "management",
|
||||||
|
packageId: "@tech-log/management-contract",
|
||||||
|
canonicalYaml: `${CANONICAL_ROOT}/contracts/openapi/studio-management-v1.yaml`,
|
||||||
|
vendorYaml: "src/features/tech-log/contracts/management/management-api.openapi.yaml",
|
||||||
|
generated: "src/features/tech-log/contracts/management/generated.ts",
|
||||||
|
sourceRecord: "src/features/tech-log/contracts/management/canonical-source.json",
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
const OPENAPI_TYPESCRIPT = "openapi-typescript@7.9.1";
|
const OPENAPI_TYPESCRIPT = "openapi-typescript@7.9.1";
|
||||||
const GENERATOR_TYPESCRIPT = "typescript@5.9.3";
|
const GENERATOR_TYPESCRIPT = "typescript@5.9.3";
|
||||||
@@ -56,71 +94,84 @@ function fail(problems: readonly string[]): never {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (check) {
|
if (check) {
|
||||||
const vendored = readFileSync(VENDOR_YAML, "utf8");
|
|
||||||
const generated = readFileSync(GENERATED, "utf8");
|
|
||||||
const record = JSON.parse(readFileSync(SOURCE_RECORD, "utf8")) as CanonicalRecord;
|
|
||||||
const problems: string[] = [];
|
const problems: string[] = [];
|
||||||
|
const summaries: string[] = [];
|
||||||
|
|
||||||
if (digestOf(readFileSync(VENDOR_YAML)) !== record.digest) {
|
for (const target of CONTRACTS) {
|
||||||
problems.push(`${VENDOR_YAML} does not hash to the recorded digest`);
|
const vendored = readFileSync(target.vendorYaml, "utf8");
|
||||||
}
|
const generated = readFileSync(target.generated, "utf8");
|
||||||
const vendoredOperations = operationIdsOf(vendored);
|
const record = JSON.parse(readFileSync(target.sourceRecord, "utf8")) as CanonicalRecord;
|
||||||
if (vendoredOperations.join(" ") !== [...record.operationIds].join(" ")) {
|
|
||||||
problems.push(`${SOURCE_RECORD} operationIds differ from ${VENDOR_YAML}`);
|
if (digestOf(readFileSync(target.vendorYaml)) !== record.digest) {
|
||||||
}
|
problems.push(`${target.vendorYaml} does not hash to the recorded digest`);
|
||||||
if (specVersionOf(vendored) !== record.version) {
|
|
||||||
problems.push(`${SOURCE_RECORD} version differs from ${VENDOR_YAML}`);
|
|
||||||
}
|
|
||||||
// 생성물은 operationId로 키가 매겨진 `operations` 인터페이스를 노출한다.
|
|
||||||
for (const operationId of record.operationIds) {
|
|
||||||
if (!new RegExp(`^\\s{4}${operationId}:`, "mu").test(generated)) {
|
|
||||||
problems.push(`${GENERATED} is missing operation ${operationId}`);
|
|
||||||
}
|
}
|
||||||
|
if (operationIdsOf(vendored).join(" ") !== [...record.operationIds].join(" ")) {
|
||||||
|
problems.push(`${target.sourceRecord} operationIds differ from ${target.vendorYaml}`);
|
||||||
|
}
|
||||||
|
if (specVersionOf(vendored) !== record.version) {
|
||||||
|
problems.push(`${target.sourceRecord} version differs from ${target.vendorYaml}`);
|
||||||
|
}
|
||||||
|
if (record.packageId !== target.packageId) {
|
||||||
|
problems.push(`${target.sourceRecord} packageId is not ${target.packageId}`);
|
||||||
|
}
|
||||||
|
// 생성물은 operationId로 키가 매겨진 `operations` 인터페이스를 노출한다.
|
||||||
|
for (const operationId of record.operationIds) {
|
||||||
|
if (!new RegExp(`^\\s{4}${operationId}:`, "mu").test(generated)) {
|
||||||
|
problems.push(`${target.generated} is missing operation ${operationId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
summaries.push(
|
||||||
|
`${record.packageId}@${record.version} (${record.sourceRevision}), ${record.operationIds.length} operations`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (problems.length > 0) fail(problems);
|
if (problems.length > 0) fail(problems);
|
||||||
console.log(
|
console.log(`tech-log contracts are in sync:\n- ${summaries.join("\n- ")}`);
|
||||||
`tech-log contract is in sync: ${record.packageId}@${record.version} (${record.sourceRevision}), ${record.operationIds.length} operations.`,
|
|
||||||
);
|
|
||||||
exit(0);
|
exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
const canonicalBytes = readFileSync(CANONICAL_YAML);
|
const sourceRevision = execFileSync(
|
||||||
const canonicalText = canonicalBytes.toString("utf8");
|
"git",
|
||||||
|
["-C", CANONICAL_ROOT, "rev-parse", "--short=7", "HEAD"],
|
||||||
|
{ encoding: "utf8" },
|
||||||
|
).trim();
|
||||||
|
|
||||||
const record: CanonicalRecord = {
|
for (const target of CONTRACTS) {
|
||||||
packageId: "@tech-log/studio-contract",
|
const canonicalBytes = readFileSync(target.canonicalYaml);
|
||||||
version: specVersionOf(canonicalText),
|
const canonicalText = canonicalBytes.toString("utf8");
|
||||||
digest: digestOf(canonicalBytes),
|
|
||||||
sourceRevision: execFileSync(
|
|
||||||
"git",
|
|
||||||
["-C", CANONICAL_ROOT, "rev-parse", "--short=7", "HEAD"],
|
|
||||||
{ encoding: "utf8" },
|
|
||||||
).trim(),
|
|
||||||
operationIds: operationIdsOf(canonicalText),
|
|
||||||
};
|
|
||||||
|
|
||||||
// 격리 실행. 저장소의 node_modules와 lockfile은 그대로다.
|
const record: CanonicalRecord = {
|
||||||
const generated = execFileSync(
|
packageId: target.packageId,
|
||||||
"corepack",
|
version: specVersionOf(canonicalText),
|
||||||
[
|
digest: digestOf(canonicalBytes),
|
||||||
"pnpm",
|
sourceRevision,
|
||||||
"dlx",
|
operationIds: operationIdsOf(canonicalText),
|
||||||
"--package",
|
};
|
||||||
GENERATOR_TYPESCRIPT,
|
|
||||||
"--package",
|
|
||||||
OPENAPI_TYPESCRIPT,
|
|
||||||
"openapi-typescript",
|
|
||||||
CANONICAL_YAML,
|
|
||||||
],
|
|
||||||
{ encoding: "utf8", maxBuffer: 32 * 1024 * 1024 },
|
|
||||||
);
|
|
||||||
|
|
||||||
writeFileSync(VENDOR_YAML, canonicalText);
|
// 격리 실행. 저장소의 node_modules와 lockfile은 그대로다.
|
||||||
writeFileSync(GENERATED, generated);
|
const generated = execFileSync(
|
||||||
writeFileSync(SOURCE_RECORD, `${JSON.stringify(record, null, 2)}\n`);
|
"corepack",
|
||||||
console.log(
|
[
|
||||||
`Generated from ${record.packageId}@${record.version} (${record.sourceRevision}), ${record.operationIds.length} operations.`,
|
"pnpm",
|
||||||
);
|
"dlx",
|
||||||
|
"--package",
|
||||||
|
GENERATOR_TYPESCRIPT,
|
||||||
|
"--package",
|
||||||
|
OPENAPI_TYPESCRIPT,
|
||||||
|
"openapi-typescript",
|
||||||
|
target.canonicalYaml,
|
||||||
|
],
|
||||||
|
{ encoding: "utf8", maxBuffer: 32 * 1024 * 1024 },
|
||||||
|
);
|
||||||
|
|
||||||
|
mkdirSync(dirname(target.vendorYaml), { recursive: true });
|
||||||
|
writeFileSync(target.vendorYaml, canonicalText);
|
||||||
|
writeFileSync(target.generated, generated);
|
||||||
|
writeFileSync(target.sourceRecord, `${JSON.stringify(record, null, 2)}\n`);
|
||||||
|
console.log(
|
||||||
|
`Generated from ${record.packageId}@${record.version} (${record.sourceRevision}), ${record.operationIds.length} operations.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 재생성은 매번 package digest를 바꾼다. `pnpm dev`가 그대로 서빙하는
|
// 재생성은 매번 package digest를 바꾼다. `pnpm dev`가 그대로 서빙하는
|
||||||
// `public/release-manifest.json`은 build가 컴파일한 contract set을 그대로
|
// `public/release-manifest.json`은 build가 컴파일한 contract set을 그대로
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
import {
|
import { TECH_LOG_ROUTE_REGISTRY } from "../src/features/tech-log/contracts/tech-log-route-contract.ts";
|
||||||
projects,
|
|
||||||
publicRecords,
|
|
||||||
releases,
|
|
||||||
} from "../src/features/tech-log/adapters/static/public-content.ts";
|
|
||||||
import { writeTechLogServingArtifact } from "./lib/tech-log-serving-artifact.ts";
|
import { writeTechLogServingArtifact } from "./lib/tech-log-serving-artifact.ts";
|
||||||
import { createTechLogServingContract } from "./lib/tech-log-serving-contract.ts";
|
import { createTechLogServingContract } from "./lib/tech-log-serving-contract.ts";
|
||||||
|
|
||||||
const contract = createTechLogServingContract({
|
// The router owns which public paths exist. Reading them from the catalog
|
||||||
projects,
|
// instead — as this did — pinned the served set to whatever the bundled fixture
|
||||||
publicRecords,
|
// contained on the day of the build.
|
||||||
releases,
|
const publicRoutePaths = Object.values(TECH_LOG_ROUTE_REGISTRY)
|
||||||
});
|
.filter((route) => route.layoutGroup === "PUBLIC")
|
||||||
|
.map((route) => route.path);
|
||||||
|
|
||||||
|
const studioRoutePaths = Object.values(TECH_LOG_ROUTE_REGISTRY)
|
||||||
|
.filter((route) => route.layoutGroup === "STUDIO")
|
||||||
|
.map((route) => route.path);
|
||||||
|
|
||||||
|
const contract = createTechLogServingContract({ publicRoutePaths, studioRoutePaths });
|
||||||
|
|
||||||
await writeTechLogServingArtifact({ distRoot: "dist", contract });
|
await writeTechLogServingArtifact({ distRoot: "dist", contract });
|
||||||
|
|||||||
@@ -45,7 +45,9 @@ export function createTechLogProductionServer({
|
|||||||
contract,
|
contract,
|
||||||
}) {
|
}) {
|
||||||
const absoluteRoot = path.resolve(root);
|
const absoluteRoot = path.resolve(root);
|
||||||
const publicSpaPaths = new Set(contract.publicSpaPaths);
|
const publicSpaPathPatterns = contract.publicSpaPathPatterns.map(
|
||||||
|
(pattern) => new RegExp(pattern),
|
||||||
|
);
|
||||||
const studioSpaPathPatterns = contract.studioSpaPathPatterns.map(
|
const studioSpaPathPatterns = contract.studioSpaPathPatterns.map(
|
||||||
(pattern) => new RegExp(pattern),
|
(pattern) => new RegExp(pattern),
|
||||||
);
|
);
|
||||||
@@ -69,7 +71,7 @@ export function createTechLogProductionServer({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
publicSpaPaths.has(pathname) ||
|
publicSpaPathPatterns.some((pattern) => pattern.test(pathname)) ||
|
||||||
studioSpaPathPatterns.some((pattern) => pattern.test(pathname))
|
studioSpaPathPatterns.some((pattern) => pattern.test(pathname))
|
||||||
) {
|
) {
|
||||||
await sendFile(path.join(absoluteRoot, "index.html"), request.method, response);
|
await sendFile(path.join(absoluteRoot, "index.html"), request.method, response);
|
||||||
|
|||||||
@@ -1,6 +1,20 @@
|
|||||||
export type TechLogServingContract = Readonly<{
|
export type TechLogServingContract = Readonly<{
|
||||||
schemaVersion: 1;
|
schemaVersion: 2;
|
||||||
publicSpaPaths: readonly string[];
|
/**
|
||||||
|
* Patterns, not an enumeration.
|
||||||
|
*
|
||||||
|
* This used to list every public path the bundled fixture happened to
|
||||||
|
* contain, and the generated nginx served exactly those. A record published
|
||||||
|
* after the build — which is the entire point of a backend — answered 404 at
|
||||||
|
* the edge before the SPA was ever asked, and no amount of correct routing
|
||||||
|
* inside the bundle could recover it.
|
||||||
|
*
|
||||||
|
* The route contract already declares which paths exist; the catalog only
|
||||||
|
* decides which of them currently resolve, and that is the SPA's call, not
|
||||||
|
* the web server's. Studio has been pattern-based all along — this brings the
|
||||||
|
* public half to the same footing.
|
||||||
|
*/
|
||||||
|
publicSpaPathPatterns: readonly string[];
|
||||||
studioPathPrefix: "/studio";
|
studioPathPrefix: "/studio";
|
||||||
studioSpaPathPatterns: readonly string[];
|
studioSpaPathPatterns: readonly string[];
|
||||||
notFound: Readonly<{
|
notFound: Readonly<{
|
||||||
@@ -11,68 +25,67 @@ export type TechLogServingContract = Readonly<{
|
|||||||
}>;
|
}>;
|
||||||
|
|
||||||
type ServingContractInput = Readonly<{
|
type ServingContractInput = Readonly<{
|
||||||
publicRecords: readonly Readonly<{
|
/**
|
||||||
path: string;
|
* The public route templates the router registers, in route-contract form
|
||||||
topicSlug: string;
|
* (`/cases/:slug`). Passed in rather than imported so this module stays a
|
||||||
}>[];
|
* pure transform the tests can drive directly.
|
||||||
projects: readonly Readonly<{ slug: string }>[];
|
*/
|
||||||
releases: readonly Readonly<{ path: string }>[];
|
publicRoutePaths: readonly string[];
|
||||||
|
/** The Studio route templates, same form and same reason. */
|
||||||
|
studioRoutePaths: readonly string[];
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
const staticPublicPaths = Object.freeze([
|
/**
|
||||||
"/",
|
* `/cases/:slug` -> `^/cases/[^/]+$`. A parameter matches one segment and never
|
||||||
"/explore",
|
* a slash, which is what keeps `/cases/a/b` a 404 instead of a case page.
|
||||||
"/explore/cases",
|
*/
|
||||||
"/explore/questions",
|
function patternOf(routePath: string): string {
|
||||||
"/explore/references",
|
const escaped = routePath
|
||||||
"/profile",
|
.split("/")
|
||||||
"/projects",
|
.map((segment) =>
|
||||||
"/releases",
|
segment.startsWith(":")
|
||||||
"/search",
|
? "[^/]+"
|
||||||
]);
|
: segment.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"),
|
||||||
|
)
|
||||||
|
.join("/");
|
||||||
|
return `^${escaped === "" ? "/" : escaped}$`;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
const studioSpaPathPatterns = Object.freeze([
|
|
||||||
"^/studio$",
|
|
||||||
// The Asset Library is a first-class Studio route (TECH_LOG_STUDIO_ASSETS in
|
|
||||||
// the route contract) but was never listed here, so a hard navigation or a
|
|
||||||
// reload of /studio/assets was served the in-shell Studio 404 -- the screen
|
|
||||||
// was only reachable by client-side navigation from another Studio page.
|
|
||||||
"^/studio/assets$",
|
|
||||||
"^/studio/documents$",
|
|
||||||
"^/studio/documents/new$",
|
|
||||||
"^/studio/documents/[^/]+/(edit|validation|preview|publish)$",
|
|
||||||
"^/studio/publications$",
|
|
||||||
"^/studio/publications/[^/]+/preview$",
|
|
||||||
]);
|
|
||||||
|
|
||||||
function asciiCompare(left: string, right: string): number {
|
function asciiCompare(left: string, right: string): number {
|
||||||
return left < right ? -1 : left > right ? 1 : 0;
|
return left < right ? -1 : left > right ? 1 : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createTechLogServingContract({
|
/**
|
||||||
publicRecords,
|
* The catch-all is the SPA's own not-found screen; serving index.html for every
|
||||||
projects,
|
* unmatched URL would turn the edge 404 into a soft 200 and hide broken links
|
||||||
releases,
|
* from crawlers and from us.
|
||||||
}: ServingContractInput): TechLogServingContract {
|
*/
|
||||||
const publicSpaPaths = new Set(staticPublicPaths);
|
function patternsFor(routePaths: readonly string[]): readonly string[] {
|
||||||
for (const record of publicRecords) {
|
const patterns = new Set<string>();
|
||||||
publicSpaPaths.add(record.path);
|
for (const routePath of routePaths) {
|
||||||
publicSpaPaths.add(`/topics/${record.topicSlug}`);
|
if (routePath === "*" || routePath.includes("*")) continue;
|
||||||
|
patterns.add(patternOf(routePath));
|
||||||
}
|
}
|
||||||
for (const project of projects) {
|
return Object.freeze([...patterns].sort(asciiCompare));
|
||||||
const projectPath = `/projects/${project.slug}`;
|
}
|
||||||
publicSpaPaths.add(projectPath);
|
|
||||||
publicSpaPaths.add(`${projectPath}/activity`);
|
|
||||||
publicSpaPaths.add(`${projectPath}/decisions`);
|
|
||||||
publicSpaPaths.add(`${projectPath}/records`);
|
|
||||||
}
|
|
||||||
for (const release of releases) publicSpaPaths.add(release.path);
|
|
||||||
|
|
||||||
|
export function createTechLogServingContract({
|
||||||
|
publicRoutePaths,
|
||||||
|
studioRoutePaths,
|
||||||
|
}: ServingContractInput): TechLogServingContract {
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
schemaVersion: 1,
|
schemaVersion: 2,
|
||||||
publicSpaPaths: Object.freeze([...publicSpaPaths].sort(asciiCompare)),
|
publicSpaPathPatterns: patternsFor(publicRoutePaths),
|
||||||
studioPathPrefix: "/studio",
|
studioPathPrefix: "/studio",
|
||||||
studioSpaPathPatterns,
|
// Derived, not listed. This was a hand-maintained array, and it went stale
|
||||||
|
// exactly the way a hand-maintained array does: /studio/assets was missing
|
||||||
|
// for its whole life, and /studio/releases repeated the mistake the moment
|
||||||
|
// it was added — the route worked by client-side navigation and 404'd on
|
||||||
|
// reload, because nginx had never heard of it. The route contract already
|
||||||
|
// knows which Studio paths exist, so ask it.
|
||||||
|
studioSpaPathPatterns: patternsFor(studioRoutePaths),
|
||||||
notFound: Object.freeze({
|
notFound: Object.freeze({
|
||||||
status: 404,
|
status: 404,
|
||||||
contentType: "text/plain;charset=UTF-8",
|
contentType: "text/plain;charset=UTF-8",
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { resolveRuntimeCapabilities } from "../contracts/runtime-capabilities.ts";
|
import { resolveRuntimeCapabilities } from "../contracts/runtime-capabilities.ts";
|
||||||
|
import { installBffSessionOwner } from "../features/tech-log/adapters/http/bff-session-owner.ts";
|
||||||
import { INSTALLED_RUNTIME_CAPABILITIES } from "../features/installed-runtime-capabilities.ts";
|
import { INSTALLED_RUNTIME_CAPABILITIES } from "../features/installed-runtime-capabilities.ts";
|
||||||
import { createCompositionRoot } from "./composition-root.ts";
|
import { createCompositionRoot } from "./composition-root.ts";
|
||||||
import { loadReleaseManifest } from "./load-release-manifest.ts";
|
import { loadReleaseManifest } from "./load-release-manifest.ts";
|
||||||
@@ -27,13 +28,31 @@ export async function createRuntimeComposition(
|
|||||||
loadConfig: () => loadRuntimeConfig({ fetcher: dependencies.fetcher }),
|
loadConfig: () => loadRuntimeConfig({ fetcher: dependencies.fetcher }),
|
||||||
loadRelease: (runtime) =>
|
loadRelease: (runtime) =>
|
||||||
loadReleaseManifest(runtime, { fetcher: dependencies.fetcher }),
|
loadReleaseManifest(runtime, { fetcher: dependencies.fetcher }),
|
||||||
createAdapters: ({ config: runtime, release }) =>
|
createAdapters: ({ config: runtime, release }) => {
|
||||||
createRuntimeAdapters({
|
// `AUTH_MODE: "external"` delegates the session to whoever hosts this
|
||||||
|
// bundle. For Tech Log that host is its own backend — the session is an
|
||||||
|
// httpOnly cookie the SPA cannot read — so the owner is installed here,
|
||||||
|
// before the adapters resolve it. Only for the HTTP Studio: the MOCK
|
||||||
|
// source has no backend to ask, and `demo` keeps its own adapter.
|
||||||
|
const host =
|
||||||
|
dependencies.host ?? (globalThis as unknown as Record<string, unknown>);
|
||||||
|
if (
|
||||||
|
runtime.config.AUTH_MODE === "external" &&
|
||||||
|
runtime.config.TECH_LOG_STUDIO_SOURCE === "HTTP"
|
||||||
|
) {
|
||||||
|
installBffSessionOwner(
|
||||||
|
host,
|
||||||
|
runtime.config.API_BASE_URL,
|
||||||
|
dependencies.fetcher ?? fetch,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return createRuntimeAdapters({
|
||||||
runtime,
|
runtime,
|
||||||
release,
|
release,
|
||||||
fetcher: dependencies.fetcher,
|
fetcher: dependencies.fetcher,
|
||||||
host: dependencies.host,
|
host: dependencies.host,
|
||||||
}),
|
});
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const capabilities = resolveRuntimeCapabilities(
|
const capabilities = resolveRuntimeCapabilities(
|
||||||
|
|||||||
@@ -513,6 +513,18 @@ export async function createRuntimeAdapters(
|
|||||||
techLogCsrf,
|
techLogCsrf,
|
||||||
);
|
);
|
||||||
if (studioOutcome) return studioOutcome;
|
if (studioOutcome) return studioOutcome;
|
||||||
|
// An anonymous profile carries no credentials by definition — the
|
||||||
|
// registry refuses to install one that allows any credential header. It
|
||||||
|
// must therefore never consult the session: a signed-out visitor's state
|
||||||
|
// is `unauthenticated`, and falling through below refused every public
|
||||||
|
// read before it left the browser. The public site rendered its terminal
|
||||||
|
// error surface on every screen with no request in the network log.
|
||||||
|
//
|
||||||
|
// Keyed on the profile's transport rather than a profile id, so any
|
||||||
|
// anonymous operation is covered rather than one named surface.
|
||||||
|
if (INSTALLED_REST_AUTH_PROFILES.get(operation.authProfileId)?.transport === "ANONYMOUS") {
|
||||||
|
return Object.freeze({ kind: "READY" as const, headers: Object.freeze({}) });
|
||||||
|
}
|
||||||
const state = authSession.getState();
|
const state = authSession.getState();
|
||||||
if (state === "integration-failed") {
|
if (state === "integration-failed") {
|
||||||
return Object.freeze({ kind: "UNAVAILABLE" as const });
|
return Object.freeze({ kind: "UNAVAILABLE" as const });
|
||||||
@@ -599,6 +611,7 @@ export async function createRuntimeAdapters(
|
|||||||
const featureInputs = createInstalledFeatureInputs({
|
const featureInputs = createInstalledFeatureInputs({
|
||||||
contractOperations,
|
contractOperations,
|
||||||
studioSource: config.TECH_LOG_STUDIO_SOURCE,
|
studioSource: config.TECH_LOG_STUDIO_SOURCE,
|
||||||
|
publicSource: config.TECH_LOG_PUBLIC_SOURCE,
|
||||||
apiBaseUrl: config.API_BASE_URL,
|
apiBaseUrl: config.API_BASE_URL,
|
||||||
requestTimeoutMs: config.REQUEST_TIMEOUT_MS,
|
requestTimeoutMs: config.REQUEST_TIMEOUT_MS,
|
||||||
csrf: techLogCsrf,
|
csrf: techLogCsrf,
|
||||||
|
|||||||
@@ -56,6 +56,12 @@ export type RuntimeConfig = Readonly<{
|
|||||||
* MOCK.
|
* MOCK.
|
||||||
*/
|
*/
|
||||||
TECH_LOG_STUDIO_SOURCE: "MOCK" | "HTTP";
|
TECH_LOG_STUDIO_SOURCE: "MOCK" | "HTTP";
|
||||||
|
/**
|
||||||
|
* §3.5-adjacent runtime switch: which public-read adapter this build talks
|
||||||
|
* to. Independent of the Studio switch — the two surfaces are separate
|
||||||
|
* services. A V1 document predates the key and normalizes to MOCK.
|
||||||
|
*/
|
||||||
|
TECH_LOG_PUBLIC_SOURCE: "MOCK" | "HTTP";
|
||||||
/** Present only while a V1 document is still accepted. */
|
/** Present only while a V1 document is still accepted. */
|
||||||
LEGACY_API_CONTRACT_VERSION?: string;
|
LEGACY_API_CONTRACT_VERSION?: string;
|
||||||
}>;
|
}>;
|
||||||
@@ -143,6 +149,10 @@ export function validateRuntimeConfig(value: unknown): RuntimeConfigValidation {
|
|||||||
TECH_LOG_STUDIO_SOURCE: isV2
|
TECH_LOG_STUDIO_SOURCE: isV2
|
||||||
? (parsed as RuntimeConfigV2).TECH_LOG_STUDIO_SOURCE
|
? (parsed as RuntimeConfigV2).TECH_LOG_STUDIO_SOURCE
|
||||||
: "MOCK",
|
: "MOCK",
|
||||||
|
// Same treatment for the public-read switch.
|
||||||
|
TECH_LOG_PUBLIC_SOURCE: isV2
|
||||||
|
? (parsed as RuntimeConfigV2).TECH_LOG_PUBLIC_SOURCE
|
||||||
|
: "MOCK",
|
||||||
...(isV2
|
...(isV2
|
||||||
? {}
|
? {}
|
||||||
: {
|
: {
|
||||||
|
|||||||
@@ -52,6 +52,11 @@ export const ENV_REGISTRY = Object.freeze({
|
|||||||
// TechLog Studio gateway adapter selection. Defaults to MOCK while the
|
// TechLog Studio gateway adapter selection. Defaults to MOCK while the
|
||||||
// backend does not exist yet.
|
// backend does not exist yet.
|
||||||
TECH_LOG_STUDIO_SOURCE: runtime("public", false, "MOCK"),
|
TECH_LOG_STUDIO_SOURCE: runtime("public", false, "MOCK"),
|
||||||
|
// TechLog public-read adapter selection. Separate from the Studio switch on
|
||||||
|
// purpose: the two surfaces are different services on different schedules,
|
||||||
|
// and the combination that matters right now — an authoring backend that is
|
||||||
|
// live while the public read API is not — is unreachable with one flag.
|
||||||
|
TECH_LOG_PUBLIC_SOURCE: runtime("public", false, "MOCK"),
|
||||||
// §3.5: build-time narrowing of the product manifest. A feature left out
|
// §3.5: build-time narrowing of the product manifest. A feature left out
|
||||||
// here is not imported by any registry and never reaches the bundle.
|
// here is not imported by any registry and never reaches the bundle.
|
||||||
VITE_PRODUCT_FEATURES: build("compile-time", false, null),
|
VITE_PRODUCT_FEATURES: build("compile-time", false, null),
|
||||||
|
|||||||
@@ -159,6 +159,10 @@ export const runtimeConfigV2ArtifactSchema = z
|
|||||||
// TechLog Studio adapter selection. Backend is not live yet, so the
|
// TechLog Studio adapter selection. Backend is not live yet, so the
|
||||||
// default is the in-memory mock; a document may opt a build into HTTP.
|
// default is the in-memory mock; a document may opt a build into HTTP.
|
||||||
TECH_LOG_STUDIO_SOURCE: z.enum(["MOCK", "HTTP"]).default("MOCK"),
|
TECH_LOG_STUDIO_SOURCE: z.enum(["MOCK", "HTTP"]).default("MOCK"),
|
||||||
|
// TechLog public-read adapter selection, defaulted the same way and for
|
||||||
|
// the same reason. Held apart from the Studio switch so one surface can
|
||||||
|
// move to HTTP without dragging the other with it.
|
||||||
|
TECH_LOG_PUBLIC_SOURCE: z.enum(["MOCK", "HTTP"]).default("MOCK"),
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.superRefine(runtimeConfigArtifactInvariants);
|
.superRefine(runtimeConfigArtifactInvariants);
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
import { REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION } from "./reference-feature/contracts/reference-feature-contract-contribution.ts";
|
import { REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION } from "./reference-feature/contracts/reference-feature-contract-contribution.ts";
|
||||||
import { REFERENCE_FEATURE_ID } from "./reference-feature/contracts/reference-feature-contract.ts";
|
import { REFERENCE_FEATURE_ID } from "./reference-feature/contracts/reference-feature-contract.ts";
|
||||||
import { INSTALLED_PRODUCT_FEATURE_IDS } from "./installed-product-manifest.ts";
|
import { INSTALLED_PRODUCT_FEATURE_IDS } from "./installed-product-manifest.ts";
|
||||||
|
import { TECH_LOG_MANAGEMENT_CONTRIBUTION } from "./tech-log/contracts/tech-log-management-contract-contribution.ts";
|
||||||
|
import { TECH_LOG_PUBLIC_CONTRIBUTION } from "./tech-log/contracts/tech-log-public-contract-contribution.ts";
|
||||||
import { TECH_LOG_STUDIO_CONTRIBUTION } from "./tech-log/contracts/tech-log-studio-contract-contribution.ts";
|
import { TECH_LOG_STUDIO_CONTRIBUTION } from "./tech-log/contracts/tech-log-studio-contract-contribution.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -18,8 +20,17 @@ import { TECH_LOG_STUDIO_CONTRIBUTION } from "./tech-log/contracts/tech-log-stud
|
|||||||
export const INSTALLED_CONTRACT_CONTRIBUTIONS: readonly InstalledContractContribution[] =
|
export const INSTALLED_CONTRACT_CONTRIBUTIONS: readonly InstalledContractContribution[] =
|
||||||
Object.freeze(
|
Object.freeze(
|
||||||
INSTALLED_PRODUCT_FEATURE_IDS.includes(REFERENCE_FEATURE_ID)
|
INSTALLED_PRODUCT_FEATURE_IDS.includes(REFERENCE_FEATURE_ID)
|
||||||
? [REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION, TECH_LOG_STUDIO_CONTRIBUTION]
|
? [
|
||||||
: [TECH_LOG_STUDIO_CONTRIBUTION],
|
REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION,
|
||||||
|
TECH_LOG_STUDIO_CONTRIBUTION,
|
||||||
|
TECH_LOG_PUBLIC_CONTRIBUTION,
|
||||||
|
TECH_LOG_MANAGEMENT_CONTRIBUTION,
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
TECH_LOG_STUDIO_CONTRIBUTION,
|
||||||
|
TECH_LOG_PUBLIC_CONTRIBUTION,
|
||||||
|
TECH_LOG_MANAGEMENT_CONTRIBUTION,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
export const COMPOSED_CONTRACT_CONTRIBUTIONS = composeContractContributions(
|
export const COMPOSED_CONTRACT_CONTRIBUTIONS = composeContractContributions(
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export function createInstalledFeatureInputs(
|
|||||||
context: Parameters<typeof createReferenceFeatureInstalledInput>[0] &
|
context: Parameters<typeof createReferenceFeatureInstalledInput>[0] &
|
||||||
Readonly<{
|
Readonly<{
|
||||||
studioSource: "MOCK" | "HTTP";
|
studioSource: "MOCK" | "HTTP";
|
||||||
|
publicSource: "MOCK" | "HTTP";
|
||||||
apiBaseUrl: string;
|
apiBaseUrl: string;
|
||||||
requestTimeoutMs: number;
|
requestTimeoutMs: number;
|
||||||
csrf: CsrfTokenProvider;
|
csrf: CsrfTokenProvider;
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import {
|
|||||||
import type { CsrfTokenProvider } from "./http/studio-session-csrf.ts";
|
import type { CsrfTokenProvider } from "./http/studio-session-csrf.ts";
|
||||||
import { createMockStudioAssetGateway } from "./mock/mock-studio-asset-gateway.ts";
|
import { createMockStudioAssetGateway } from "./mock/mock-studio-asset-gateway.ts";
|
||||||
import { createMockStudioGateway } from "./mock/mock-studio-gateway.ts";
|
import { createMockStudioGateway } from "./mock/mock-studio-gateway.ts";
|
||||||
|
import { createHttpManagementGateway } from "./http/http-management-gateway.ts";
|
||||||
|
import { createHttpPublicContentGateway } from "./http/http-public-content-gateway.ts";
|
||||||
import { publicContentQueries } from "./static/public-query.ts";
|
import { publicContentQueries } from "./static/public-query.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -27,6 +29,7 @@ import { publicContentQueries } from "./static/public-query.ts";
|
|||||||
*/
|
*/
|
||||||
export type TechLogInstallContext = Readonly<{
|
export type TechLogInstallContext = Readonly<{
|
||||||
studioSource: "MOCK" | "HTTP";
|
studioSource: "MOCK" | "HTTP";
|
||||||
|
publicSource: "MOCK" | "HTTP";
|
||||||
contractOperations: StudioOperationExecutor;
|
contractOperations: StudioOperationExecutor;
|
||||||
apiBaseUrl: string;
|
apiBaseUrl: string;
|
||||||
requestTimeoutMs: number;
|
requestTimeoutMs: number;
|
||||||
@@ -66,8 +69,20 @@ export function createTechLogFeatureInstalledInput(
|
|||||||
? createMockStudioGateway({ assets: mockAssets })
|
? createMockStudioGateway({ assets: mockAssets })
|
||||||
: createHttpStudioGateway({ operations: context.contractOperations });
|
: createHttpStudioGateway({ operations: context.contractOperations });
|
||||||
|
|
||||||
|
// The public read source switches independently of Studio: the two are
|
||||||
|
// different services, and the combination that matters today is an authoring
|
||||||
|
// backend that is live while the public read API is not.
|
||||||
|
const publicContent =
|
||||||
|
context.publicSource === "MOCK"
|
||||||
|
? publicContentQueries
|
||||||
|
: createHttpPublicContentGateway({ operations: context.contractOperations });
|
||||||
|
|
||||||
|
const createManagementGateway = () =>
|
||||||
|
createHttpManagementGateway({ operations: context.contractOperations });
|
||||||
|
|
||||||
const input: TechLogFeatureInput = Object.freeze({
|
const input: TechLogFeatureInput = Object.freeze({
|
||||||
publicContent: publicContentQueries,
|
publicContent,
|
||||||
|
createManagementGateway,
|
||||||
createStudioGateway,
|
createStudioGateway,
|
||||||
createStudioAssetGateway,
|
createStudioAssetGateway,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ export function createAssetUploadTransport(
|
|||||||
return parsed.data;
|
return parsed.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
let problemBody: unknown = null;
|
let problemBody: unknown;
|
||||||
try {
|
try {
|
||||||
problemBody = await response.json();
|
problemBody = await response.json();
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import type { SessionState } from "../../../../application/ports/auth-session-port.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The host-installed session owner for the BFF deployment.
|
||||||
|
*
|
||||||
|
* `AUTH_MODE: "external"` means the page hosting this bundle owns the session
|
||||||
|
* and publishes it on `window.__CA_FRONTEND_AUTH_OWNER__`; with no owner
|
||||||
|
* present the runtime falls back to `createUnavailableSessionAdapter`, which is
|
||||||
|
* why a signed-in browser still saw "로그인 연동이 필요합니다". Tech Log's host
|
||||||
|
* *is* its backend: the session lives in an httpOnly `TECHLOG_SESSION` cookie
|
||||||
|
* the SPA cannot read, so the only way to observe it is to ask the backend.
|
||||||
|
*
|
||||||
|
* That is what this owner does. It is not a second authentication mechanism —
|
||||||
|
* `getStudioSession` is already the contract's bootstrap operation (the one
|
||||||
|
* that issues the CSRF token), so reading session state from it adds no
|
||||||
|
* round-trip the Studio would not make anyway.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const SESSION_PATH = "api/v1/studio/session";
|
||||||
|
const SIGN_IN_PATH = "oauth2/authorization/keycloak";
|
||||||
|
const SIGN_OUT_PATH = "logout";
|
||||||
|
|
||||||
|
type Listener = () => void;
|
||||||
|
|
||||||
|
function endpoint(apiBaseUrl: string, path: string): string {
|
||||||
|
return new URL(path, apiBaseUrl).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts at `recovery-pending` rather than `unauthenticated`. The state cannot
|
||||||
|
* be known synchronously and the router already models exactly this: a
|
||||||
|
* session-required route in that state renders the recovering surface and calls
|
||||||
|
* `recoverSession()`, which is where the probe belongs. Starting at
|
||||||
|
* `unauthenticated` would flash a sign-in prompt at a signed-in user on every
|
||||||
|
* reload.
|
||||||
|
*/
|
||||||
|
export function createBffSessionOwner(
|
||||||
|
apiBaseUrl: string,
|
||||||
|
fetcher: typeof fetch = fetch,
|
||||||
|
) {
|
||||||
|
let state: SessionState = "recovery-pending";
|
||||||
|
// The session response carries the CSRF token; `/logout` is a mutation and the
|
||||||
|
// backend rejects it without one. Kept here so sign-out does not need a second
|
||||||
|
// round-trip on the happy path.
|
||||||
|
let csrf: Readonly<{ token: string; header: string }> | null = null;
|
||||||
|
const listeners = new Set<Listener>();
|
||||||
|
|
||||||
|
const publish = (next: SessionState) => {
|
||||||
|
if (state === next) return;
|
||||||
|
state = next;
|
||||||
|
for (const listener of listeners) listener();
|
||||||
|
};
|
||||||
|
|
||||||
|
async function probe(): Promise<"restored" | "no-session"> {
|
||||||
|
try {
|
||||||
|
const response = await fetcher(endpoint(apiBaseUrl, SESSION_PATH), {
|
||||||
|
method: "GET",
|
||||||
|
credentials: "include",
|
||||||
|
headers: { accept: "application/json" },
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
csrf = await readCsrf(response);
|
||||||
|
publish("authenticated");
|
||||||
|
return "restored";
|
||||||
|
}
|
||||||
|
csrf = null;
|
||||||
|
// 401/403 are answers, not faults: the caller simply has no session.
|
||||||
|
if (response.status === 401 || response.status === 403) {
|
||||||
|
publish("unauthenticated");
|
||||||
|
return "no-session";
|
||||||
|
}
|
||||||
|
// 5xx means the backend could not say. Claiming "signed out" would send
|
||||||
|
// the user through a login they do not need, so report the integration
|
||||||
|
// as unavailable and let the shell surface that instead.
|
||||||
|
publish("integration-failed");
|
||||||
|
return "no-session";
|
||||||
|
} catch {
|
||||||
|
publish("integration-failed");
|
||||||
|
return "no-session";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Total: a session that parses is still a session. A body we cannot read only
|
||||||
|
* costs sign-out its cached token, and `signOut` re-probes for one.
|
||||||
|
*/
|
||||||
|
async function readCsrf(
|
||||||
|
response: Response,
|
||||||
|
): Promise<Readonly<{ token: string; header: string }> | null> {
|
||||||
|
try {
|
||||||
|
const body = (await response.clone().json()) as {
|
||||||
|
data?: { csrfToken?: unknown; csrfHeaderName?: unknown };
|
||||||
|
};
|
||||||
|
const token = body?.data?.csrfToken;
|
||||||
|
const header = body?.data?.csrfHeaderName;
|
||||||
|
return typeof token === "string" && typeof header === "string"
|
||||||
|
? Object.freeze({ token, header })
|
||||||
|
: null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Probe immediately instead of waiting for the router's recovery button. The
|
||||||
|
// session is knowable without asking the user to do anything, and the button
|
||||||
|
// exists for owners that genuinely need a user gesture (a popup-based flow,
|
||||||
|
// say). Subscribers are notified when this settles, so a route that mounted
|
||||||
|
// during `recovery-pending` re-renders on its own. `recoverSession` remains
|
||||||
|
// wired for the manual path and for a retry after `integration-failed`.
|
||||||
|
void probe();
|
||||||
|
|
||||||
|
return Object.freeze({
|
||||||
|
readState: () => state,
|
||||||
|
subscribe(listener: Listener) {
|
||||||
|
listeners.add(listener);
|
||||||
|
return () => listeners.delete(listener);
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* A full-page navigation, not a fetch: the authorization-code flow is a
|
||||||
|
* browser redirect chain through the identity provider, and an XHR cannot
|
||||||
|
* follow it. The backend sends the browser back to the SPA once the session
|
||||||
|
* cookie is set.
|
||||||
|
*/
|
||||||
|
async beginSignIn(): Promise<void> {
|
||||||
|
globalThis.location.assign(endpoint(apiBaseUrl, SIGN_IN_PATH));
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Only reports signed-out when the backend actually ended the session.
|
||||||
|
*
|
||||||
|
* The first version published `unauthenticated` in a `finally`, which read
|
||||||
|
* as defensive but was the opposite: `/logout` is a mutation and answered
|
||||||
|
* 403 without the CSRF header, so the cookie survived while the UI claimed
|
||||||
|
* the user was out — the exact failure someone on a shared machine would
|
||||||
|
* never think to check. A sign-out that did not happen has to look like a
|
||||||
|
* sign-out that did not happen.
|
||||||
|
*/
|
||||||
|
async signOut(): Promise<void> {
|
||||||
|
if (csrf === null) {
|
||||||
|
// No cached token (never probed, or the probe body was unreadable).
|
||||||
|
// Ask again rather than sending a request that is certain to 403.
|
||||||
|
await probe();
|
||||||
|
}
|
||||||
|
const headers: Record<string, string> = csrf
|
||||||
|
? { [csrf.header]: csrf.token }
|
||||||
|
: {};
|
||||||
|
const response = await fetcher(endpoint(apiBaseUrl, SIGN_OUT_PATH), {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`sign-out failed with status ${response.status}; the session is still active`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
csrf = null;
|
||||||
|
publish("unauthenticated");
|
||||||
|
},
|
||||||
|
/** Cookies travel on their own; the CSRF header comes from its own collaborator. */
|
||||||
|
async attachCredential() {
|
||||||
|
return Object.freeze({ headers: Object.freeze({}) });
|
||||||
|
},
|
||||||
|
recoverSession: probe,
|
||||||
|
notifyUnauthenticated() {
|
||||||
|
publish("unauthenticated");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publishes the owner on the host global the runtime reads. Called before
|
||||||
|
* `createRuntimeAdapters`, which resolves the owner once and keeps it.
|
||||||
|
*/
|
||||||
|
export function installBffSessionOwner(
|
||||||
|
host: Record<string, unknown>,
|
||||||
|
apiBaseUrl: string,
|
||||||
|
fetcher: typeof fetch = fetch,
|
||||||
|
): void {
|
||||||
|
host["__CA_FRONTEND_AUTH_OWNER__"] = createBffSessionOwner(apiBaseUrl, fetcher);
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import type {
|
||||||
|
CreateDraftResponse,
|
||||||
|
ProjectEditResponse,
|
||||||
|
ProjectIndexPage,
|
||||||
|
ProjectUpdateRequest,
|
||||||
|
PublishResponse,
|
||||||
|
ReleaseEditResponse,
|
||||||
|
ReleaseIndexPage,
|
||||||
|
ReleaseUpdateRequest,
|
||||||
|
TopicEdit,
|
||||||
|
} from "../../contracts/management/contract.ts";
|
||||||
|
import type { ManagementGateway } from "../../application/ports/management-gateway.ts";
|
||||||
|
import type { StudioOperationExecutor } from "./http-studio-gateway.ts";
|
||||||
|
|
||||||
|
export type { ManagementGateway };
|
||||||
|
|
||||||
|
const ROUTE_ID = "TECH_LOG_STUDIO";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 주제·프로젝트 관리 게이트웨이.
|
||||||
|
*
|
||||||
|
* <p>Studio 게이트웨이와 같은 실패 규약을 쓴다 — 실패는 던지고, 화면은 `usePublicContent` 가 아니라
|
||||||
|
* Studio 쪽 상태 처리를 그대로 쓴다. 여기서 Result 로 감싸면 이 표면만 다른 규약이 된다.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class ManagementGatewayError extends Error {
|
||||||
|
readonly operationId: string;
|
||||||
|
readonly code: string;
|
||||||
|
|
||||||
|
constructor(operationId: string, code: string) {
|
||||||
|
super(`${operationId}: ${code}`);
|
||||||
|
this.name = "ManagementGatewayError";
|
||||||
|
this.operationId = operationId;
|
||||||
|
this.code = code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createHttpManagementGateway(
|
||||||
|
deps: Readonly<{ operations: StudioOperationExecutor }>,
|
||||||
|
): ManagementGateway {
|
||||||
|
async function run<T>(operationId: string, input: unknown): Promise<T> {
|
||||||
|
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 management surface answers with the ADR-006 envelope, which nests
|
||||||
|
// the code under `error` — reading `problem.code` found nothing and every
|
||||||
|
// failure surfaced as the literal "PROBLEM", matching no i18n key.
|
||||||
|
const body = outcome.problem as
|
||||||
|
| Readonly<{ code?: unknown; error?: Readonly<{ code?: unknown }> }>
|
||||||
|
| null;
|
||||||
|
const code =
|
||||||
|
typeof body?.code === "string"
|
||||||
|
? body.code
|
||||||
|
: typeof body?.error?.code === "string"
|
||||||
|
? body.error.code
|
||||||
|
: "PROBLEM";
|
||||||
|
throw new ManagementGatewayError(operationId, code);
|
||||||
|
}
|
||||||
|
throw new ManagementGatewayError(operationId, outcome.kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.freeze({
|
||||||
|
listTopics: () => run<TopicEdit[]>("listStudioTopics", {}),
|
||||||
|
createTopic: (input: TopicEdit) => run<TopicEdit>("createTopic", input),
|
||||||
|
updateTopic: (id: string, body: TopicEdit) => run<TopicEdit>("updateTopic", { id, body }),
|
||||||
|
deleteTopic: async (id: string, expectedVersion: number) => {
|
||||||
|
await run<void>("deleteTopic", { id, expectedVersion });
|
||||||
|
},
|
||||||
|
listProjects: (page?: number, size?: number) =>
|
||||||
|
run<ProjectIndexPage>("listStudioProjects", { ...(page !== undefined ? { page } : {}), ...(size !== undefined ? { size } : {}) }),
|
||||||
|
getProject: (id: string) => run<ProjectEditResponse>("getProjectForEdit", { id }),
|
||||||
|
createProject: (title: string) => run<CreateDraftResponse>("createProject", { title }),
|
||||||
|
updateProject: (id: string, body: ProjectUpdateRequest) =>
|
||||||
|
run<ProjectEditResponse>("updateProject", { id, body }),
|
||||||
|
listReleases: (page?: number, size?: number) =>
|
||||||
|
run<ReleaseIndexPage>("listStudioReleases", { ...(page !== undefined ? { page } : {}), ...(size !== undefined ? { size } : {}) }),
|
||||||
|
getRelease: (id: string) => run<ReleaseEditResponse>("getReleaseForEdit", { id }),
|
||||||
|
createRelease: (title: string) => run<CreateDraftResponse>("createRelease", { title }),
|
||||||
|
updateRelease: (id: string, body: ReleaseUpdateRequest) =>
|
||||||
|
run<ReleaseEditResponse>("updateRelease", { id, body }),
|
||||||
|
deleteRelease: async (id: string, expectedVersion: number) => {
|
||||||
|
await run<void>("deleteRelease", { id, expectedVersion });
|
||||||
|
},
|
||||||
|
publishRelease: (id: string, expectedVersion: number) =>
|
||||||
|
run<PublishResponse>("publishRelease", { id, expectedVersion }),
|
||||||
|
archiveRelease: (id: string, expectedVersion: number) =>
|
||||||
|
run<ReleaseEditResponse>("archiveRelease", { id, expectedVersion }),
|
||||||
|
deleteDocument: async (
|
||||||
|
kind: "CASE" | "REFERENCE" | "QUESTION",
|
||||||
|
id: string,
|
||||||
|
expectedVersion: number,
|
||||||
|
) => {
|
||||||
|
const operationId =
|
||||||
|
kind === "CASE"
|
||||||
|
? "deleteCaseDraft"
|
||||||
|
: kind === "REFERENCE"
|
||||||
|
? "deleteReferenceDraft"
|
||||||
|
: "deleteQuestion";
|
||||||
|
await run<void>(operationId, { id, expectedVersion });
|
||||||
|
},
|
||||||
|
deleteDecision: async (projectId: string, decisionId: string, expectedVersion: number) => {
|
||||||
|
await run<void>("deleteProjectDecision", { id: projectId, decisionId, expectedVersion });
|
||||||
|
},
|
||||||
|
deleteProject: async (id: string, expectedVersion: number) => {
|
||||||
|
await run<void>("deleteProject", { id, expectedVersion });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,453 @@
|
|||||||
|
import type {
|
||||||
|
HomeFocusItem,
|
||||||
|
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,
|
||||||
|
markdownLines,
|
||||||
|
markdownSections,
|
||||||
|
questionListItemToRecord,
|
||||||
|
releaseDetailToRelease,
|
||||||
|
searchItemToEntity,
|
||||||
|
} from "./public-content-mapping.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.problemSummary 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) ?? "",
|
||||||
|
environment: ((body.environmentSummary as readonly string[]) ?? []).join(", "),
|
||||||
|
// The Case document renders a verification line. The contract has no
|
||||||
|
// field for it — verification lives in the body — so it stays empty
|
||||||
|
// rather than being guessed from a heading.
|
||||||
|
verification: "",
|
||||||
|
lastVerifiedLabel: dateLabel(body.lastVerifiedAt as string),
|
||||||
|
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.purposeSummary 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",
|
||||||
|
purpose: (body.purposeSummary as string) ?? "",
|
||||||
|
rules: Object.freeze(
|
||||||
|
markdownSections(body.content as string).map((section) => ({
|
||||||
|
title: section.title,
|
||||||
|
body: section.paragraphs.join("\n"),
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
applyWhen: Object.freeze(markdownLines(body.applyWhenMarkdown as string)),
|
||||||
|
exceptions: Object.freeze(markdownLines(body.exceptionsMarkdown as string)),
|
||||||
|
examples: Object.freeze(markdownLines(body.examplesMarkdown as string)),
|
||||||
|
verifiedAt: dateLabel(body.lastVerifiedAt as string),
|
||||||
|
}) as unknown as Extract<PublicRecord, { kind: K }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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) ?? []),
|
||||||
|
);
|
||||||
|
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,
|
||||||
|
relations: flattenRelations(groups, {
|
||||||
|
derivedCases: "이 질문에서 나온 기록",
|
||||||
|
projectDecisions: "이 질문이 이끈 결정",
|
||||||
|
relatedQuestions: "관련 질문",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
kind: "QUESTION",
|
||||||
|
questionStatus: (body.status as QuestionRecord["questionStatus"]) ?? "OPEN",
|
||||||
|
facts: pointsOf("KNOWN_FACT"),
|
||||||
|
assumptions: pointsOf("ASSUMPTION"),
|
||||||
|
unknowns: pointsOf("UNRESOLVED"),
|
||||||
|
constraints: pointsOf("CONSTRAINT"),
|
||||||
|
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),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
searchPublicContent,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
import type {
|
||||||
|
CaseRecord,
|
||||||
|
ProjectActivity,
|
||||||
|
ProjectDecision,
|
||||||
|
PublicRecord,
|
||||||
|
QuestionRecord,
|
||||||
|
RecordSection,
|
||||||
|
ReferenceRecord,
|
||||||
|
Release,
|
||||||
|
SearchablePublicEntity,
|
||||||
|
} from "../../application/ports/public-content-queries.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The contract and the screens disagree about shape, on purpose.
|
||||||
|
*
|
||||||
|
* The contract speaks in what the server stores — timestamps, one markdown body,
|
||||||
|
* relations grouped by their kind. The screens were built against a catalog that
|
||||||
|
* spoke in what a page renders — formatted labels, sections, one flat relation
|
||||||
|
* list. Neither is wrong, and translating here rather than at either end is what
|
||||||
|
* keeps the presentation components untouched by this migration.
|
||||||
|
*
|
||||||
|
* Where the contract has no counterpart the value is empty rather than invented,
|
||||||
|
* and the gap is named at the call site.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const DATE_LABEL = new Intl.DateTimeFormat("ko-KR", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
timeZone: "UTC",
|
||||||
|
});
|
||||||
|
|
||||||
|
/** `2026. 08. 20.` → `2026.08.20`, the form the fixture used. */
|
||||||
|
export function dateLabel(value: string | null | undefined): string {
|
||||||
|
if (!value) return "";
|
||||||
|
const parsed = new Date(value);
|
||||||
|
if (Number.isNaN(parsed.getTime())) return "";
|
||||||
|
return DATE_LABEL.format(parsed).replaceAll(" ", "").replace(/\.$/u, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isoDate(value: string | null | undefined): string {
|
||||||
|
if (!value) return "";
|
||||||
|
const parsed = new Date(value);
|
||||||
|
return Number.isNaN(parsed.getTime()) ? "" : parsed.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The contract carries one markdown body; the document components render an
|
||||||
|
* ordered list of titled sections. Splitting on `##` reproduces that structure
|
||||||
|
* without a full renderer: everything before the first heading is the lead, and
|
||||||
|
* each heading opens a section whose bullets are its `-`/`*` lines.
|
||||||
|
*
|
||||||
|
* This is deliberately not the Studio parser (`parseCaseContent`). That one
|
||||||
|
* produces the canonical render-block union the editor needs — inline marks,
|
||||||
|
* evidence directives, tables — which is a richer tree than `RecordSection` can
|
||||||
|
* hold. Reusing it would mean flattening its output back down to this shape, and
|
||||||
|
* flattening loses exactly the blocks that made it worth using.
|
||||||
|
*/
|
||||||
|
export function markdownSections(body: string | null | undefined): RecordSection[] {
|
||||||
|
if (!body) return [];
|
||||||
|
const sections: RecordSection[] = [];
|
||||||
|
let current: { id: string; title: string; paragraphs: string[]; bullets: string[] } | null = null;
|
||||||
|
const flush = () => {
|
||||||
|
if (!current) return;
|
||||||
|
sections.push(
|
||||||
|
Object.freeze({
|
||||||
|
id: current.id,
|
||||||
|
title: current.title,
|
||||||
|
paragraphs: Object.freeze([...current.paragraphs]),
|
||||||
|
...(current.bullets.length > 0 ? { bullets: Object.freeze([...current.bullets]) } : {}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
for (const rawLine of body.split(/\r?\n/u)) {
|
||||||
|
const line = rawLine.trim();
|
||||||
|
const heading = /^#{2,3}\s+(.*)$/u.exec(line);
|
||||||
|
if (heading) {
|
||||||
|
flush();
|
||||||
|
const title = heading[1]!.trim();
|
||||||
|
current = { id: slugOf(title, sections.length), title, paragraphs: [], bullets: [] };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!current) {
|
||||||
|
if (line.length === 0) continue;
|
||||||
|
current = { id: "lead", title: "", paragraphs: [], bullets: [] };
|
||||||
|
}
|
||||||
|
if (line.length === 0) continue;
|
||||||
|
const bullet = /^[-*]\s+(.*)$/u.exec(line);
|
||||||
|
if (bullet) current.bullets.push(bullet[1]!.trim());
|
||||||
|
else current.paragraphs.push(line);
|
||||||
|
}
|
||||||
|
flush();
|
||||||
|
return sections;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Markdown that is really a list — the release document's four bodies are. */
|
||||||
|
export function markdownLines(body: string | null | undefined): string[] {
|
||||||
|
if (!body) return [];
|
||||||
|
return body
|
||||||
|
.split(/\r?\n/u)
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter((line) => line.length > 0)
|
||||||
|
.map((line) => line.replace(/^[-*]\s+/u, ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
function slugOf(title: string, index: number): string {
|
||||||
|
const normalized = title
|
||||||
|
.toLocaleLowerCase("ko-KR")
|
||||||
|
.replace(/[^\p{Letter}\p{Number}]+/gu, "-")
|
||||||
|
.replace(/^-+|-+$/gu, "");
|
||||||
|
return normalized.length > 0 ? normalized : `section-${index + 1}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Related = Readonly<{ type?: string; title?: string; summary?: string; path?: string }>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The contract groups relations by why they relate (origin question, derived
|
||||||
|
* references, related cases); the renderer takes one list where the reason is a
|
||||||
|
* label. Flattening keeps the group name as that label.
|
||||||
|
*/
|
||||||
|
export function flattenRelations(
|
||||||
|
groups: Readonly<Record<string, Related | readonly Related[] | undefined>>,
|
||||||
|
labels: Readonly<Record<string, string>>,
|
||||||
|
): ReadonlyArray<{ reason: string; title: string; path: string }> {
|
||||||
|
const flat: { reason: string; title: string; path: string }[] = [];
|
||||||
|
for (const [group, value] of Object.entries(groups)) {
|
||||||
|
if (!value) continue;
|
||||||
|
const reason = labels[group] ?? group;
|
||||||
|
for (const entry of Array.isArray(value) ? value : [value as Related]) {
|
||||||
|
if (!entry?.path || !entry.title) continue;
|
||||||
|
flat.push({ reason, title: entry.title, path: entry.path });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Object.freeze(flat);
|
||||||
|
}
|
||||||
|
|
||||||
|
type Summary = Readonly<{ name?: string; slug?: string; path?: string }> | undefined;
|
||||||
|
|
||||||
|
export function baseOf(
|
||||||
|
kind: PublicRecord["kind"],
|
||||||
|
slug: string,
|
||||||
|
fields: Readonly<{
|
||||||
|
title?: string;
|
||||||
|
summary?: string;
|
||||||
|
path?: string;
|
||||||
|
primaryTopic?: Summary;
|
||||||
|
primaryProject?: Summary;
|
||||||
|
publishedAt?: string | null;
|
||||||
|
relations?: ReadonlyArray<{ reason: string; title: string; path: string }>;
|
||||||
|
}>,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
kind,
|
||||||
|
slug,
|
||||||
|
title: fields.title ?? "",
|
||||||
|
summary: fields.summary ?? "",
|
||||||
|
path: fields.path ?? "",
|
||||||
|
topic: fields.primaryTopic?.name ?? "",
|
||||||
|
topicSlug: fields.primaryTopic?.slug ?? "",
|
||||||
|
projectSlug: fields.primaryProject?.slug ?? "",
|
||||||
|
projectTitle: fields.primaryProject?.name ?? "",
|
||||||
|
publishedAt: isoDate(fields.publishedAt),
|
||||||
|
publishedLabel: dateLabel(fields.publishedAt),
|
||||||
|
visibility: "PUBLIC" as const,
|
||||||
|
relations: fields.relations ?? Object.freeze([]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A list endpoint answers with what a list row needs, not with a whole document.
|
||||||
|
* The port's type is the full record, so the detail fields are filled empty here
|
||||||
|
* and the detail screens fetch by slug. That is the same trip the fixture made
|
||||||
|
* for free; it is a real extra request now, and the alternative — widening the
|
||||||
|
* list response — would send every body to render a title.
|
||||||
|
*/
|
||||||
|
export function knowledgeListItemToRecord(item: Readonly<Record<string, unknown>>): PublicRecord | null {
|
||||||
|
const type = String(item.type ?? "");
|
||||||
|
const path = String(item.path ?? "");
|
||||||
|
const slug = path.split("/").filter(Boolean).pop() ?? "";
|
||||||
|
const base = baseOf(type === "REFERENCE" ? "REFERENCE" : "CASE", slug, {
|
||||||
|
title: item.title as string,
|
||||||
|
summary: (item.primarySummary as string) ?? "",
|
||||||
|
path,
|
||||||
|
primaryTopic: item.primaryTopic as Summary,
|
||||||
|
primaryProject: item.primaryProject as Summary,
|
||||||
|
publishedAt: item.publishedAt as string,
|
||||||
|
});
|
||||||
|
if (type === "CASE") {
|
||||||
|
return Object.freeze({
|
||||||
|
...base,
|
||||||
|
kind: "CASE",
|
||||||
|
problem: (item.primarySummary as string) ?? "",
|
||||||
|
conclusion: (item.secondarySummary as string) ?? "",
|
||||||
|
environment: "",
|
||||||
|
verification: "",
|
||||||
|
lastVerifiedLabel: dateLabel(item.lastVerifiedAt as string),
|
||||||
|
sections: Object.freeze([]),
|
||||||
|
}) as CaseRecord;
|
||||||
|
}
|
||||||
|
if (type === "REFERENCE") {
|
||||||
|
return Object.freeze({
|
||||||
|
...base,
|
||||||
|
kind: "REFERENCE",
|
||||||
|
purpose: (item.primarySummary as string) ?? "",
|
||||||
|
rules: Object.freeze([]),
|
||||||
|
applyWhen: Object.freeze([]),
|
||||||
|
exceptions: Object.freeze([]),
|
||||||
|
examples: Object.freeze([]),
|
||||||
|
verifiedAt: dateLabel(item.lastVerifiedAt as string),
|
||||||
|
}) as ReferenceRecord;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function questionListItemToRecord(item: Readonly<Record<string, unknown>>): QuestionRecord {
|
||||||
|
const path = String(item.path ?? "");
|
||||||
|
const slug = path.split("/").filter(Boolean).pop() ?? "";
|
||||||
|
return Object.freeze({
|
||||||
|
...baseOf("QUESTION", slug, {
|
||||||
|
title: item.question as string,
|
||||||
|
summary: (item.summary as string) ?? "",
|
||||||
|
path,
|
||||||
|
primaryProject: item.primaryProject as Summary,
|
||||||
|
publishedAt: item.updatedAt as string,
|
||||||
|
}),
|
||||||
|
kind: "QUESTION",
|
||||||
|
questionStatus: (item.status as QuestionRecord["questionStatus"]) ?? "OPEN",
|
||||||
|
facts: Object.freeze([]),
|
||||||
|
assumptions: Object.freeze([]),
|
||||||
|
unknowns: Object.freeze([]),
|
||||||
|
constraints: Object.freeze([]),
|
||||||
|
options: Object.freeze([]),
|
||||||
|
nextValidation: (item.nextVerification as string) ?? "",
|
||||||
|
}) as QuestionRecord;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decisionItemToDecision(item: Readonly<Record<string, unknown>>): ProjectDecision {
|
||||||
|
const sources = [item.sourceQuestion, item.sourceCase]
|
||||||
|
.filter((entry): entry is Related => Boolean(entry))
|
||||||
|
.map((entry) => ({ title: entry.title ?? "", path: entry.path ?? "" }));
|
||||||
|
return Object.freeze({
|
||||||
|
id: String(item.id ?? ""),
|
||||||
|
status: (item.status as ProjectDecision["status"]) ?? "PROPOSED",
|
||||||
|
date: dateLabel(item.decidedAt as string),
|
||||||
|
title: String(item.statement ?? ""),
|
||||||
|
statement: String(item.statement ?? ""),
|
||||||
|
rationale: String(item.rationaleSummary ?? ""),
|
||||||
|
// The list response carries a rationale summary, not the consequence list the
|
||||||
|
// decision screen renders; the contract has no field for it here.
|
||||||
|
consequences: Object.freeze([]),
|
||||||
|
evidence: Object.freeze(sources),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function activityItemToActivity(
|
||||||
|
item: Readonly<Record<string, unknown>>,
|
||||||
|
index: number,
|
||||||
|
): ProjectActivity {
|
||||||
|
const occurredAt = item.occurredAt as string;
|
||||||
|
const relatedPath = (item.relatedPath as string) ?? "";
|
||||||
|
return Object.freeze({
|
||||||
|
id: `activity-${index + 1}`,
|
||||||
|
date: dateLabel(occurredAt),
|
||||||
|
dateTime: isoDate(occurredAt),
|
||||||
|
type: (item.type as ProjectActivity["type"]) ?? "PROJECT UPDATE",
|
||||||
|
title: String(item.title ?? ""),
|
||||||
|
summary: String(item.summary ?? ""),
|
||||||
|
path: relatedPath,
|
||||||
|
...(relatedPath ? { recordPath: relatedPath } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function releaseDetailToRelease(
|
||||||
|
detail: Readonly<Record<string, unknown>>,
|
||||||
|
version: string,
|
||||||
|
): Release {
|
||||||
|
const related = (detail.relatedRecords as readonly Related[] | undefined) ?? [];
|
||||||
|
return Object.freeze({
|
||||||
|
version: String(detail.version ?? version),
|
||||||
|
path: `/releases/${String(detail.version ?? version)}`,
|
||||||
|
title: String(detail.title ?? ""),
|
||||||
|
summary: String(detail.summary ?? ""),
|
||||||
|
publishedAt: isoDate(detail.releasedOn as string),
|
||||||
|
publishedLabel: dateLabel(detail.releasedOn as string),
|
||||||
|
changes: Object.freeze(markdownLines(detail.changesMarkdown as string)),
|
||||||
|
reasons: Object.freeze(markdownLines(detail.reasonMarkdown as string)),
|
||||||
|
impacts: Object.freeze([
|
||||||
|
...markdownLines(detail.userImpactMarkdown as string),
|
||||||
|
...markdownLines(detail.implementationImpactMarkdown as string),
|
||||||
|
]),
|
||||||
|
related: Object.freeze(
|
||||||
|
related
|
||||||
|
.filter((entry) => entry.path && entry.title)
|
||||||
|
.map((entry) => ({ title: entry.title!, path: entry.path! })),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function searchItemToEntity(
|
||||||
|
item: Readonly<Record<string, unknown>>,
|
||||||
|
): SearchablePublicEntity {
|
||||||
|
const topic = (item.primaryTopic as Summary)?.name;
|
||||||
|
const project = (item.primaryProject as Summary)?.name;
|
||||||
|
return Object.freeze({
|
||||||
|
contentType: (item.contentType as SearchablePublicEntity["contentType"]) ?? "CASE",
|
||||||
|
title: String(item.title ?? ""),
|
||||||
|
summary: String(item.snippet ?? ""),
|
||||||
|
path: String(item.path ?? ""),
|
||||||
|
...(topic ? { topic } : {}),
|
||||||
|
...(project ? { project } : {}),
|
||||||
|
...(item.publishedAt ? { publishedAt: isoDate(item.publishedAt as string) } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -10,7 +10,10 @@ import {
|
|||||||
type Release,
|
type Release,
|
||||||
type HomeFocusItem,
|
type HomeFocusItem,
|
||||||
} from "./public-content.ts";
|
} from "./public-content.ts";
|
||||||
import type { PublicContentQueries } from "../../application/ports/public-content-queries.ts";
|
import type {
|
||||||
|
PublicContentQueries,
|
||||||
|
PublicTopic,
|
||||||
|
} from "../../application/ports/public-content-queries.ts";
|
||||||
|
|
||||||
export type RecordFilters = {
|
export type RecordFilters = {
|
||||||
kind?: RecordKind;
|
kind?: RecordKind;
|
||||||
@@ -98,6 +101,24 @@ export function getProjectActivity(projectSlug: string): ProjectActivity[] {
|
|||||||
return [...(getProject(projectSlug)?.activity ?? [])];
|
return [...(getProject(projectSlug)?.activity ?? [])];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 정적 카탈로그에는 주제 테이블이 없다 — 기록마다 붙은 주제 이름이 있을 뿐이다. 그것을 모아
|
||||||
|
* 세면 백엔드의 `listPublicTopics` 와 같은 모양이 되고, 이 어댑터의 목적(백엔드 없이 화면을
|
||||||
|
* 그린다)에도 맞는다.
|
||||||
|
*/
|
||||||
|
export function listTopics(): PublicTopic[] {
|
||||||
|
const counts = new Map<string, { name: string; slug: string; recordCount: number }>();
|
||||||
|
for (const record of listRecords()) {
|
||||||
|
if (!record.topic) continue;
|
||||||
|
const existing = counts.get(record.topicSlug);
|
||||||
|
if (existing) existing.recordCount += 1;
|
||||||
|
else counts.set(record.topicSlug, { name: record.topic, slug: record.topicSlug, recordCount: 1 });
|
||||||
|
}
|
||||||
|
return [...counts.values()]
|
||||||
|
.sort((left, right) => right.recordCount - left.recordCount || (left.name < right.name ? -1 : 1))
|
||||||
|
.map((entry) => Object.freeze(entry));
|
||||||
|
}
|
||||||
|
|
||||||
export function getHomeFocusItems(): HomeFocusItem[] {
|
export function getHomeFocusItems(): HomeFocusItem[] {
|
||||||
const project = getProject("backend-skeleton");
|
const project = getProject("backend-skeleton");
|
||||||
const question = getRecord("QUESTION", "validate-edge-token-again");
|
const question = getRecord("QUESTION", "validate-edge-token-again");
|
||||||
@@ -208,14 +229,45 @@ export function searchPublicContent(query: string): SearchablePublicEntity[] {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The MOCK source. The functions above stay synchronous — they filter arrays
|
||||||
|
* that are already in the bundle, and making them async would only add a
|
||||||
|
* microtask to every fixture test — so the port's async shape is applied here,
|
||||||
|
* at the adapter boundary, rather than pushed into the query implementations.
|
||||||
|
*
|
||||||
|
* `async` rather than `Promise.resolve(...)` so a throw from one of these
|
||||||
|
* becomes a rejected promise like the HTTP adapter's would, instead of
|
||||||
|
* escaping synchronously past the caller's await.
|
||||||
|
*/
|
||||||
export const publicContentQueries = Object.freeze({
|
export const publicContentQueries = Object.freeze({
|
||||||
listRecords,
|
async listRecords(filters?: RecordFilters) {
|
||||||
getRecord,
|
return listRecords(filters);
|
||||||
getProject,
|
},
|
||||||
getRelease,
|
async getRecord<K extends RecordKind>(kind: K, slug: string) {
|
||||||
getProjectRecords,
|
return getRecord(kind, slug);
|
||||||
getProjectDecisions,
|
},
|
||||||
getProjectActivity,
|
async getProject(slug: string) {
|
||||||
getHomeFocusItems,
|
return getProject(slug);
|
||||||
searchPublicContent,
|
},
|
||||||
|
async getRelease(version: string) {
|
||||||
|
return getRelease(version);
|
||||||
|
},
|
||||||
|
async getProjectRecords(projectSlug: string) {
|
||||||
|
return getProjectRecords(projectSlug);
|
||||||
|
},
|
||||||
|
async getProjectDecisions(projectSlug: string) {
|
||||||
|
return getProjectDecisions(projectSlug);
|
||||||
|
},
|
||||||
|
async getProjectActivity(projectSlug: string) {
|
||||||
|
return getProjectActivity(projectSlug);
|
||||||
|
},
|
||||||
|
async listTopics() {
|
||||||
|
return listTopics();
|
||||||
|
},
|
||||||
|
async getHomeFocusItems() {
|
||||||
|
return getHomeFocusItems();
|
||||||
|
},
|
||||||
|
async searchPublicContent(query: string) {
|
||||||
|
return searchPublicContent(query);
|
||||||
|
},
|
||||||
}) satisfies PublicContentQueries;
|
}) satisfies PublicContentQueries;
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import type {
|
||||||
|
CreateDraftResponse,
|
||||||
|
ProjectEditResponse,
|
||||||
|
ProjectIndexPage,
|
||||||
|
ProjectUpdateRequest,
|
||||||
|
PublishResponse,
|
||||||
|
ReleaseEditResponse,
|
||||||
|
ReleaseIndexPage,
|
||||||
|
ReleaseUpdateRequest,
|
||||||
|
TopicEdit,
|
||||||
|
} from "../../contracts/management/contract.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 주제·프로젝트·릴리즈·작업본 삭제 관리 표면.
|
||||||
|
*
|
||||||
|
* <p>Studio 게이트웨이와 같은 실패 규약이다 — 실패는 던지고, 화면이 잡는다. MOCK 대응물을 두지
|
||||||
|
* 않는 것도 의도다: 이 표면은 백엔드가 없으면 존재할 이유가 없고, 픽스처를 만들면 실제로는 만들
|
||||||
|
* 수 없는 주제를 화면이 보여주게 된다.
|
||||||
|
*/
|
||||||
|
export type ManagementGateway = Readonly<{
|
||||||
|
listTopics(): Promise<TopicEdit[]>;
|
||||||
|
createTopic(input: TopicEdit): Promise<TopicEdit>;
|
||||||
|
updateTopic(id: string, body: TopicEdit): Promise<TopicEdit>;
|
||||||
|
deleteTopic(id: string, expectedVersion: number): Promise<void>;
|
||||||
|
listProjects(page?: number, size?: number): Promise<ProjectIndexPage>;
|
||||||
|
getProject(id: string): Promise<ProjectEditResponse>;
|
||||||
|
createProject(title: string): Promise<CreateDraftResponse>;
|
||||||
|
updateProject(id: string, body: ProjectUpdateRequest): Promise<ProjectEditResponse>;
|
||||||
|
deleteProject(id: string, expectedVersion: number): Promise<void>;
|
||||||
|
listReleases(page?: number, size?: number): Promise<ReleaseIndexPage>;
|
||||||
|
getRelease(id: string): Promise<ReleaseEditResponse>;
|
||||||
|
createRelease(title: string): Promise<CreateDraftResponse>;
|
||||||
|
updateRelease(id: string, body: ReleaseUpdateRequest): Promise<ReleaseEditResponse>;
|
||||||
|
deleteRelease(id: string, expectedVersion: number): Promise<void>;
|
||||||
|
publishRelease(id: string, expectedVersion: number): Promise<PublishResponse>;
|
||||||
|
archiveRelease(id: string, expectedVersion: number): Promise<ReleaseEditResponse>;
|
||||||
|
/**
|
||||||
|
* 작업본 삭제. 종류마다 다른 endpoint 인 것은 계약의 모양이자 저장 구조다 — Case 와 Reference
|
||||||
|
* 는 한 테이블을 나눠 쓰고 Question 은 다른 테이블이다. Decision 은 계약에 삭제가 없다:
|
||||||
|
* 그쪽 수명주기는 수락·기각·대체이고, 그건 지우는 것이 아니라 무슨 일이 있었는지 남기는 것이다.
|
||||||
|
*/
|
||||||
|
deleteDocument(kind: "CASE" | "REFERENCE" | "QUESTION", id: string, expectedVersion: number): Promise<void>;
|
||||||
|
/**
|
||||||
|
* Decision 은 프로젝트에 속하므로 경로가 둘을 요구한다. 다른 종류처럼 한 번에 묶지 않는 이유는
|
||||||
|
* 계약이 그렇게 선언했고, 실제로도 프로젝트 밖의 Decision 은 존재하지 않기 때문이다.
|
||||||
|
*/
|
||||||
|
deleteDecision(projectId: string, decisionId: string, expectedVersion: number): Promise<void>;
|
||||||
|
}>;
|
||||||
@@ -132,6 +132,13 @@ export type Release = {
|
|||||||
related: ReadonlyArray<{ title: string; path: string }>;
|
related: ReadonlyArray<{ title: string; path: string }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 계약 `TopicSummary`. 목록에 필요한 만큼만 옮긴다. */
|
||||||
|
export type PublicTopic = {
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
recordCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type FocusKey = "current" | "question" | "decision";
|
export type FocusKey = "current" | "question" | "decision";
|
||||||
|
|
||||||
export type HomeFocusItem = {
|
export type HomeFocusItem = {
|
||||||
@@ -165,17 +172,36 @@ export type SearchablePublicEntity = {
|
|||||||
* The application-facing boundary for the immutable source Public catalog.
|
* The application-facing boundary for the immutable source Public catalog.
|
||||||
* Method signatures intentionally retain the source query argument and return shapes.
|
* Method signatures intentionally retain the source query argument and return shapes.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* The public read surface.
|
||||||
|
*
|
||||||
|
* Every method is async because one of the two adapters behind this port is a
|
||||||
|
* network client. The other reads a bundled fixture and could answer
|
||||||
|
* synchronously, but a port has one shape: if the fixture adapter kept the
|
||||||
|
* synchronous signature, the HTTP adapter could not implement the same port
|
||||||
|
* and callers written against the fixture would not compile against the
|
||||||
|
* network.
|
||||||
|
*
|
||||||
|
* Failures throw rather than resolving to a Result. That matches the Studio
|
||||||
|
* gateways, and it lets `useApplicationQuery` classify a rejection once at the
|
||||||
|
* boundary instead of every caller unwrapping.
|
||||||
|
*/
|
||||||
export type PublicContentQueries = Readonly<{
|
export type PublicContentQueries = Readonly<{
|
||||||
listRecords(filters?: RecordFilters): PublicRecord[];
|
listRecords(filters?: RecordFilters): Promise<PublicRecord[]>;
|
||||||
getRecord<K extends RecordKind>(
|
getRecord<K extends RecordKind>(
|
||||||
kind: K,
|
kind: K,
|
||||||
slug: string,
|
slug: string,
|
||||||
): Extract<PublicRecord, { kind: K }> | undefined;
|
): Promise<Extract<PublicRecord, { kind: K }> | undefined>;
|
||||||
getProject(slug: string): Project | undefined;
|
getProject(slug: string): Promise<Project | undefined>;
|
||||||
getRelease(version: string): Release | undefined;
|
getRelease(version: string): Promise<Release | undefined>;
|
||||||
getProjectRecords(projectSlug: string): PublicRecord[];
|
getProjectRecords(projectSlug: string): Promise<PublicRecord[]>;
|
||||||
getProjectDecisions(projectSlug: string): ProjectDecision[];
|
getProjectDecisions(projectSlug: string): Promise<ProjectDecision[]>;
|
||||||
getProjectActivity(projectSlug: string): ProjectActivity[];
|
getProjectActivity(projectSlug: string): Promise<ProjectActivity[]>;
|
||||||
getHomeFocusItems(): HomeFocusItem[];
|
/**
|
||||||
searchPublicContent(query: string): SearchablePublicEntity[];
|
* 공개된 주제 목록. 프로필의 "주요 관심 주제"가 이 값을 그린다 — 그 목록은 코드에 박혀
|
||||||
|
* 있었고, Studio 에서 주제를 만들어도 바뀌지 않았다.
|
||||||
|
*/
|
||||||
|
listTopics(): Promise<PublicTopic[]>;
|
||||||
|
getHomeFocusItems(): Promise<HomeFocusItem[]>;
|
||||||
|
searchPublicContent(query: string): Promise<SearchablePublicEntity[]>;
|
||||||
}>;
|
}>;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { PublicContentQueries } from "./ports/public-content-queries.ts";
|
import type { PublicContentQueries } from "./ports/public-content-queries.ts";
|
||||||
import type { StudioAssetGateway } from "./ports/studio-asset-gateway.ts";
|
import type { StudioAssetGateway } from "./ports/studio-asset-gateway.ts";
|
||||||
|
import type { ManagementGateway } from "./ports/management-gateway.ts";
|
||||||
import type { StudioGateway } from "./ports/studio-gateway.ts";
|
import type { StudioGateway } from "./ports/studio-gateway.ts";
|
||||||
|
|
||||||
export const TECH_LOG_FEATURE_ID = "tech-log" as const;
|
export const TECH_LOG_FEATURE_ID = "tech-log" as const;
|
||||||
@@ -8,6 +9,9 @@ export type TechLogFeatureInput = Readonly<{
|
|||||||
publicContent: PublicContentQueries;
|
publicContent: PublicContentQueries;
|
||||||
createStudioGateway(): StudioGateway;
|
createStudioGateway(): StudioGateway;
|
||||||
createStudioAssetGateway(): StudioAssetGateway;
|
createStudioAssetGateway(): StudioAssetGateway;
|
||||||
|
// 주제·프로젝트 관리. MOCK 대응물이 없다 — 이 표면은 백엔드가 없으면 존재할 이유가 없고,
|
||||||
|
// 픽스처를 만들면 실제로는 못 만드는 주제를 화면이 보여주게 된다.
|
||||||
|
createManagementGateway(): ManagementGateway;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
declare module "../../../application/ports/in/application-api.ts" {
|
declare module "../../../application/ports/in/application-api.ts" {
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
{
|
||||||
|
"packageId": "@tech-log/management-contract",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"digest": "sha256:d19ae7c4fbcac924a356bbb0cc1a46a4046ecec701158ca0a9b7cc089bbaf878",
|
||||||
|
"sourceRevision": "65a04fc",
|
||||||
|
"operationIds": [
|
||||||
|
"createCaseDraft",
|
||||||
|
"getCaseForEdit",
|
||||||
|
"updateCaseDraft",
|
||||||
|
"deleteCaseDraft",
|
||||||
|
"createReferenceDraft",
|
||||||
|
"getReferenceForEdit",
|
||||||
|
"updateReferenceDraft",
|
||||||
|
"deleteReferenceDraft",
|
||||||
|
"validateCase",
|
||||||
|
"submitReviewCase",
|
||||||
|
"returnToDraftCase",
|
||||||
|
"unpublishCase",
|
||||||
|
"archiveCase",
|
||||||
|
"restoreCase",
|
||||||
|
"publishCase",
|
||||||
|
"validateReference",
|
||||||
|
"submitReviewReference",
|
||||||
|
"returnToDraftReference",
|
||||||
|
"unpublishReference",
|
||||||
|
"archiveReference",
|
||||||
|
"restoreReference",
|
||||||
|
"publishReference",
|
||||||
|
"createQuestion",
|
||||||
|
"listStudioQuestions",
|
||||||
|
"getQuestionForEdit",
|
||||||
|
"updateQuestion",
|
||||||
|
"deleteQuestion",
|
||||||
|
"addQuestionUpdate",
|
||||||
|
"updateQuestionUpdate",
|
||||||
|
"deleteQuestionUpdate",
|
||||||
|
"resolveQuestion",
|
||||||
|
"startQuestionInvestigation",
|
||||||
|
"pauseQuestion",
|
||||||
|
"resumeQuestion",
|
||||||
|
"reopenQuestion",
|
||||||
|
"archiveQuestion",
|
||||||
|
"publishQuestion",
|
||||||
|
"unpublishQuestion",
|
||||||
|
"createProject",
|
||||||
|
"listStudioProjects",
|
||||||
|
"getProjectForEdit",
|
||||||
|
"updateProject",
|
||||||
|
"deleteProject",
|
||||||
|
"changeProjectPhase",
|
||||||
|
"publishProject",
|
||||||
|
"unpublishProject",
|
||||||
|
"createProjectDecision",
|
||||||
|
"listStudioProjectDecisions",
|
||||||
|
"getProjectDecision",
|
||||||
|
"updateProjectDecision",
|
||||||
|
"deleteProjectDecision",
|
||||||
|
"acceptProjectDecision",
|
||||||
|
"rejectProjectDecision",
|
||||||
|
"supersedeProjectDecision",
|
||||||
|
"createRelease",
|
||||||
|
"listStudioReleases",
|
||||||
|
"getReleaseForEdit",
|
||||||
|
"updateRelease",
|
||||||
|
"deleteRelease",
|
||||||
|
"publishRelease",
|
||||||
|
"archiveRelease",
|
||||||
|
"listStudioTopics",
|
||||||
|
"createTopic",
|
||||||
|
"updateTopic",
|
||||||
|
"deleteTopic",
|
||||||
|
"listStudioTags",
|
||||||
|
"createTag",
|
||||||
|
"updateTag",
|
||||||
|
"deleteTag",
|
||||||
|
"getStudioSite",
|
||||||
|
"updateStudioSite",
|
||||||
|
"getStudioProfile",
|
||||||
|
"updateStudioProfile",
|
||||||
|
"publishProfile",
|
||||||
|
"unpublishProfile",
|
||||||
|
"getHomeFocus",
|
||||||
|
"updateHomeFocus",
|
||||||
|
"listStudioProjectActivities",
|
||||||
|
"createProjectActivity",
|
||||||
|
"updateProjectActivity"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import type { components } from "./generated.ts";
|
||||||
|
|
||||||
|
type Schemas = components["schemas"];
|
||||||
|
|
||||||
|
export type TopicEdit = Schemas["TopicEdit"];
|
||||||
|
export type ProjectEditResponse = Schemas["ProjectEditResponse"];
|
||||||
|
export type ProjectIndexItem = Schemas["ProjectIndexItem"];
|
||||||
|
export type ProjectIndexPage = Schemas["ProjectIndexPage"];
|
||||||
|
export type ProjectUpdateRequest = Schemas["ProjectUpdateRequest"];
|
||||||
|
export type CreateDraftRequest = Schemas["CreateDraftRequest"];
|
||||||
|
export type CreateDraftResponse = Schemas["CreateDraftResponse"];
|
||||||
|
export type ExpectedVersionRequest = Schemas["ExpectedVersionRequest"];
|
||||||
|
export type ReleaseEditResponse = Schemas["ReleaseEditResponse"];
|
||||||
|
export type ReleaseIndexItem = Schemas["ReleaseIndexItem"];
|
||||||
|
export type ReleaseIndexPage = Schemas["ReleaseIndexPage"];
|
||||||
|
export type ReleaseUpdateRequest = Schemas["ReleaseUpdateRequest"];
|
||||||
|
export type PublishResponse = Schemas["PublishResponse"];
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -4,5 +4,8 @@ export const publicSiteConfig = Object.freeze({
|
|||||||
operator: "동현",
|
operator: "동현",
|
||||||
contactLabel: "프로필",
|
contactLabel: "프로필",
|
||||||
contactPath: "/profile",
|
contactPath: "/profile",
|
||||||
latestRelease: "/releases/0.1.0",
|
// 변경 기록 목록. 이전에는 특정 버전(`/releases/0.1.0`)을 박아 두었는데, 그 릴리즈가 아직
|
||||||
|
// 없어서 푸터 링크가 404 였고, 있었더라도 다음 버전이 나오면 다시 낡는 자리였다. 목록은 어떤
|
||||||
|
// 버전이 최신인지 아는 유일한 곳이고 릴리즈가 하나도 없어도 성립한다.
|
||||||
|
releasesPath: "/releases",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"packageId": "@tech-log/public-contract",
|
||||||
|
"version": "2.0.0",
|
||||||
|
"digest": "sha256:6575a09317a1ffe951747b12102ad2cf884110007426a45d6f59a53b65612d59",
|
||||||
|
"sourceRevision": "65a04fc",
|
||||||
|
"operationIds": [
|
||||||
|
"getPublicSite",
|
||||||
|
"getPublicHome",
|
||||||
|
"exploreKnowledge",
|
||||||
|
"exploreQuestions",
|
||||||
|
"listPublicTopics",
|
||||||
|
"getPublicTopic",
|
||||||
|
"getPublicCase",
|
||||||
|
"getPublicReference",
|
||||||
|
"getPublicQuestion",
|
||||||
|
"listPublicProjects",
|
||||||
|
"getPublicProject",
|
||||||
|
"listPublicProjectDecisions",
|
||||||
|
"listPublicProjectRecords",
|
||||||
|
"listPublicProjectActivities",
|
||||||
|
"listPublicReleases",
|
||||||
|
"getPublicRelease",
|
||||||
|
"getPublicProfile",
|
||||||
|
"searchPublicResources",
|
||||||
|
"getPublicMedia"
|
||||||
|
]
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"packageId": "@tech-log/studio-contract",
|
"packageId": "@tech-log/studio-contract",
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"digest": "sha256:6cae9924403d0761f401643a022980b8e04183eea0d890c143c9fbbbbc7431e4",
|
"digest": "sha256:5229865c3d242f19d75030d3f524a44dfebbf444324068d6ae88e43b8047dba4",
|
||||||
"sourceRevision": "b20d7a2",
|
"sourceRevision": "65a04fc",
|
||||||
"operationIds": [
|
"operationIds": [
|
||||||
"getStudioSession",
|
"getStudioSession",
|
||||||
"getStudioDashboard",
|
"getStudioDashboard",
|
||||||
|
|||||||
@@ -645,7 +645,7 @@ export interface components {
|
|||||||
constraints: components["schemas"]["OrderedText"][];
|
constraints: components["schemas"]["OrderedText"][];
|
||||||
options: components["schemas"]["QuestionOption"][];
|
options: components["schemas"]["QuestionOption"][];
|
||||||
nextValidation: string;
|
nextValidation: string;
|
||||||
resolution: components["schemas"]["QuestionResolution"] | null;
|
resolution?: components["schemas"]["QuestionResolution"] | null;
|
||||||
} & {
|
} & {
|
||||||
/**
|
/**
|
||||||
* @description discriminator enum property added by openapi-typescript
|
* @description discriminator enum property added by openapi-typescript
|
||||||
|
|||||||
@@ -959,7 +959,12 @@ components:
|
|||||||
allOf:
|
allOf:
|
||||||
- { $ref: "#/components/schemas/WorkingCopyInputBase" }
|
- { $ref: "#/components/schemas/WorkingCopyInputBase" }
|
||||||
- type: object
|
- type: object
|
||||||
required: [kind, questionStatus, facts, assumptions, unknowns, constraints, options, nextValidation, resolution]
|
# `resolution` 은 여기 없다. 미해결 질문에는 해결 내용이 없고, 그것을 required 로 두면
|
||||||
|
# Java 생성기가 nullable 여부와 무관하게 @NotNull 을 찍는다 — oneOf 로 적은 null 을 그
|
||||||
|
# 생성기는 읽지 못한다. 실제로 그래서 Question 작업본을 만들 수 없었다: 프론트가 계약대로
|
||||||
|
# resolution: null 을 보냈고 백엔드가 422 로 거절했다. 같은 목록의 `questionStatus` 가
|
||||||
|
# 통과하는 것은 그쪽이 nullability 를 `type: [string, "null"]` 로 적었기 때문이다.
|
||||||
|
required: [kind, questionStatus, facts, assumptions, unknowns, constraints, options, nextValidation]
|
||||||
properties:
|
properties:
|
||||||
kind: { type: string, enum: [QUESTION] }
|
kind: { type: string, enum: [QUESTION] }
|
||||||
questionStatus:
|
questionStatus:
|
||||||
|
|||||||
@@ -0,0 +1,442 @@
|
|||||||
|
import type {
|
||||||
|
CommandEffectDescriptor,
|
||||||
|
InstalledContractContribution,
|
||||||
|
InstalledHttpContract,
|
||||||
|
} from "../../../contracts/external-contract-runtime.ts";
|
||||||
|
import type { ProblemDetails } from "./studio/contract.ts";
|
||||||
|
import { TECH_LOG_FEATURE_ID } from "../application/tech-log-feature-input.ts";
|
||||||
|
import canonicalSource from "./management/canonical-source.json" with { type: "json" };
|
||||||
|
import {
|
||||||
|
envelopeData,
|
||||||
|
envelopeError,
|
||||||
|
passthroughInput,
|
||||||
|
} from "./tech-log-studio-contract-contribution.ts";
|
||||||
|
import { TECH_LOG_STUDIO_SESSION_AUTH_PROFILE_ID } from "../adapters/http/studio-session-credentials.ts";
|
||||||
|
|
||||||
|
type PathValues = Readonly<Record<string, string>>;
|
||||||
|
type QueryEntries = readonly (readonly [string, string])[];
|
||||||
|
|
||||||
|
const NO_PATH: PathValues = Object.freeze({});
|
||||||
|
const NO_QUERY = Object.freeze([]) as QueryEntries;
|
||||||
|
/** studio-management-v1.yaml `ApiError.code` enum과 1:1이다. */
|
||||||
|
const MANAGEMENT_ERROR_CODES = Object.freeze([
|
||||||
|
"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",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const PROBLEM = envelopeError(MANAGEMENT_ERROR_CODES, "ManagementErrorEnvelope");
|
||||||
|
|
||||||
|
/** Studio 쪽과 같은 판정이다: 4xx 도메인 거절은 적용되지 않았음이 확정, 5xx·네트워크는 불확정. */
|
||||||
|
const COMMAND_EFFECT: CommandEffectDescriptor<ProblemDetails> = Object.freeze({
|
||||||
|
successEffect: "APPLIED_CONFIRMED" as const,
|
||||||
|
classifyProblem({ status }: Readonly<{ status: number; problem: unknown }>) {
|
||||||
|
return status >= 400 && status < 500 ? "NOT_APPLIED" : "MAYBE_APPLIED";
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 관리 표면은 Studio 와 같은 세션·CSRF 를 쓴다. 계약이 `sessionCookie` 보안과 `CsrfToken`
|
||||||
|
* 파라미터를 선언하고 있고, 실제로 같은 백엔드의 같은 필터 체인을 지난다 — 그래서 인증 프로필도
|
||||||
|
* 공유한다. 부트스트랩 프로필은 쓰지 않는다: CSRF 토큰을 발급하는 것은 `getStudioSession`
|
||||||
|
* 하나뿐이고, 이 표면은 그 뒤에만 호출된다.
|
||||||
|
*/
|
||||||
|
function readOperation(
|
||||||
|
operationId: string,
|
||||||
|
pathTemplate: string,
|
||||||
|
responseByteLimit: number,
|
||||||
|
project: (input: never) => Readonly<{ pathValues: PathValues; queryEntries: QueryEntries }> = () =>
|
||||||
|
Object.freeze({ pathValues: NO_PATH, queryEntries: NO_QUERY }),
|
||||||
|
): InstalledHttpContract<unknown, unknown, unknown> {
|
||||||
|
return Object.freeze({
|
||||||
|
contract: Object.freeze({
|
||||||
|
operationId,
|
||||||
|
method: "GET" as const,
|
||||||
|
pathTemplate,
|
||||||
|
inputValidator: passthroughInput(`${operationId}Input`),
|
||||||
|
outputValidator: envelopeData(`${operationId}Output`),
|
||||||
|
problemValidator: PROBLEM,
|
||||||
|
acceptedStatuses: Object.freeze([200]),
|
||||||
|
emptyBodyStatuses: Object.freeze([]),
|
||||||
|
retrySemantics: "SAFE" as const,
|
||||||
|
requestBody: "NONE" as const,
|
||||||
|
responseBody: "REQUIRED_JSON" as const,
|
||||||
|
commandRecovery: null,
|
||||||
|
commandEffect: null,
|
||||||
|
projectRequest(input: never) {
|
||||||
|
return Object.freeze({ ...project(input), body: null });
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
frontend: Object.freeze({
|
||||||
|
policyId: `${operationId}_V1`,
|
||||||
|
requestByteLimit: 0,
|
||||||
|
responseByteLimit,
|
||||||
|
totalDeadlineMs: 10_000,
|
||||||
|
retryBudget: 2 as const,
|
||||||
|
authProfileId: TECH_LOG_STUDIO_SESSION_AUTH_PROFILE_ID,
|
||||||
|
diagnosticsOperation: `techLog.management.${operationId}`,
|
||||||
|
}),
|
||||||
|
}) as InstalledHttpContract<unknown, unknown, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 쓰기는 `Idempotency-Key` 를 쓰지 않는다 — 계약이 요구하지 않고, 재생 보호는 `expectedVersion`
|
||||||
|
* 이 맡는다. 그래서 `NOT_IDEMPOTENT` 가 아니라 재시도 예산 0 으로 둔다: 응답을 못 본 재시도가
|
||||||
|
* 두 번째 생성을 만들 수 있는 표면이다.
|
||||||
|
*/
|
||||||
|
function writeOperation(
|
||||||
|
operationId: string,
|
||||||
|
method: "POST" | "PUT" | "DELETE",
|
||||||
|
pathTemplate: string,
|
||||||
|
options: Readonly<{
|
||||||
|
acceptedStatuses: readonly number[];
|
||||||
|
emptyBodyStatuses?: readonly number[];
|
||||||
|
requestByteLimit: number;
|
||||||
|
responseByteLimit: number;
|
||||||
|
}>,
|
||||||
|
project: (input: never) => Readonly<{
|
||||||
|
pathValues: PathValues;
|
||||||
|
queryEntries: QueryEntries;
|
||||||
|
body: unknown;
|
||||||
|
}>,
|
||||||
|
): InstalledHttpContract<unknown, unknown, unknown> {
|
||||||
|
return Object.freeze({
|
||||||
|
contract: Object.freeze({
|
||||||
|
operationId,
|
||||||
|
method,
|
||||||
|
pathTemplate,
|
||||||
|
inputValidator: passthroughInput(`${operationId}Input`),
|
||||||
|
outputValidator: envelopeData(`${operationId}Output`),
|
||||||
|
problemValidator: PROBLEM,
|
||||||
|
acceptedStatuses: Object.freeze([...options.acceptedStatuses]),
|
||||||
|
emptyBodyStatuses: Object.freeze([...(options.emptyBodyStatuses ?? [])]),
|
||||||
|
// 생성은 재생 보호가 없으므로 NEVER 다. 수정·삭제는 expectedVersion 이 두 번째
|
||||||
|
// 적용을 409 로 막으므로 IDEMPOTENT 로 둘 수 있지만, 세 경우를 한 헬퍼가 만들고
|
||||||
|
// 있어 가장 보수적인 값으로 통일한다 — 재시도 예산도 0 이라 실제 차이는 없다.
|
||||||
|
retrySemantics: "NEVER" as const,
|
||||||
|
requestBody: "JSON" as const,
|
||||||
|
responseBody:
|
||||||
|
(options.emptyBodyStatuses ?? []).length > 0
|
||||||
|
? ("OPTIONAL_JSON" as const)
|
||||||
|
: ("REQUIRED_JSON" as const),
|
||||||
|
commandRecovery: null,
|
||||||
|
commandEffect: COMMAND_EFFECT,
|
||||||
|
projectRequest(input: never) {
|
||||||
|
return Object.freeze(project(input));
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
frontend: Object.freeze({
|
||||||
|
policyId: `${operationId}_V1`,
|
||||||
|
requestByteLimit: options.requestByteLimit,
|
||||||
|
responseByteLimit: options.responseByteLimit,
|
||||||
|
totalDeadlineMs: 15_000,
|
||||||
|
retryBudget: 0 as const,
|
||||||
|
authProfileId: TECH_LOG_STUDIO_SESSION_AUTH_PROFILE_ID,
|
||||||
|
diagnosticsOperation: `techLog.management.${operationId}`,
|
||||||
|
}),
|
||||||
|
// read 쪽과 달리 여기서만 unknown 을 거친다: `emptyBodyStatuses` 유무로 responseBody 가
|
||||||
|
// 갈리는 삼항이 union 타입을 만들어, 컴파일러가 리터럴을 대상 타입과 겹친다고 보지 않는다.
|
||||||
|
}) as unknown as InstalledHttpContract<unknown, unknown, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const byId = (input: never) => {
|
||||||
|
const value = input as unknown as Readonly<{ id: string }>;
|
||||||
|
return Object.freeze({ pathValues: Object.freeze({ id: value.id }), queryEntries: NO_QUERY });
|
||||||
|
};
|
||||||
|
|
||||||
|
const T = "/api/v1/studio/topics";
|
||||||
|
const P = "/api/v1/studio/projects";
|
||||||
|
const R = "/api/v1/studio/releases";
|
||||||
|
const D = "/api/v1/studio";
|
||||||
|
|
||||||
|
const HTTP_CONTRACTS = Object.freeze([
|
||||||
|
readOperation("listStudioTopics", T, 131_072),
|
||||||
|
writeOperation(
|
||||||
|
"createTopic",
|
||||||
|
"POST",
|
||||||
|
T,
|
||||||
|
{ acceptedStatuses: [201], requestByteLimit: 16_384, responseByteLimit: 16_384 },
|
||||||
|
(input: never) =>
|
||||||
|
Object.freeze({ pathValues: NO_PATH, queryEntries: NO_QUERY, body: input }),
|
||||||
|
),
|
||||||
|
writeOperation(
|
||||||
|
"updateTopic",
|
||||||
|
"PUT",
|
||||||
|
`${T}/{id}`,
|
||||||
|
{ acceptedStatuses: [200], requestByteLimit: 16_384, responseByteLimit: 16_384 },
|
||||||
|
(input: never) => {
|
||||||
|
const value = input as unknown as Readonly<{ id: string; body: unknown }>;
|
||||||
|
return Object.freeze({
|
||||||
|
pathValues: Object.freeze({ id: value.id }),
|
||||||
|
queryEntries: NO_QUERY,
|
||||||
|
body: value.body,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
writeOperation(
|
||||||
|
"deleteTopic",
|
||||||
|
"DELETE",
|
||||||
|
`${T}/{id}`,
|
||||||
|
{
|
||||||
|
acceptedStatuses: [204],
|
||||||
|
emptyBodyStatuses: [204],
|
||||||
|
requestByteLimit: 1_024,
|
||||||
|
responseByteLimit: 1_024,
|
||||||
|
},
|
||||||
|
(input: never) => {
|
||||||
|
const value = input as unknown as Readonly<{ id: string; expectedVersion: number }>;
|
||||||
|
return Object.freeze({
|
||||||
|
pathValues: Object.freeze({ id: value.id }),
|
||||||
|
queryEntries: NO_QUERY,
|
||||||
|
body: { expectedVersion: value.expectedVersion },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
readOperation("listStudioProjects", P, 262_144, (input: never) => {
|
||||||
|
const value = input as unknown as Readonly<{ page?: number; size?: number }> | undefined;
|
||||||
|
const entries: (readonly [string, string])[] = [];
|
||||||
|
if (value?.page !== undefined) entries.push(["page", String(value.page)]);
|
||||||
|
if (value?.size !== undefined) entries.push(["size", String(value.size)]);
|
||||||
|
return Object.freeze({ pathValues: NO_PATH, queryEntries: Object.freeze(entries) });
|
||||||
|
}),
|
||||||
|
readOperation("getProjectForEdit", `${P}/{id}`, 262_144, byId),
|
||||||
|
writeOperation(
|
||||||
|
"createProject",
|
||||||
|
"POST",
|
||||||
|
P,
|
||||||
|
{ acceptedStatuses: [201], requestByteLimit: 4_096, responseByteLimit: 8_192 },
|
||||||
|
(input: never) =>
|
||||||
|
Object.freeze({ pathValues: NO_PATH, queryEntries: NO_QUERY, body: input }),
|
||||||
|
),
|
||||||
|
writeOperation(
|
||||||
|
"updateProject",
|
||||||
|
"PUT",
|
||||||
|
`${P}/{id}`,
|
||||||
|
{ acceptedStatuses: [200], requestByteLimit: 131_072, responseByteLimit: 262_144 },
|
||||||
|
(input: never) => {
|
||||||
|
const value = input as unknown as Readonly<{ id: string; body: unknown }>;
|
||||||
|
return Object.freeze({
|
||||||
|
pathValues: Object.freeze({ id: value.id }),
|
||||||
|
queryEntries: NO_QUERY,
|
||||||
|
body: value.body,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
writeOperation(
|
||||||
|
"deleteProject",
|
||||||
|
"DELETE",
|
||||||
|
`${P}/{id}`,
|
||||||
|
{
|
||||||
|
acceptedStatuses: [204],
|
||||||
|
emptyBodyStatuses: [204],
|
||||||
|
requestByteLimit: 1_024,
|
||||||
|
responseByteLimit: 1_024,
|
||||||
|
},
|
||||||
|
(input: never) => {
|
||||||
|
const value = input as unknown as Readonly<{ id: string; expectedVersion: number }>;
|
||||||
|
return Object.freeze({
|
||||||
|
pathValues: Object.freeze({ id: value.id }),
|
||||||
|
queryEntries: NO_QUERY,
|
||||||
|
body: { expectedVersion: value.expectedVersion },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
readOperation("listStudioReleases", R, 262_144, (input: never) => {
|
||||||
|
const value = input as unknown as Readonly<{ page?: number; size?: number }> | undefined;
|
||||||
|
const entries: (readonly [string, string])[] = [];
|
||||||
|
if (value?.page !== undefined) entries.push(["page", String(value.page)]);
|
||||||
|
if (value?.size !== undefined) entries.push(["size", String(value.size)]);
|
||||||
|
return Object.freeze({ pathValues: NO_PATH, queryEntries: Object.freeze(entries) });
|
||||||
|
}),
|
||||||
|
readOperation("getReleaseForEdit", `${R}/{id}`, 262_144, byId),
|
||||||
|
writeOperation(
|
||||||
|
"createRelease",
|
||||||
|
"POST",
|
||||||
|
R,
|
||||||
|
{ acceptedStatuses: [201], requestByteLimit: 4_096, responseByteLimit: 8_192 },
|
||||||
|
(input: never) =>
|
||||||
|
Object.freeze({ pathValues: NO_PATH, queryEntries: NO_QUERY, body: input }),
|
||||||
|
),
|
||||||
|
writeOperation(
|
||||||
|
"updateRelease",
|
||||||
|
"PUT",
|
||||||
|
`${R}/{id}`,
|
||||||
|
// 릴리즈 본문은 마크다운 여섯 구획이라 문서 다음으로 큰 요청이다.
|
||||||
|
{ acceptedStatuses: [200], requestByteLimit: 262_144, responseByteLimit: 262_144 },
|
||||||
|
(input: never) => {
|
||||||
|
const value = input as unknown as Readonly<{ id: string; body: unknown }>;
|
||||||
|
return Object.freeze({
|
||||||
|
pathValues: Object.freeze({ id: value.id }),
|
||||||
|
queryEntries: NO_QUERY,
|
||||||
|
body: value.body,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
writeOperation(
|
||||||
|
"deleteRelease",
|
||||||
|
"DELETE",
|
||||||
|
`${R}/{id}`,
|
||||||
|
{
|
||||||
|
acceptedStatuses: [204],
|
||||||
|
emptyBodyStatuses: [204],
|
||||||
|
requestByteLimit: 1_024,
|
||||||
|
responseByteLimit: 1_024,
|
||||||
|
},
|
||||||
|
(input: never) => {
|
||||||
|
const value = input as unknown as Readonly<{ id: string; expectedVersion: number }>;
|
||||||
|
return Object.freeze({
|
||||||
|
pathValues: Object.freeze({ id: value.id }),
|
||||||
|
queryEntries: NO_QUERY,
|
||||||
|
body: { expectedVersion: value.expectedVersion },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
writeOperation(
|
||||||
|
"publishRelease",
|
||||||
|
"POST",
|
||||||
|
`${R}/{id}/publish`,
|
||||||
|
{ acceptedStatuses: [200], requestByteLimit: 1_024, responseByteLimit: 8_192 },
|
||||||
|
(input: never) => {
|
||||||
|
const value = input as unknown as Readonly<{ id: string; expectedVersion: number }>;
|
||||||
|
return Object.freeze({
|
||||||
|
pathValues: Object.freeze({ id: value.id }),
|
||||||
|
queryEntries: NO_QUERY,
|
||||||
|
body: { expectedVersion: value.expectedVersion },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
writeOperation(
|
||||||
|
"archiveRelease",
|
||||||
|
"POST",
|
||||||
|
`${R}/{id}/archive`,
|
||||||
|
{ acceptedStatuses: [200], requestByteLimit: 1_024, responseByteLimit: 262_144 },
|
||||||
|
(input: never) => {
|
||||||
|
const value = input as unknown as Readonly<{ id: string; expectedVersion: number }>;
|
||||||
|
return Object.freeze({
|
||||||
|
pathValues: Object.freeze({ id: value.id }),
|
||||||
|
queryEntries: NO_QUERY,
|
||||||
|
body: { expectedVersion: value.expectedVersion },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
writeOperation(
|
||||||
|
"deleteCaseDraft",
|
||||||
|
"DELETE",
|
||||||
|
`${D}/cases/{id}`,
|
||||||
|
{
|
||||||
|
acceptedStatuses: [204],
|
||||||
|
emptyBodyStatuses: [204],
|
||||||
|
requestByteLimit: 1_024,
|
||||||
|
responseByteLimit: 1_024,
|
||||||
|
},
|
||||||
|
(input: never) => {
|
||||||
|
const value = input as unknown as Readonly<{ id: string; expectedVersion: number }>;
|
||||||
|
return Object.freeze({
|
||||||
|
pathValues: Object.freeze({ id: value.id }),
|
||||||
|
queryEntries: NO_QUERY,
|
||||||
|
body: { expectedVersion: value.expectedVersion },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
writeOperation(
|
||||||
|
"deleteReferenceDraft",
|
||||||
|
"DELETE",
|
||||||
|
`${D}/references/{id}`,
|
||||||
|
{
|
||||||
|
acceptedStatuses: [204],
|
||||||
|
emptyBodyStatuses: [204],
|
||||||
|
requestByteLimit: 1_024,
|
||||||
|
responseByteLimit: 1_024,
|
||||||
|
},
|
||||||
|
(input: never) => {
|
||||||
|
const value = input as unknown as Readonly<{ id: string; expectedVersion: number }>;
|
||||||
|
return Object.freeze({
|
||||||
|
pathValues: Object.freeze({ id: value.id }),
|
||||||
|
queryEntries: NO_QUERY,
|
||||||
|
body: { expectedVersion: value.expectedVersion },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
writeOperation(
|
||||||
|
"deleteQuestion",
|
||||||
|
"DELETE",
|
||||||
|
`${D}/questions/{id}`,
|
||||||
|
{
|
||||||
|
acceptedStatuses: [204],
|
||||||
|
emptyBodyStatuses: [204],
|
||||||
|
requestByteLimit: 1_024,
|
||||||
|
responseByteLimit: 1_024,
|
||||||
|
},
|
||||||
|
(input: never) => {
|
||||||
|
const value = input as unknown as Readonly<{ id: string; expectedVersion: number }>;
|
||||||
|
return Object.freeze({
|
||||||
|
pathValues: Object.freeze({ id: value.id }),
|
||||||
|
queryEntries: NO_QUERY,
|
||||||
|
body: { expectedVersion: value.expectedVersion },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
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(
|
||||||
|
HTTP_CONTRACTS.map((entry) => entry.contract.operationId),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const TECH_LOG_MANAGEMENT_CONTRIBUTION: InstalledContractContribution = Object.freeze({
|
||||||
|
contributionId: "tech-log-management-http-v1",
|
||||||
|
featureId: TECH_LOG_FEATURE_ID,
|
||||||
|
source: Object.freeze({
|
||||||
|
kind: "EXTERNAL_PACKAGE" as const,
|
||||||
|
package: Object.freeze({
|
||||||
|
packageId: canonicalSource.packageId,
|
||||||
|
version: canonicalSource.version,
|
||||||
|
digest: canonicalSource.digest as `sha256:${string}`,
|
||||||
|
runtimeProtocolVersion: 1 as const,
|
||||||
|
sourceRevision: canonicalSource.sourceRevision,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
http: HTTP_CONTRACTS,
|
||||||
|
events: Object.freeze([]),
|
||||||
|
});
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import type {
|
||||||
|
InstalledContractContribution,
|
||||||
|
InstalledHttpContract,
|
||||||
|
} from "../../../contracts/external-contract-runtime.ts";
|
||||||
|
import { TECH_LOG_FEATURE_ID } from "../application/tech-log-feature-input.ts";
|
||||||
|
import canonicalSource from "./public/canonical-source.json" with { type: "json" };
|
||||||
|
import { envelopeData, envelopeError, passthroughInput } from "./tech-log-studio-contract-contribution.ts";
|
||||||
|
|
||||||
|
type PathValues = Readonly<Record<string, string>>;
|
||||||
|
type QueryEntries = readonly (readonly [string, string])[];
|
||||||
|
|
||||||
|
const NO_PATH: PathValues = Object.freeze({});
|
||||||
|
const NO_QUERY = Object.freeze([]) as QueryEntries;
|
||||||
|
/**
|
||||||
|
* public-v1.yaml `ApiError.code` enum과 1:1이다. `INTERNAL_ERROR` 는 이 기능이
|
||||||
|
* 아니라 스켈레톤 공통 처리기가 내는 코드이고, 계약이 그것까지 열거하므로 여기도
|
||||||
|
* 열거한다 — 빠지면 500 응답이 계약 위반으로 분류된다.
|
||||||
|
*/
|
||||||
|
const PUBLIC_ERROR_CODES = Object.freeze([
|
||||||
|
"PUBLIC_REQUEST_INVALID",
|
||||||
|
"PUBLIC_RESOURCE_NOT_FOUND",
|
||||||
|
"INTERNAL_ERROR",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const PROBLEM = envelopeError(PUBLIC_ERROR_CODES, "PublicErrorEnvelope");
|
||||||
|
|
||||||
|
function queryOf(input: Readonly<Record<string, unknown>>): QueryEntries {
|
||||||
|
const entries: (readonly [string, string])[] = [];
|
||||||
|
for (const [key, value] of Object.entries(input)) {
|
||||||
|
if (value === undefined || value === null || value === "") continue;
|
||||||
|
entries.push([key, String(value)]);
|
||||||
|
}
|
||||||
|
return Object.freeze(entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every public operation has the same shape, which is the point of holding this
|
||||||
|
* surface apart from Studio: all 18 are GET, none carries a body, none needs a
|
||||||
|
* session, and none needs a CSRF token. The `ANONYMOUS` auth profile is what
|
||||||
|
* states that — it forbids credentials outright, so a future change that starts
|
||||||
|
* sending the session cookie on a public read fails the profile check rather
|
||||||
|
* than silently making the cache-friendly surface user-specific.
|
||||||
|
*/
|
||||||
|
function publicRead(
|
||||||
|
operationId: string,
|
||||||
|
pathTemplate: string,
|
||||||
|
responseByteLimit: number,
|
||||||
|
project: (input: never) => Readonly<{ pathValues: PathValues; queryEntries: QueryEntries }> = () =>
|
||||||
|
Object.freeze({ pathValues: NO_PATH, queryEntries: NO_QUERY }),
|
||||||
|
): InstalledHttpContract<unknown, unknown, unknown> {
|
||||||
|
return Object.freeze({
|
||||||
|
contract: Object.freeze({
|
||||||
|
operationId,
|
||||||
|
method: "GET" as const,
|
||||||
|
pathTemplate,
|
||||||
|
inputValidator: passthroughInput(`${operationId}Input`),
|
||||||
|
outputValidator: envelopeData(`${operationId}Output`),
|
||||||
|
problemValidator: PROBLEM,
|
||||||
|
// 404 is a normal answer here — a slug that is not published — so it is
|
||||||
|
// mapped by the gateway rather than treated as a transport failure.
|
||||||
|
acceptedStatuses: Object.freeze([200]),
|
||||||
|
emptyBodyStatuses: Object.freeze([]),
|
||||||
|
retrySemantics: "SAFE" as const,
|
||||||
|
requestBody: "NONE" as const,
|
||||||
|
responseBody: "REQUIRED_JSON" as const,
|
||||||
|
commandRecovery: null,
|
||||||
|
commandEffect: null,
|
||||||
|
projectRequest(input: never) {
|
||||||
|
return Object.freeze({ ...project(input), body: null });
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
frontend: Object.freeze({
|
||||||
|
policyId: `${operationId}_V1`,
|
||||||
|
requestByteLimit: 0,
|
||||||
|
responseByteLimit,
|
||||||
|
totalDeadlineMs: 10_000,
|
||||||
|
retryBudget: 2 as const,
|
||||||
|
authProfileId: "ANONYMOUS",
|
||||||
|
diagnosticsOperation: `techLog.public.${operationId}`,
|
||||||
|
}),
|
||||||
|
}) as InstalledHttpContract<unknown, unknown, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bySlug = (input: never) => {
|
||||||
|
const value = input as unknown as Readonly<{ slug: string }>;
|
||||||
|
return Object.freeze({
|
||||||
|
pathValues: Object.freeze({ slug: value.slug }),
|
||||||
|
queryEntries: NO_QUERY,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const P = "/api/v1/public";
|
||||||
|
|
||||||
|
const HTTP_CONTRACTS = Object.freeze([
|
||||||
|
publicRead("getPublicSite", `${P}/site`, 32_768),
|
||||||
|
publicRead("getPublicHome", `${P}/home`, 262_144),
|
||||||
|
publicRead("exploreKnowledge", `${P}/explore/knowledge`, 262_144, (input: never) => {
|
||||||
|
const value = input as unknown as Readonly<{
|
||||||
|
type?: string;
|
||||||
|
topic?: string;
|
||||||
|
project?: string;
|
||||||
|
page?: number;
|
||||||
|
size?: number;
|
||||||
|
}>;
|
||||||
|
return Object.freeze({ pathValues: NO_PATH, queryEntries: queryOf(value ?? {}) });
|
||||||
|
}),
|
||||||
|
publicRead("exploreQuestions", `${P}/explore/questions`, 262_144, (input: never) => {
|
||||||
|
const value = input as unknown as Readonly<{
|
||||||
|
status?: string;
|
||||||
|
topic?: string;
|
||||||
|
project?: string;
|
||||||
|
page?: number;
|
||||||
|
size?: number;
|
||||||
|
}>;
|
||||||
|
return Object.freeze({ pathValues: NO_PATH, queryEntries: queryOf(value ?? {}) });
|
||||||
|
}),
|
||||||
|
publicRead("listPublicTopics", `${P}/topics`, 65_536),
|
||||||
|
publicRead("getPublicTopic", `${P}/topics/{topicSlug}`, 262_144, (input: never) => {
|
||||||
|
const value = input as unknown as Readonly<{ topicSlug: string }>;
|
||||||
|
return Object.freeze({
|
||||||
|
pathValues: Object.freeze({ topicSlug: value.topicSlug }),
|
||||||
|
queryEntries: NO_QUERY,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
publicRead("getPublicCase", `${P}/cases/{slug}`, 524_288, bySlug),
|
||||||
|
publicRead("getPublicReference", `${P}/references/{slug}`, 524_288, bySlug),
|
||||||
|
publicRead("getPublicQuestion", `${P}/questions/{slug}`, 524_288, bySlug),
|
||||||
|
publicRead("listPublicProjects", `${P}/projects`, 262_144),
|
||||||
|
publicRead("getPublicProject", `${P}/projects/{slug}`, 262_144, bySlug),
|
||||||
|
publicRead("listPublicProjectDecisions", `${P}/projects/{slug}/decisions`, 262_144, bySlug),
|
||||||
|
publicRead("listPublicProjectRecords", `${P}/projects/{slug}/records`, 262_144, bySlug),
|
||||||
|
publicRead("listPublicProjectActivities", `${P}/projects/{slug}/activities`, 262_144, bySlug),
|
||||||
|
publicRead("listPublicReleases", `${P}/releases`, 262_144),
|
||||||
|
publicRead("getPublicRelease", `${P}/releases/{version}`, 262_144, (input: never) => {
|
||||||
|
const value = input as unknown as Readonly<{ version: string }>;
|
||||||
|
return Object.freeze({
|
||||||
|
pathValues: Object.freeze({ version: value.version }),
|
||||||
|
queryEntries: NO_QUERY,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
publicRead("getPublicProfile", `${P}/profile`, 65_536),
|
||||||
|
publicRead("searchPublicResources", `${P}/search`, 262_144, (input: never) => {
|
||||||
|
const value = input as unknown as Readonly<{ q?: string; page?: number; size?: number }>;
|
||||||
|
return Object.freeze({ pathValues: NO_PATH, queryEntries: queryOf(value ?? {}) });
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const TECH_LOG_PUBLIC_OPERATION_IDS = Object.freeze(
|
||||||
|
HTTP_CONTRACTS.map((entry) => entry.contract.operationId),
|
||||||
|
);
|
||||||
|
|
||||||
|
export type TechLogPublicOperationId =
|
||||||
|
(typeof TECH_LOG_PUBLIC_OPERATION_IDS)[number];
|
||||||
|
|
||||||
|
export const TECH_LOG_PUBLIC_CONTRIBUTION: InstalledContractContribution =
|
||||||
|
Object.freeze({
|
||||||
|
contributionId: "tech-log-public-http-v1",
|
||||||
|
featureId: TECH_LOG_FEATURE_ID,
|
||||||
|
source: Object.freeze({
|
||||||
|
kind: "EXTERNAL_PACKAGE" as const,
|
||||||
|
package: Object.freeze({
|
||||||
|
packageId: canonicalSource.packageId,
|
||||||
|
version: canonicalSource.version,
|
||||||
|
digest: canonicalSource.digest as `sha256:${string}`,
|
||||||
|
runtimeProtocolVersion: 1 as const,
|
||||||
|
sourceRevision: canonicalSource.sourceRevision,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
http: HTTP_CONTRACTS,
|
||||||
|
events: Object.freeze([]),
|
||||||
|
});
|
||||||
@@ -48,6 +48,8 @@ const TECH_LOG_ROUTE_SPECS = [
|
|||||||
defineSpec({ routeId: "TECH_LOG_STUDIO_PUBLICATIONS", path: "/studio/publications", layoutGroup: "STUDIO", paramsSchema: null, searchSchema: null, title: "게시 기록", navigationLabel: "게시 기록", navigationOrder: 20 }),
|
defineSpec({ routeId: "TECH_LOG_STUDIO_PUBLICATIONS", path: "/studio/publications", layoutGroup: "STUDIO", paramsSchema: null, searchSchema: null, title: "게시 기록", navigationLabel: "게시 기록", navigationOrder: 20 }),
|
||||||
defineSpec({ routeId: "TECH_LOG_STUDIO_PUBLICATION_PREVIEW", path: "/studio/publications/:publicationEventId/preview", layoutGroup: "STUDIO", paramsSchema: "TechLogPublicationEventIdParams", searchSchema: null, title: "게시 Snapshot", navigationLabel: null, navigationOrder: null }),
|
defineSpec({ routeId: "TECH_LOG_STUDIO_PUBLICATION_PREVIEW", path: "/studio/publications/:publicationEventId/preview", layoutGroup: "STUDIO", paramsSchema: "TechLogPublicationEventIdParams", searchSchema: null, title: "게시 Snapshot", navigationLabel: null, navigationOrder: null }),
|
||||||
defineSpec({ routeId: "TECH_LOG_STUDIO_ASSETS", path: "/studio/assets", layoutGroup: "STUDIO", paramsSchema: null, searchSchema: null, title: "Asset", navigationLabel: null, navigationOrder: null }),
|
defineSpec({ routeId: "TECH_LOG_STUDIO_ASSETS", path: "/studio/assets", layoutGroup: "STUDIO", paramsSchema: null, searchSchema: null, title: "Asset", navigationLabel: null, navigationOrder: null }),
|
||||||
|
defineSpec({ routeId: "TECH_LOG_STUDIO_TAXONOMY", path: "/studio/taxonomy", layoutGroup: "STUDIO", paramsSchema: null, searchSchema: null, title: "주제와 프로젝트", navigationLabel: "주제·프로젝트", navigationOrder: 40 }),
|
||||||
|
defineSpec({ routeId: "TECH_LOG_STUDIO_RELEASES", path: "/studio/releases", layoutGroup: "STUDIO", paramsSchema: null, searchSchema: null, title: "릴리즈", navigationLabel: "릴리즈", navigationOrder: 50 }),
|
||||||
defineSpec({ routeId: "TECH_LOG_STUDIO_NOT_FOUND", path: "/studio/*", layoutGroup: "STUDIO", paramsSchema: "TechLogStudioSplat", searchSchema: null, title: "Studio 화면을 찾을 수 없습니다", navigationLabel: null, navigationOrder: null }),
|
defineSpec({ routeId: "TECH_LOG_STUDIO_NOT_FOUND", path: "/studio/*", layoutGroup: "STUDIO", paramsSchema: "TechLogStudioSplat", searchSchema: null, title: "Studio 화면을 찾을 수 없습니다", navigationLabel: null, navigationOrder: null }),
|
||||||
defineSpec({ routeId: "NOT_FOUND", path: "*", layoutGroup: "PUBLIC", paramsSchema: "NotFoundSplat", searchSchema: null, title: "페이지를 찾을 수 없습니다.", navigationLabel: null, navigationOrder: null }),
|
defineSpec({ routeId: "NOT_FOUND", path: "*", layoutGroup: "PUBLIC", paramsSchema: "NotFoundSplat", searchSchema: null, title: "페이지를 찾을 수 없습니다.", navigationLabel: null, navigationOrder: null }),
|
||||||
] as const;
|
] as const;
|
||||||
@@ -139,7 +141,13 @@ export const TECH_LOG_ROUTE_REGISTRY = Object.freeze(
|
|||||||
spec.routeId,
|
spec.routeId,
|
||||||
Object.freeze({
|
Object.freeze({
|
||||||
...spec,
|
...spec,
|
||||||
access: "public",
|
// Studio routes are the authenticated surface. Deriving this from the
|
||||||
|
// spec's own `layoutGroup` -- rather than restating it per route --
|
||||||
|
// keeps a newly added Studio route gated by construction. Registering
|
||||||
|
// every TechLog route as "public" made `decideRouteAccessForDefinition`
|
||||||
|
// a no-op for Studio: a signed-out visitor who typed /studio got the
|
||||||
|
// Studio shell rendered instead of the sign-in surface.
|
||||||
|
access: spec.layoutGroup === "STUDIO" ? "session-required" : "public",
|
||||||
loadingSurface: spec.path.endsWith("*") ? "none" : "app-shell",
|
loadingSurface: spec.path.endsWith("*") ? "none" : "app-shell",
|
||||||
errorSurface: spec.path.endsWith("*")
|
errorSurface: spec.path.endsWith("*")
|
||||||
? "not-found"
|
? "not-found"
|
||||||
|
|||||||
@@ -52,6 +52,12 @@ function zodValidator<T>(schemaId: string, schema: z.ZodType<T>): RuntimeValidat
|
|||||||
const passthrough = <T>(schemaId: string) =>
|
const passthrough = <T>(schemaId: string) =>
|
||||||
zodValidator<T>(schemaId, z.unknown() as unknown as z.ZodType<T>);
|
zodValidator<T>(schemaId, z.unknown() as unknown as z.ZodType<T>);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared with the public contribution: both surfaces project their request
|
||||||
|
* inputs in code, so neither re-validates them at the transport boundary.
|
||||||
|
*/
|
||||||
|
export const passthroughInput = passthrough;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* wire format은 봉투다 (ADR-006). 전송 계층은 봉투 뼈대만 검증하고 payload는
|
* wire format은 봉투다 (ADR-006). 전송 계층은 봉투 뼈대만 검증하고 payload는
|
||||||
* 통과시킨다 — generated 타입이 컴파일 시점 계약이고, 런타임 재검증은 계약 갱신
|
* 통과시킨다 — generated 타입이 컴파일 시점 계약이고, 런타임 재검증은 계약 갱신
|
||||||
@@ -71,14 +77,15 @@ export const envelopeData = <T>(schemaId: string): RuntimeValidator<T> =>
|
|||||||
.transform((envelope) => envelope.data as T) as unknown as z.ZodType<T>,
|
.transform((envelope) => envelope.data as T) as unknown as z.ZodType<T>,
|
||||||
);
|
);
|
||||||
|
|
||||||
const apiErrorSchema = z
|
const apiErrorSchema = (codes: readonly string[]) =>
|
||||||
.object({
|
z
|
||||||
code: z.enum(STUDIO_ERROR_CODES as unknown as [string, ...string[]]),
|
.object({
|
||||||
category: z.string().min(1),
|
code: z.enum(codes as unknown as [string, ...string[]]),
|
||||||
message: z.string().min(1).max(5000),
|
category: z.string().min(1),
|
||||||
retryable: z.boolean(),
|
message: z.string().min(1).max(5000),
|
||||||
})
|
retryable: z.boolean(),
|
||||||
.loose();
|
})
|
||||||
|
.loose();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 봉투 오류를 기존 ProblemDetails 형태로 옮긴다. 앱 계층(`StudioGatewayError`)은
|
* 봉투 오류를 기존 ProblemDetails 형태로 옮긴다. 앱 계층(`StudioGatewayError`)은
|
||||||
@@ -94,11 +101,27 @@ const apiErrorSchema = z
|
|||||||
* 않는다. 지금 그 필드들을 평평하게 읽는 소비자가 없고, 분해는 실제 소비자가
|
* 않는다. 지금 그 필드들을 평평하게 읽는 소비자가 없고, 분해는 실제 소비자가
|
||||||
* 생겼을 때 추가할 투기적 작업이다.
|
* 생겼을 때 추가할 투기적 작업이다.
|
||||||
*/
|
*/
|
||||||
export const envelopeError = (): RuntimeValidator<ProblemDetails> =>
|
/**
|
||||||
|
* The code enum is per-surface, and getting that wrong took the public site
|
||||||
|
* down. Public, Studio and Management each declare their own `ApiError.code`
|
||||||
|
* enum in their own contract; this validator was pinned to the Studio list and
|
||||||
|
* shared by all three, so every public error — `PUBLIC_RESOURCE_NOT_FOUND`
|
||||||
|
* first among them — failed the enum, became a CONTRACT_VIOLATION rather than a
|
||||||
|
* PROBLEM, and reached the screens as an unclassifiable failure. A visitor
|
||||||
|
* following a link to a project that no longer exists got the terminal error
|
||||||
|
* surface instead of a not-found page, and no gate noticed, because a strict
|
||||||
|
* enum checked against the wrong surface's contract still looks strict.
|
||||||
|
*
|
||||||
|
* Each caller now passes the enum from its own contract.
|
||||||
|
*/
|
||||||
|
export const envelopeError = (
|
||||||
|
codes: readonly string[] = STUDIO_ERROR_CODES as readonly string[],
|
||||||
|
schemaId = "StudioErrorEnvelope",
|
||||||
|
): RuntimeValidator<ProblemDetails> =>
|
||||||
zodValidator<ProblemDetails>(
|
zodValidator<ProblemDetails>(
|
||||||
"StudioErrorEnvelope",
|
schemaId,
|
||||||
z
|
z
|
||||||
.object({ success: z.literal(false), error: apiErrorSchema, meta: metaSchema })
|
.object({ success: z.literal(false), error: apiErrorSchema(codes), meta: metaSchema })
|
||||||
.loose()
|
.loose()
|
||||||
.transform((envelope) => ({
|
.transform((envelope) => ({
|
||||||
type: `https://techlog.local/problems/${envelope.error.code.toLowerCase().replaceAll("_", "-")}`,
|
type: `https://techlog.local/problems/${envelope.error.code.toLowerCase().replaceAll("_", "-")}`,
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import type { RecordKind } from "../../../application/ports/public-content-queries.ts";
|
import type { RecordKind } from "../../../application/ports/public-content-queries.ts";
|
||||||
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
|
import { usePublicContent } from "../use-public-content.tsx";
|
||||||
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
|
|
||||||
|
|
||||||
export function ExploreFilterForm({
|
export function ExploreFilterForm({
|
||||||
action,
|
action,
|
||||||
@@ -18,20 +17,34 @@ export function ExploreFilterForm({
|
|||||||
showType?: boolean;
|
showType?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
|
// This form sits inside a page that renders its own loading state, so it does
|
||||||
const publicRecords = publicContent.listRecords();
|
// not hand back a fallback of its own — that would put a second skeleton
|
||||||
const topics = [...new Set(publicRecords.map((record) => record.topic))].sort();
|
// inside a screen already showing one, and move the layout under it. It
|
||||||
const projectPrefix = "/projects/";
|
// renders its real structure immediately with empty option lists and fills
|
||||||
const projects = publicContent
|
// them in when the catalog arrives.
|
||||||
.searchPublicContent("")
|
const view = usePublicContent(["tech-log", "explore-filters"], async (queries) => {
|
||||||
.filter((entity) => entity.contentType === "PROJECT")
|
const projectPrefix = "/projects/";
|
||||||
.flatMap((entity) => {
|
const [records, entities] = await Promise.all([
|
||||||
if (!entity.path.startsWith(projectPrefix)) return [];
|
queries.listRecords(),
|
||||||
const item = publicContent.getProject(
|
queries.searchPublicContent(""),
|
||||||
decodeURIComponent(entity.path.slice(projectPrefix.length)),
|
]);
|
||||||
|
const projectSlugs = entities
|
||||||
|
.filter((entity) => entity.contentType === "PROJECT")
|
||||||
|
.flatMap((entity) =>
|
||||||
|
entity.path.startsWith(projectPrefix)
|
||||||
|
? [decodeURIComponent(entity.path.slice(projectPrefix.length))]
|
||||||
|
: [],
|
||||||
);
|
);
|
||||||
return item ? [{ slug: item.slug, title: item.title }] : [];
|
const resolved = await Promise.all(projectSlugs.map((slug) => queries.getProject(slug)));
|
||||||
});
|
return {
|
||||||
|
topics: [...new Set(records.map((record) => record.topic))].sort(),
|
||||||
|
projects: resolved
|
||||||
|
.filter((item) => item !== undefined)
|
||||||
|
.map((item) => ({ slug: item.slug, title: item.title })),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const topics = view.data?.topics ?? [];
|
||||||
|
const projects = view.data?.projects ?? [];
|
||||||
const normalizedTopic = topic?.toLocaleLowerCase("ko-KR");
|
const normalizedTopic = topic?.toLocaleLowerCase("ko-KR");
|
||||||
const selectedTopic = topics.find(
|
const selectedTopic = topics.find(
|
||||||
(item) => item.toLocaleLowerCase("ko-KR") === normalizedTopic,
|
(item) => item.toLocaleLowerCase("ko-KR") === normalizedTopic,
|
||||||
@@ -43,7 +56,11 @@ export function ExploreFilterForm({
|
|||||||
item.title.toLocaleLowerCase("ko-KR") === normalizedProject,
|
item.title.toLocaleLowerCase("ko-KR") === normalizedProject,
|
||||||
)?.slug;
|
)?.slug;
|
||||||
const hasActiveFilter = Boolean((showType && kind) || topic || project);
|
const hasActiveFilter = Boolean((showType && kind) || topic || project);
|
||||||
const formKey = [kind, topic, project, showType].join(":");
|
// 선택지는 나중에 도착하는데 아래 select 들은 `defaultValue` 를 쓰는 비제어 요소다 —
|
||||||
|
// 첫 렌더에는 맞출 option 이 없어 값이 비어버린다. 도착 여부를 키에 넣어 그때 폼을
|
||||||
|
// 다시 마운트시키면 defaultValue 가 적용된다. 제어 요소로 바꾸지 않는 이유는 이 폼이
|
||||||
|
// submit 으로 URL 을 만드는 구조라 값의 주인이 URL 이기 때문이다.
|
||||||
|
const formKey = [kind, topic, project, showType, view.ready].join(":");
|
||||||
|
|
||||||
function submit(event: React.FormEvent<HTMLFormElement>) {
|
function submit(event: React.FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { useId, useRef, useState } from "react";
|
import { useId, useRef, useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
|
import { usePublicContent } from "../use-public-content.tsx";
|
||||||
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
|
|
||||||
|
|
||||||
type SearchDialogProps = {
|
type SearchDialogProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -19,8 +18,21 @@ export function SearchDialog({
|
|||||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const normalizedQuery = query.trim().toLocaleLowerCase("ko-KR");
|
const normalizedQuery = query.trim().toLocaleLowerCase("ko-KR");
|
||||||
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
|
// Keyed on the empty query, then filtered here, rather than one request per
|
||||||
const results = publicContent.searchPublicContent(normalizedQuery);
|
// keystroke. This is a type-ahead: re-querying per character would replace the
|
||||||
|
// result list with a loading skeleton on every key, which is a worse dialog
|
||||||
|
// than a stale-free local filter. The predicate is the same one the catalog
|
||||||
|
// applies for a non-empty query, so the visible result set is unchanged.
|
||||||
|
const view = usePublicContent(["tech-log", "search", "dialog"], async (queries) => ({
|
||||||
|
entities: await queries.searchPublicContent(""),
|
||||||
|
}));
|
||||||
|
const results = (view.data?.entities ?? []).filter((entity) =>
|
||||||
|
normalizedQuery
|
||||||
|
? [entity.title, entity.summary, entity.topic, entity.project, ...(entity.topics ?? [])]
|
||||||
|
.filter((value): value is string => Boolean(value))
|
||||||
|
.some((value) => value.toLocaleLowerCase("ko-KR").includes(normalizedQuery))
|
||||||
|
: true,
|
||||||
|
);
|
||||||
|
|
||||||
function open() {
|
function open() {
|
||||||
onBeforeOpen?.();
|
onBeforeOpen?.();
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
|
|
||||||
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
|
|
||||||
import {
|
import {
|
||||||
RegisteredNotFoundRoute,
|
RegisteredNotFoundRoute,
|
||||||
useRouteInput,
|
useRouteInput,
|
||||||
} from "../../../../../presentation/routes/route-input.tsx";
|
} from "../../../../../presentation/routes/route-input.tsx";
|
||||||
import { CaseDocumentPage } from "../components/case-document-page.tsx";
|
import { CaseDocumentPage } from "../components/case-document-page.tsx";
|
||||||
|
import { usePublicContent } from "../use-public-content.tsx";
|
||||||
|
|
||||||
function optionalString(value: unknown): string | undefined {
|
function optionalString(value: unknown): string | undefined {
|
||||||
return typeof value === "string" ? value : undefined;
|
return typeof value === "string" ? value : undefined;
|
||||||
@@ -14,9 +13,12 @@ export function CasePage() {
|
|||||||
const { params, search } = useRouteInput<"TECH_LOG_CASE">();
|
const { params, search } = useRouteInput<"TECH_LOG_CASE">();
|
||||||
const slug = optionalString(params.slug);
|
const slug = optionalString(params.slug);
|
||||||
const requestedState = optionalString(search.state);
|
const requestedState = optionalString(search.state);
|
||||||
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
|
const view = usePublicContent(["tech-log", "case", slug], async (queries) => ({
|
||||||
const record = slug ? publicContent.getRecord("CASE", slug) : undefined;
|
record: slug ? await queries.getRecord("CASE", slug) : undefined,
|
||||||
|
}));
|
||||||
|
if (!view.ready) return view.fallback;
|
||||||
|
|
||||||
|
const { record } = view.data;
|
||||||
if (!record) return <RegisteredNotFoundRoute />;
|
if (!record) return <RegisteredNotFoundRoute />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
|
|
||||||
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
|
|
||||||
import {
|
import {
|
||||||
RegisteredNotFoundRoute,
|
RegisteredNotFoundRoute,
|
||||||
useRouteInput,
|
useRouteInput,
|
||||||
} from "../../../../../presentation/routes/route-input.tsx";
|
} from "../../../../../presentation/routes/route-input.tsx";
|
||||||
import { ExploreFilterForm } from "../components/explore-filter-form.tsx";
|
import { ExploreFilterForm } from "../components/explore-filter-form.tsx";
|
||||||
import { PublicRecordList } from "../components/public-record-list.tsx";
|
import { PublicRecordList } from "../components/public-record-list.tsx";
|
||||||
|
import { usePublicContent } from "../use-public-content.tsx";
|
||||||
|
|
||||||
const kinds = {
|
const kinds = {
|
||||||
cases: { kind: "CASE", title: "Case", description: "문제를 재현하고 관찰한 값에서 설계 결론까지 따라갑니다." },
|
cases: { kind: "CASE", title: "Case", description: "문제를 재현하고 관찰한 값에서 설계 결론까지 따라갑니다." },
|
||||||
@@ -28,18 +27,29 @@ function getKindConfig(value: string | undefined) {
|
|||||||
|
|
||||||
export function ExploreKindPage() {
|
export function ExploreKindPage() {
|
||||||
const { params, search } = useRouteInput<"TECH_LOG_EXPLORE_KIND">();
|
const { params, search } = useRouteInput<"TECH_LOG_EXPLORE_KIND">();
|
||||||
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
|
|
||||||
const kind = optionalString(params.kind);
|
const kind = optionalString(params.kind);
|
||||||
const config = getKindConfig(kind);
|
const config = getKindConfig(kind);
|
||||||
if (!config) return <RegisteredNotFoundRoute />;
|
|
||||||
const topic = optionalString(search.topic);
|
const topic = optionalString(search.topic);
|
||||||
const project = optionalString(search.project);
|
const project = optionalString(search.project);
|
||||||
const records = publicContent.listRecords({
|
// The unknown-kind check reads as an early return, but it cannot come before
|
||||||
kind: config.kind,
|
// the query: hooks run unconditionally or React loses the call order. The
|
||||||
...(topic ? { topic } : {}),
|
// loader short-circuits instead, and the not-found route is chosen below.
|
||||||
...(project ? { project } : {}),
|
const view = usePublicContent(
|
||||||
});
|
["tech-log", "explore-kind", config?.kind, topic, project],
|
||||||
|
async (queries) => ({
|
||||||
|
records: config
|
||||||
|
? await queries.listRecords({
|
||||||
|
kind: config.kind,
|
||||||
|
...(topic ? { topic } : {}),
|
||||||
|
...(project ? { project } : {}),
|
||||||
|
})
|
||||||
|
: [],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (!config) return <RegisteredNotFoundRoute />;
|
||||||
|
if (!view.ready) return view.fallback;
|
||||||
|
|
||||||
|
const { records } = view.data;
|
||||||
return <main id="main-content" className="shell public-index-page">
|
return <main id="main-content" className="shell public-index-page">
|
||||||
<header className="public-page-header"><p className="section-kicker">Explore</p><h1>{config.title}</h1><p>{config.description}</p></header>
|
<header className="public-page-header"><p className="section-kicker">Explore</p><h1>{config.title}</h1><p>{config.description}</p></header>
|
||||||
<ExploreFilterForm action={`/explore/${kind}`} topic={topic} project={project} showType={false} />
|
<ExploreFilterForm action={`/explore/${kind}`} topic={topic} project={project} showType={false} />
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import type { RecordKind } from "../../../application/ports/public-content-queries.ts";
|
import type { RecordKind } from "../../../application/ports/public-content-queries.ts";
|
||||||
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
|
|
||||||
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
|
|
||||||
import { useRouteInput } from "../../../../../presentation/routes/route-input.tsx";
|
import { useRouteInput } from "../../../../../presentation/routes/route-input.tsx";
|
||||||
import { ExploreFilterForm } from "../components/explore-filter-form.tsx";
|
import { ExploreFilterForm } from "../components/explore-filter-form.tsx";
|
||||||
import { PublicRecordList } from "../components/public-record-list.tsx";
|
import { PublicRecordList } from "../components/public-record-list.tsx";
|
||||||
|
import { usePublicContent } from "../use-public-content.tsx";
|
||||||
|
|
||||||
function optionalString(value: unknown): string | undefined {
|
function optionalString(value: unknown): string | undefined {
|
||||||
return typeof value === "string" ? value : undefined;
|
return typeof value === "string" ? value : undefined;
|
||||||
@@ -17,13 +16,19 @@ export function ExplorePage() {
|
|||||||
const kind = (["CASE", "REFERENCE", "QUESTION"] as const).find(
|
const kind = (["CASE", "REFERENCE", "QUESTION"] as const).find(
|
||||||
(item) => item === requestedKind,
|
(item) => item === requestedKind,
|
||||||
) satisfies RecordKind | undefined;
|
) satisfies RecordKind | undefined;
|
||||||
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
|
const view = usePublicContent(
|
||||||
const records = publicContent.listRecords({
|
["tech-log", "explore", kind, topic, project],
|
||||||
...(kind ? { kind } : {}),
|
async (queries) => ({
|
||||||
...(topic ? { topic } : {}),
|
records: await queries.listRecords({
|
||||||
...(project ? { project } : {}),
|
...(kind ? { kind } : {}),
|
||||||
});
|
...(topic ? { topic } : {}),
|
||||||
|
...(project ? { project } : {}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (!view.ready) return view.fallback;
|
||||||
|
|
||||||
|
const { records } = view.data;
|
||||||
return <main id="main-content" className="shell public-index-page">
|
return <main id="main-content" className="shell public-index-page">
|
||||||
<header className="public-page-header"><p className="section-kicker">Explore</p><h1>탐색</h1><p>유형과 기술 주제, 프로젝트를 조합해 공개 기록을 찾습니다.</p></header>
|
<header className="public-page-header"><p className="section-kicker">Explore</p><h1>탐색</h1><p>유형과 기술 주제, 프로젝트를 조합해 공개 기록을 찾습니다.</p></header>
|
||||||
<ExploreFilterForm action="/explore" kind={kind} topic={topic} project={project} />
|
<ExploreFilterForm action="/explore" kind={kind} topic={topic} project={project} />
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
import type { PublicContentQueries } from "../../../application/ports/public-content-queries.ts";
|
import type { PublicContentQueries } from "../../../application/ports/public-content-queries.ts";
|
||||||
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
|
|
||||||
import { publicSiteConfig } from "../../../contracts/public-site-config.ts";
|
import { publicSiteConfig } from "../../../contracts/public-site-config.ts";
|
||||||
import { normalizeFocus } from "../../../domain/public/focus-state.ts";
|
import { normalizeFocus } from "../../../domain/public/focus-state.ts";
|
||||||
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
|
|
||||||
import { useRouteInput } from "../../../../../presentation/routes/route-input.tsx";
|
import { useRouteInput } from "../../../../../presentation/routes/route-input.tsx";
|
||||||
|
import { usePublicContent } from "../use-public-content.tsx";
|
||||||
import { FatalErrorState } from "../components/fatal-error-state.tsx";
|
import { FatalErrorState } from "../components/fatal-error-state.tsx";
|
||||||
import { HomeFocus } from "../components/home-focus.tsx";
|
import { HomeFocus } from "../components/home-focus.tsx";
|
||||||
import {
|
import {
|
||||||
@@ -40,12 +39,14 @@ function optionalString(value: unknown): string | undefined {
|
|||||||
return typeof value === "string" ? value : undefined;
|
return typeof value === "string" ? value : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLatestEntries(publicContent: PublicContentQueries): LatestEntry[] {
|
async function getLatestEntries(
|
||||||
const publicRecords = publicContent.listRecords();
|
publicContent: PublicContentQueries,
|
||||||
|
): Promise<LatestEntry[]> {
|
||||||
|
const publicRecords = await publicContent.listRecords();
|
||||||
const publicRecordByPath = new Map(
|
const publicRecordByPath = new Map(
|
||||||
publicRecords.map((record) => [record.path, record]),
|
publicRecords.map((record) => [record.path, record]),
|
||||||
);
|
);
|
||||||
const searchableEntities = publicContent.searchPublicContent("");
|
const searchableEntities = await publicContent.searchPublicContent("");
|
||||||
const projectPrefix = "/projects/";
|
const projectPrefix = "/projects/";
|
||||||
const projectSlugs = searchableEntities
|
const projectSlugs = searchableEntities
|
||||||
.filter((entity) => entity.contentType === "PROJECT")
|
.filter((entity) => entity.contentType === "PROJECT")
|
||||||
@@ -54,10 +55,16 @@ function getLatestEntries(publicContent: PublicContentQueries): LatestEntry[] {
|
|||||||
? [decodeURIComponent(entity.path.slice(projectPrefix.length))]
|
? [decodeURIComponent(entity.path.slice(projectPrefix.length))]
|
||||||
: [],
|
: [],
|
||||||
);
|
);
|
||||||
const projectTimeline = projectSlugs.flatMap((projectSlug) => {
|
// One project at a time would serialise a request per project; issuing them
|
||||||
const project = publicContent.getProject(projectSlug);
|
// together keeps the timeline's cost at its slowest project rather than their
|
||||||
if (!project) return [];
|
// sum. The flatten below restores the original single-list shape.
|
||||||
return publicContent.getProjectActivity(projectSlug).map((activity) => {
|
const projectTimeline = (
|
||||||
|
await Promise.all(
|
||||||
|
projectSlugs.map(async (projectSlug) => {
|
||||||
|
const project = await publicContent.getProject(projectSlug);
|
||||||
|
if (!project) return [];
|
||||||
|
const activities = await publicContent.getProjectActivity(projectSlug);
|
||||||
|
return activities.map((activity) => {
|
||||||
const record = publicRecordByPath.get(
|
const record = publicRecordByPath.get(
|
||||||
activity.recordPath ?? activity.path,
|
activity.recordPath ?? activity.path,
|
||||||
);
|
);
|
||||||
@@ -77,20 +84,24 @@ function getLatestEntries(publicContent: PublicContentQueries): LatestEntry[] {
|
|||||||
topic: record?.topic ?? project.topics[0] ?? "",
|
topic: record?.topic ?? project.topics[0] ?? "",
|
||||||
project: project.title,
|
project: project.title,
|
||||||
path: activity.path,
|
path: activity.path,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
});
|
}),
|
||||||
const releaseTimeline = searchableEntities
|
)
|
||||||
.filter((entity) => entity.contentType === "RELEASE")
|
).flat();
|
||||||
.flatMap((entity) => {
|
const releaseTimeline = (
|
||||||
const prefix = "/releases/";
|
await Promise.all(
|
||||||
if (!entity.path.startsWith(prefix)) return [];
|
searchableEntities
|
||||||
const release = publicContent.getRelease(
|
.filter((entity) => entity.contentType === "RELEASE")
|
||||||
decodeURIComponent(entity.path.slice(prefix.length)),
|
.map(async (entity) => {
|
||||||
);
|
const prefix = "/releases/";
|
||||||
if (!release) return [];
|
if (!entity.path.startsWith(prefix)) return [];
|
||||||
return [
|
const release = await publicContent.getRelease(
|
||||||
{
|
decodeURIComponent(entity.path.slice(prefix.length)),
|
||||||
|
);
|
||||||
|
if (!release) return [];
|
||||||
|
return [
|
||||||
|
{
|
||||||
id: `release-${release.version}`,
|
id: `release-${release.version}`,
|
||||||
typeLabel: "RELEASE",
|
typeLabel: "RELEASE",
|
||||||
title: release.title,
|
title: release.title,
|
||||||
@@ -99,10 +110,12 @@ function getLatestEntries(publicContent: PublicContentQueries): LatestEntry[] {
|
|||||||
dateTime: release.publishedAt,
|
dateTime: release.publishedAt,
|
||||||
topic: "TechLog",
|
topic: "TechLog",
|
||||||
project: "TechLog",
|
project: "TechLog",
|
||||||
path: release.path,
|
path: release.path,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
});
|
}),
|
||||||
|
)
|
||||||
|
).flat();
|
||||||
|
|
||||||
return [...projectTimeline, ...releaseTimeline].sort((left, right) =>
|
return [...projectTimeline, ...releaseTimeline].sort((left, right) =>
|
||||||
right.dateTime.localeCompare(left.dateTime),
|
right.dateTime.localeCompare(left.dateTime),
|
||||||
@@ -113,8 +126,16 @@ export function HomePage() {
|
|||||||
const { search } = useRouteInput<"TECH_LOG_HOME">();
|
const { search } = useRouteInput<"TECH_LOG_HOME">();
|
||||||
const requestedKey = optionalString(search.focus);
|
const requestedKey = optionalString(search.focus);
|
||||||
const requestedState = optionalString(search.state);
|
const requestedState = optionalString(search.state);
|
||||||
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
|
const view = usePublicContent(["tech-log", "home"], async (queries) => {
|
||||||
const focusItems = publicContent.getHomeFocusItems();
|
const [focusItems, latestEntries] = await Promise.all([
|
||||||
|
queries.getHomeFocusItems(),
|
||||||
|
getLatestEntries(queries),
|
||||||
|
]);
|
||||||
|
return { focusItems, latestEntries };
|
||||||
|
});
|
||||||
|
if (!view.ready) return view.fallback;
|
||||||
|
|
||||||
|
const { focusItems, latestEntries } = view.data;
|
||||||
const availableFocusItems = requestedState === "focus-empty" ? [] : focusItems;
|
const availableFocusItems = requestedState === "focus-empty" ? [] : focusItems;
|
||||||
const normalizedKey = normalizeFocus(
|
const normalizedKey = normalizeFocus(
|
||||||
requestedKey,
|
requestedKey,
|
||||||
@@ -131,8 +152,6 @@ export function HomePage() {
|
|||||||
return <FatalErrorState traceId="PREVIEW-HOME-500" />;
|
return <FatalErrorState traceId="PREVIEW-HOME-500" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const latestEntries = getLatestEntries(publicContent);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main id="main-content">
|
<main id="main-content">
|
||||||
<section className="shell home-identity" aria-labelledby="home-title">
|
<section className="shell home-identity" aria-labelledby="home-title">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Link } from "react-router-dom";
|
|||||||
|
|
||||||
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
|
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
|
||||||
import { publicSiteConfig } from "../../../contracts/public-site-config.ts";
|
import { publicSiteConfig } from "../../../contracts/public-site-config.ts";
|
||||||
|
import { usePublicContent } from "../use-public-content.tsx";
|
||||||
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
|
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
|
||||||
|
|
||||||
const principles = [
|
const principles = [
|
||||||
@@ -22,16 +23,31 @@ const principles = [
|
|||||||
},
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const currentProjectSlugs = ["backend-skeleton", "auth-lab"] as const;
|
|
||||||
const topics = ["Backend Architecture", "JPA", "Authentication", "Redis"] as const;
|
|
||||||
|
|
||||||
export function ProfilePage() {
|
export function ProfilePage() {
|
||||||
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
|
// The two project slugs this named were the static fixture's, and they exist
|
||||||
const currentProjects = currentProjectSlugs.flatMap((slug) => {
|
// in no real deployment — the page asked the backend for them, took two 404s,
|
||||||
const project = publicContent.getProject(slug);
|
// and rendered nothing but an error. "Current projects" means the published
|
||||||
return project ? [project] : [];
|
// ones, so read them from the catalogue the projects index already reads.
|
||||||
|
const view = usePublicContent(["tech-log", "profile"], async (queries) => {
|
||||||
|
const entries = (await queries.searchPublicContent("")).filter(
|
||||||
|
(item) => item.contentType === "PROJECT",
|
||||||
|
);
|
||||||
|
const [resolved, topics] = await Promise.all([
|
||||||
|
Promise.all(entries.map((item) => queries.getProject(item.path.replace("/projects/", "")))),
|
||||||
|
queries.listTopics(),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
currentProjects: resolved.filter((project) => project !== undefined),
|
||||||
|
topics,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Only the project list comes from the network. Returning the page-wide
|
||||||
|
// fallback here — as every public screen did — held the operator's name, the
|
||||||
|
// principles, and the topics behind a request that has nothing to do with
|
||||||
|
// them, so a visitor saw a skeleton, then possibly an error, where the page
|
||||||
|
// could have been readable the whole time. The markup below is unchanged;
|
||||||
|
// the fallback now sits in the one section that is actually waiting.
|
||||||
return (
|
return (
|
||||||
<main id="main-content" className="shell profile-page">
|
<main id="main-content" className="shell profile-page">
|
||||||
<header className="profile-header">
|
<header className="profile-header">
|
||||||
@@ -61,29 +77,45 @@ export function ProfilePage() {
|
|||||||
<p className="section-kicker">Current</p>
|
<p className="section-kicker">Current</p>
|
||||||
<h2 id="profile-projects-title">현재 프로젝트</h2>
|
<h2 id="profile-projects-title">현재 프로젝트</h2>
|
||||||
</div>
|
</div>
|
||||||
<ul>
|
{!view.ready ? (
|
||||||
{currentProjects.map((project) => (
|
view.fallback
|
||||||
<li key={project.slug}>
|
) : view.data.currentProjects.length === 0 ? (
|
||||||
<Link to={`/projects/${project.slug}`}>
|
<p className="public-empty-note">아직 공개된 프로젝트가 없습니다.</p>
|
||||||
<div>
|
) : (
|
||||||
<strong>{project.title}</strong>
|
<ul>
|
||||||
<span>{project.stage}</span>
|
{view.data.currentProjects.map((project) => (
|
||||||
</div>
|
<li key={project.slug}>
|
||||||
<p>{project.currentGoal}</p>
|
<Link to={`/projects/${project.slug}`}>
|
||||||
<span aria-hidden="true">↗</span>
|
<div>
|
||||||
</Link>
|
<strong>{project.title}</strong>
|
||||||
</li>
|
<span>{project.stage}</span>
|
||||||
))}
|
</div>
|
||||||
</ul>
|
<p>{project.currentGoal}</p>
|
||||||
|
<span aria-hidden="true">↗</span>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
{/*
|
||||||
|
이 목록은 코드에 네 개가 박혀 있었다 — Studio 에서 주제를 만들거나 지워도 프로필은
|
||||||
|
그대로였고, 고치려면 배포를 다시 해야 했다. 이제 공개 주제 목록을 그대로 그린다.
|
||||||
|
*/}
|
||||||
<section className="profile-topics" aria-labelledby="profile-topics-title">
|
<section className="profile-topics" aria-labelledby="profile-topics-title">
|
||||||
<p className="section-kicker">Topics</p>
|
<p className="section-kicker">Topics</p>
|
||||||
<h2 id="profile-topics-title">주요 관심 주제</h2>
|
<h2 id="profile-topics-title">주요 관심 주제</h2>
|
||||||
<ul>
|
{!view.ready ? (
|
||||||
{topics.map((topic) => (
|
view.fallback
|
||||||
<li key={topic}>{topic}</li>
|
) : view.data.topics.length === 0 ? (
|
||||||
))}
|
<p className="public-empty-note">아직 등록한 주제가 없습니다.</p>
|
||||||
</ul>
|
) : (
|
||||||
|
<ul>
|
||||||
|
{view.data.topics.map((topic) => (
|
||||||
|
<li key={topic.slug}>{topic.name}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,22 +1,26 @@
|
|||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
|
|
||||||
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
|
|
||||||
import {
|
import {
|
||||||
RegisteredNotFoundRoute,
|
RegisteredNotFoundRoute,
|
||||||
useRouteInput,
|
useRouteInput,
|
||||||
} from "../../../../../presentation/routes/route-input.tsx";
|
} from "../../../../../presentation/routes/route-input.tsx";
|
||||||
import { ProjectPageHeader } from "../components/project-page-header.tsx";
|
import { ProjectPageHeader } from "../components/project-page-header.tsx";
|
||||||
|
import { usePublicContent } from "../use-public-content.tsx";
|
||||||
|
|
||||||
export function ProjectActivityPage() {
|
export function ProjectActivityPage() {
|
||||||
const { params } = useRouteInput<"TECH_LOG_PROJECT_ACTIVITY">();
|
const { params } = useRouteInput<"TECH_LOG_PROJECT_ACTIVITY">();
|
||||||
const slug = typeof params.slug === "string" ? params.slug : "";
|
const slug = typeof params.slug === "string" ? params.slug : "";
|
||||||
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
|
const view = usePublicContent(["tech-log", "project", slug, "activity"], async (queries) => {
|
||||||
const project = publicContent.getProject(slug);
|
const project = await queries.getProject(slug);
|
||||||
|
return project
|
||||||
|
? { project, activity: await queries.getProjectActivity(slug) }
|
||||||
|
: { project: undefined, activity: [] };
|
||||||
|
});
|
||||||
|
if (!view.ready) return view.fallback;
|
||||||
|
|
||||||
|
const { project, activity } = view.data;
|
||||||
if (!project) return <RegisteredNotFoundRoute />;
|
if (!project) return <RegisteredNotFoundRoute />;
|
||||||
|
|
||||||
const activity = publicContent.getProjectActivity(slug);
|
|
||||||
return (
|
return (
|
||||||
<main id="main-content" className="shell project-page">
|
<main id="main-content" className="shell project-page">
|
||||||
<ProjectPageHeader project={project} title={`${project.title} 활동`} />
|
<ProjectPageHeader project={project} title={`${project.title} 활동`} />
|
||||||
|
|||||||
@@ -1,22 +1,26 @@
|
|||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
|
|
||||||
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
|
|
||||||
import {
|
import {
|
||||||
RegisteredNotFoundRoute,
|
RegisteredNotFoundRoute,
|
||||||
useRouteInput,
|
useRouteInput,
|
||||||
} from "../../../../../presentation/routes/route-input.tsx";
|
} from "../../../../../presentation/routes/route-input.tsx";
|
||||||
import { ProjectPageHeader } from "../components/project-page-header.tsx";
|
import { ProjectPageHeader } from "../components/project-page-header.tsx";
|
||||||
|
import { usePublicContent } from "../use-public-content.tsx";
|
||||||
|
|
||||||
export function ProjectDecisionsPage() {
|
export function ProjectDecisionsPage() {
|
||||||
const { params } = useRouteInput<"TECH_LOG_PROJECT_DECISIONS">();
|
const { params } = useRouteInput<"TECH_LOG_PROJECT_DECISIONS">();
|
||||||
const slug = typeof params.slug === "string" ? params.slug : "";
|
const slug = typeof params.slug === "string" ? params.slug : "";
|
||||||
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
|
const view = usePublicContent(["tech-log", "project", slug, "decisions"], async (queries) => {
|
||||||
const project = publicContent.getProject(slug);
|
const project = await queries.getProject(slug);
|
||||||
|
return project
|
||||||
|
? { project, decisions: await queries.getProjectDecisions(slug) }
|
||||||
|
: { project: undefined, decisions: [] };
|
||||||
|
});
|
||||||
|
if (!view.ready) return view.fallback;
|
||||||
|
|
||||||
|
const { project, decisions } = view.data;
|
||||||
if (!project) return <RegisteredNotFoundRoute />;
|
if (!project) return <RegisteredNotFoundRoute />;
|
||||||
|
|
||||||
const decisions = publicContent.getProjectDecisions(slug);
|
|
||||||
return (
|
return (
|
||||||
<main id="main-content" className="shell project-page">
|
<main id="main-content" className="shell project-page">
|
||||||
<ProjectPageHeader project={project} title={`${project.title} 결정`} />
|
<ProjectPageHeader project={project} title={`${project.title} 결정`} />
|
||||||
|
|||||||
@@ -1,24 +1,34 @@
|
|||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
|
|
||||||
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
|
|
||||||
import {
|
import {
|
||||||
RegisteredNotFoundRoute,
|
RegisteredNotFoundRoute,
|
||||||
useRouteInput,
|
useRouteInput,
|
||||||
} from "../../../../../presentation/routes/route-input.tsx";
|
} from "../../../../../presentation/routes/route-input.tsx";
|
||||||
import { ProjectPageHeader } from "../components/project-page-header.tsx";
|
import { ProjectPageHeader } from "../components/project-page-header.tsx";
|
||||||
|
import { usePublicContent } from "../use-public-content.tsx";
|
||||||
|
|
||||||
export function ProjectOverviewPage() {
|
export function ProjectOverviewPage() {
|
||||||
const { params } = useRouteInput<"TECH_LOG_PROJECT">();
|
const { params } = useRouteInput<"TECH_LOG_PROJECT">();
|
||||||
const slug = typeof params.slug === "string" ? params.slug : "";
|
const slug = typeof params.slug === "string" ? params.slug : "";
|
||||||
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
|
const view = usePublicContent(["tech-log", "project", slug, "overview"], async (queries) => {
|
||||||
const project = publicContent.getProject(slug);
|
const project = await queries.getProject(slug);
|
||||||
|
if (!project) {
|
||||||
|
return { project: undefined, records: [], decisions: [], activity: [] };
|
||||||
|
}
|
||||||
|
// Three independent reads for one screen: issued together rather than in
|
||||||
|
// sequence, so the page waits for the slowest instead of their sum.
|
||||||
|
const [records, decisions, activity] = await Promise.all([
|
||||||
|
queries.getProjectRecords(slug),
|
||||||
|
queries.getProjectDecisions(slug),
|
||||||
|
queries.getProjectActivity(slug),
|
||||||
|
]);
|
||||||
|
return { project, records, decisions, activity };
|
||||||
|
});
|
||||||
|
if (!view.ready) return view.fallback;
|
||||||
|
|
||||||
|
const { project, records, decisions, activity } = view.data;
|
||||||
if (!project) return <RegisteredNotFoundRoute />;
|
if (!project) return <RegisteredNotFoundRoute />;
|
||||||
|
|
||||||
const records = publicContent.getProjectRecords(slug);
|
|
||||||
const decisions = publicContent.getProjectDecisions(slug);
|
|
||||||
const activity = publicContent.getProjectActivity(slug);
|
|
||||||
return (
|
return (
|
||||||
<main id="main-content" className="shell project-page">
|
<main id="main-content" className="shell project-page">
|
||||||
<ProjectPageHeader project={project} title={project.title} />
|
<ProjectPageHeader project={project} title={project.title} />
|
||||||
|
|||||||
@@ -1,21 +1,25 @@
|
|||||||
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
|
|
||||||
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
|
|
||||||
import {
|
import {
|
||||||
RegisteredNotFoundRoute,
|
RegisteredNotFoundRoute,
|
||||||
useRouteInput,
|
useRouteInput,
|
||||||
} from "../../../../../presentation/routes/route-input.tsx";
|
} from "../../../../../presentation/routes/route-input.tsx";
|
||||||
import { ProjectPageHeader } from "../components/project-page-header.tsx";
|
import { ProjectPageHeader } from "../components/project-page-header.tsx";
|
||||||
import { PublicRecordList } from "../components/public-record-list.tsx";
|
import { PublicRecordList } from "../components/public-record-list.tsx";
|
||||||
|
import { usePublicContent } from "../use-public-content.tsx";
|
||||||
|
|
||||||
export function ProjectRecordsPage() {
|
export function ProjectRecordsPage() {
|
||||||
const { params } = useRouteInput<"TECH_LOG_PROJECT_RECORDS">();
|
const { params } = useRouteInput<"TECH_LOG_PROJECT_RECORDS">();
|
||||||
const slug = typeof params.slug === "string" ? params.slug : "";
|
const slug = typeof params.slug === "string" ? params.slug : "";
|
||||||
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
|
const view = usePublicContent(["tech-log", "project", slug, "records"], async (queries) => {
|
||||||
const project = publicContent.getProject(slug);
|
const project = await queries.getProject(slug);
|
||||||
|
return project
|
||||||
|
? { project, records: await queries.getProjectRecords(slug) }
|
||||||
|
: { project: undefined, records: [] };
|
||||||
|
});
|
||||||
|
if (!view.ready) return view.fallback;
|
||||||
|
|
||||||
|
const { project, records } = view.data;
|
||||||
if (!project) return <RegisteredNotFoundRoute />;
|
if (!project) return <RegisteredNotFoundRoute />;
|
||||||
|
|
||||||
const records = publicContent.getProjectRecords(slug);
|
|
||||||
return (
|
return (
|
||||||
<main id="main-content" className="shell project-page">
|
<main id="main-content" className="shell project-page">
|
||||||
<ProjectPageHeader project={project} title={`${project.title} 기록`} />
|
<ProjectPageHeader project={project} title={`${project.title} 기록`} />
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
|
import { usePublicContent } from "../use-public-content.tsx";
|
||||||
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
|
|
||||||
|
|
||||||
export function ProjectsPage() {
|
export function ProjectsPage() {
|
||||||
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
|
const view = usePublicContent(["tech-log", "projects"], async (queries) => {
|
||||||
const projects = publicContent
|
const entries = (await queries.searchPublicContent("")).filter(
|
||||||
.searchPublicContent("")
|
(item) => item.contentType === "PROJECT",
|
||||||
.filter((item) => item.contentType === "PROJECT")
|
);
|
||||||
.flatMap((item) => {
|
const resolved = await Promise.all(
|
||||||
const project = publicContent.getProject(item.path.replace("/projects/", ""));
|
entries.map((item) => queries.getProject(item.path.replace("/projects/", ""))),
|
||||||
return project ? [project] : [];
|
);
|
||||||
});
|
return { projects: resolved.filter((project) => project !== undefined) };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Header first: it is fixed copy and owes the network nothing.
|
||||||
return (
|
return (
|
||||||
<main
|
<main
|
||||||
id="main-content"
|
id="main-content"
|
||||||
@@ -26,8 +27,13 @@ export function ProjectsPage() {
|
|||||||
봅니다.
|
봅니다.
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
{!view.ready ? (
|
||||||
|
view.fallback
|
||||||
|
) : view.data.projects.length === 0 ? (
|
||||||
|
<p className="public-empty-note">아직 공개된 프로젝트가 없습니다.</p>
|
||||||
|
) : (
|
||||||
<ol className="project-index-list">
|
<ol className="project-index-list">
|
||||||
{projects.map((project, index) => (
|
{view.data.projects.map((project, index) => (
|
||||||
<li key={project.slug}>
|
<li key={project.slug}>
|
||||||
<Link to={`/projects/${project.slug}`}>
|
<Link to={`/projects/${project.slug}`}>
|
||||||
<span>{String(index + 1).padStart(2, "0")}</span>
|
<span>{String(index + 1).padStart(2, "0")}</span>
|
||||||
@@ -53,6 +59,7 @@ export function ProjectsPage() {
|
|||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ol>
|
</ol>
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
|
|
||||||
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
|
|
||||||
import {
|
import {
|
||||||
RegisteredNotFoundRoute,
|
RegisteredNotFoundRoute,
|
||||||
useRouteInput,
|
useRouteInput,
|
||||||
} from "../../../../../presentation/routes/route-input.tsx";
|
} from "../../../../../presentation/routes/route-input.tsx";
|
||||||
import { QuestionDocumentPage } from "../components/question-document-page.tsx";
|
import { QuestionDocumentPage } from "../components/question-document-page.tsx";
|
||||||
|
import { usePublicContent } from "../use-public-content.tsx";
|
||||||
|
|
||||||
function optionalString(value: unknown): string | undefined {
|
function optionalString(value: unknown): string | undefined {
|
||||||
return typeof value === "string" ? value : undefined;
|
return typeof value === "string" ? value : undefined;
|
||||||
@@ -13,9 +12,15 @@ function optionalString(value: unknown): string | undefined {
|
|||||||
export function QuestionPage() {
|
export function QuestionPage() {
|
||||||
const { params } = useRouteInput<"TECH_LOG_QUESTION">();
|
const { params } = useRouteInput<"TECH_LOG_QUESTION">();
|
||||||
const slug = optionalString(params.slug);
|
const slug = optionalString(params.slug);
|
||||||
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
|
const view = usePublicContent(
|
||||||
const record = slug ? publicContent.getRecord("QUESTION", slug) : undefined;
|
["tech-log", "question", slug],
|
||||||
|
async (queries) => ({
|
||||||
|
record: slug ? await queries.getRecord("QUESTION", slug) : undefined,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (!view.ready) return view.fallback;
|
||||||
|
|
||||||
|
const { record } = view.data;
|
||||||
return record ? (
|
return record ? (
|
||||||
<QuestionDocumentPage record={record} />
|
<QuestionDocumentPage record={record} />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
|
|
||||||
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
|
|
||||||
import {
|
import {
|
||||||
RegisteredNotFoundRoute,
|
RegisteredNotFoundRoute,
|
||||||
useRouteInput,
|
useRouteInput,
|
||||||
} from "../../../../../presentation/routes/route-input.tsx";
|
} from "../../../../../presentation/routes/route-input.tsx";
|
||||||
import { ReferenceDocumentPage } from "../components/reference-document-page.tsx";
|
import { ReferenceDocumentPage } from "../components/reference-document-page.tsx";
|
||||||
|
import { usePublicContent } from "../use-public-content.tsx";
|
||||||
|
|
||||||
function optionalString(value: unknown): string | undefined {
|
function optionalString(value: unknown): string | undefined {
|
||||||
return typeof value === "string" ? value : undefined;
|
return typeof value === "string" ? value : undefined;
|
||||||
@@ -13,9 +12,15 @@ function optionalString(value: unknown): string | undefined {
|
|||||||
export function ReferencePage() {
|
export function ReferencePage() {
|
||||||
const { params } = useRouteInput<"TECH_LOG_REFERENCE">();
|
const { params } = useRouteInput<"TECH_LOG_REFERENCE">();
|
||||||
const slug = optionalString(params.slug);
|
const slug = optionalString(params.slug);
|
||||||
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
|
const view = usePublicContent(
|
||||||
const record = slug ? publicContent.getRecord("REFERENCE", slug) : undefined;
|
["tech-log", "reference", slug],
|
||||||
|
async (queries) => ({
|
||||||
|
record: slug ? await queries.getRecord("REFERENCE", slug) : undefined,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (!view.ready) return view.fallback;
|
||||||
|
|
||||||
|
const { record } = view.data;
|
||||||
return record ? (
|
return record ? (
|
||||||
<ReferenceDocumentPage record={record} />
|
<ReferenceDocumentPage record={record} />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,18 +1,20 @@
|
|||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
|
|
||||||
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
|
|
||||||
import {
|
import {
|
||||||
RegisteredNotFoundRoute,
|
RegisteredNotFoundRoute,
|
||||||
useRouteInput,
|
useRouteInput,
|
||||||
} from "../../../../../presentation/routes/route-input.tsx";
|
} from "../../../../../presentation/routes/route-input.tsx";
|
||||||
|
import { usePublicContent } from "../use-public-content.tsx";
|
||||||
|
|
||||||
export function ReleasePage() {
|
export function ReleasePage() {
|
||||||
const { params } = useRouteInput<"TECH_LOG_RELEASE">();
|
const { params } = useRouteInput<"TECH_LOG_RELEASE">();
|
||||||
const version = typeof params.version === "string" ? params.version : "";
|
const version = typeof params.version === "string" ? params.version : "";
|
||||||
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
|
const view = usePublicContent(["tech-log", "release", version], async (queries) => ({
|
||||||
const release = publicContent.getRelease(version);
|
release: await queries.getRelease(version),
|
||||||
|
}));
|
||||||
|
if (!view.ready) return view.fallback;
|
||||||
|
|
||||||
|
const { release } = view.data;
|
||||||
if (!release) return <RegisteredNotFoundRoute />;
|
if (!release) return <RegisteredNotFoundRoute />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,18 +1,21 @@
|
|||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
|
import { usePublicContent } from "../use-public-content.tsx";
|
||||||
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
|
|
||||||
|
|
||||||
export function ReleasesPage() {
|
export function ReleasesPage() {
|
||||||
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
|
const view = usePublicContent(["tech-log", "releases"], async (queries) => {
|
||||||
const releases = publicContent
|
const entries = (await queries.searchPublicContent("")).filter(
|
||||||
.searchPublicContent("")
|
(item) => item.contentType === "RELEASE",
|
||||||
.filter((item) => item.contentType === "RELEASE")
|
);
|
||||||
.flatMap((item) => {
|
const resolved = await Promise.all(
|
||||||
const release = publicContent.getRelease(item.path.replace("/releases/", ""));
|
entries.map((item) => queries.getRelease(item.path.replace("/releases/", ""))),
|
||||||
return release ? [release] : [];
|
);
|
||||||
});
|
return { releases: resolved.filter((release) => release !== undefined) };
|
||||||
|
});
|
||||||
|
|
||||||
|
// The header is fixed copy; only the list is a request. Returning the
|
||||||
|
// page-wide fallback here left a visitor with a skeleton — or an error —
|
||||||
|
// where the page's own explanation of itself could already be on screen.
|
||||||
return (
|
return (
|
||||||
<main
|
<main
|
||||||
id="main-content"
|
id="main-content"
|
||||||
@@ -26,8 +29,13 @@ export function ReleasesPage() {
|
|||||||
생겼는지 남깁니다.
|
생겼는지 남깁니다.
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
{!view.ready ? (
|
||||||
|
view.fallback
|
||||||
|
) : view.data.releases.length === 0 ? (
|
||||||
|
<p className="public-empty-note">아직 공개된 릴리즈가 없습니다.</p>
|
||||||
|
) : (
|
||||||
<ol className="release-index-list">
|
<ol className="release-index-list">
|
||||||
{releases.map((release) => (
|
{view.data.releases.map((release) => (
|
||||||
<li key={release.version}>
|
<li key={release.version}>
|
||||||
<Link to={release.path}>
|
<Link to={release.path}>
|
||||||
<div>
|
<div>
|
||||||
@@ -45,6 +53,7 @@ export function ReleasesPage() {
|
|||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ol>
|
</ol>
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
|
|
||||||
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
|
|
||||||
import { useRouteInput } from "../../../../../presentation/routes/route-input.tsx";
|
import { useRouteInput } from "../../../../../presentation/routes/route-input.tsx";
|
||||||
|
import { usePublicContent } from "../use-public-content.tsx";
|
||||||
|
|
||||||
const labels = { CASE: "Case", REFERENCE: "Reference", QUESTION: "Open Question", PROJECT: "Project", RELEASE: "Release" } as const;
|
const labels = { CASE: "Case", REFERENCE: "Reference", QUESTION: "Open Question", PROJECT: "Project", RELEASE: "Release" } as const;
|
||||||
|
|
||||||
@@ -14,8 +13,13 @@ export function SearchPage() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { search } = useRouteInput<"TECH_LOG_SEARCH">();
|
const { search } = useRouteInput<"TECH_LOG_SEARCH">();
|
||||||
const query = optionalString(search.q)?.trim() ?? "";
|
const query = optionalString(search.q)?.trim() ?? "";
|
||||||
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
|
// 키의 마지막 조각이 화면을 구분한다. 헤더의 검색 다이얼로그도 같은 카탈로그를 읽고
|
||||||
const results = publicContent.searchPublicContent(query);
|
// 빈 검색어일 때 앞 세 조각이 완전히 겹치는데, 두 화면이 담아 오는 모양이 다르다
|
||||||
|
// (여기는 `results`, 다이얼로그는 `entities`). 키가 같으면 react-query 가 한쪽 캐시를
|
||||||
|
// 다른 쪽에 돌려주고, 받는 쪽은 없는 필드를 읽다 렌더에서 죽는다.
|
||||||
|
const view = usePublicContent(["tech-log", "search", "page", query], async (queries) => ({
|
||||||
|
results: await queries.searchPublicContent(query),
|
||||||
|
}));
|
||||||
|
|
||||||
function submit(event: React.FormEvent<HTMLFormElement>) {
|
function submit(event: React.FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -25,6 +29,9 @@ export function SearchPage() {
|
|||||||
void navigate(`/search?q=${encodeURIComponent(nextQuery)}`);
|
void navigate(`/search?q=${encodeURIComponent(nextQuery)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!view.ready) return view.fallback;
|
||||||
|
|
||||||
|
const { results } = view.data;
|
||||||
return <main id="main-content" className="shell search-page">
|
return <main id="main-content" className="shell search-page">
|
||||||
<header className="public-page-header"><p className="section-kicker">Search</p><h1>검색</h1><p>제목과 요약, 주제, 프로젝트를 함께 검색합니다.</p></header>
|
<header className="public-page-header"><p className="section-kicker">Search</p><h1>검색</h1><p>제목과 요약, 주제, 프로젝트를 함께 검색합니다.</p></header>
|
||||||
<form key={query} className="search-page-form" action="/search" method="get" onSubmit={submit}><label><span className="visually-hidden">검색어</span><input type="search" name="q" defaultValue={query} placeholder="검색어를 입력하세요" /></label><button type="submit">검색</button></form>
|
<form key={query} className="search-page-form" action="/search" method="get" onSubmit={submit}><label><span className="visually-hidden">검색어</span><input type="search" name="q" defaultValue={query} placeholder="검색어를 입력하세요" /></label><button type="submit">검색</button></form>
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
|
|
||||||
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
|
|
||||||
import {
|
import {
|
||||||
RegisteredNotFoundRoute,
|
RegisteredNotFoundRoute,
|
||||||
useRouteInput,
|
useRouteInput,
|
||||||
} from "../../../../../presentation/routes/route-input.tsx";
|
} from "../../../../../presentation/routes/route-input.tsx";
|
||||||
import { PublicRecordList } from "../components/public-record-list.tsx";
|
import { PublicRecordList } from "../components/public-record-list.tsx";
|
||||||
|
import { usePublicContent } from "../use-public-content.tsx";
|
||||||
|
|
||||||
const topics = {
|
const topics = {
|
||||||
jpa: {
|
jpa: {
|
||||||
@@ -37,11 +36,17 @@ function topicConfig(value: unknown) {
|
|||||||
export function TopicPage() {
|
export function TopicPage() {
|
||||||
const { params } = useRouteInput<"TECH_LOG_TOPIC">();
|
const { params } = useRouteInput<"TECH_LOG_TOPIC">();
|
||||||
const topic = topicConfig(params.slug);
|
const topic = topicConfig(params.slug);
|
||||||
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
|
// Hooks run unconditionally, so the unknown-topic case is handled by the
|
||||||
|
// loader and the not-found route is chosen after it.
|
||||||
|
const view = usePublicContent(
|
||||||
|
["tech-log", "topic", topic?.title],
|
||||||
|
async (queries) =>
|
||||||
|
topic ? { records: await queries.listRecords({ topic: topic.title }) } : { records: [] },
|
||||||
|
);
|
||||||
if (!topic) return <RegisteredNotFoundRoute />;
|
if (!topic) return <RegisteredNotFoundRoute />;
|
||||||
|
if (!view.ready) return view.fallback;
|
||||||
|
|
||||||
const records = publicContent.listRecords({ topic: topic.title });
|
const { records } = view.data;
|
||||||
return (
|
return (
|
||||||
<main id="main-content" className="shell public-index-page">
|
<main id="main-content" className="shell public-index-page">
|
||||||
<header className="public-page-header">
|
<header className="public-page-header">
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export function PublicShell({ children, currentPath }: PublicShellProps) {
|
|||||||
<Link to={publicSiteConfig.contactPath}>
|
<Link to={publicSiteConfig.contactPath}>
|
||||||
{publicSiteConfig.contactLabel}
|
{publicSiteConfig.contactLabel}
|
||||||
</Link>
|
</Link>
|
||||||
<Link to={publicSiteConfig.latestRelease}>최신 Release</Link>
|
<Link to={publicSiteConfig.releasesPath}>변경 기록</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { useCallback, useMemo, type ReactNode } from "react";
|
||||||
|
|
||||||
|
import { createFailure } from "../../../../contracts/errors.ts";
|
||||||
|
import {
|
||||||
|
LoadingSurface,
|
||||||
|
TerminalErrorSurface,
|
||||||
|
} from "../../../../presentation/components/async-surface.tsx";
|
||||||
|
import { useApplicationQuery } from "../../../../presentation/adapters/query/index.ts";
|
||||||
|
import { useApplication } from "../../../../presentation/providers/application-provider.tsx";
|
||||||
|
import type { PublicContentQueries } from "../../application/ports/public-content-queries.ts";
|
||||||
|
import { TECH_LOG_FEATURE_ID } from "../../application/tech-log-feature-input.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One query per screen, not one per call.
|
||||||
|
*
|
||||||
|
* The public pages were written against a synchronous fixture, so they read
|
||||||
|
* whatever they needed inline — and several read in a loop: the home timeline
|
||||||
|
* walks every project for its activity, the explore filter walks search results
|
||||||
|
* to resolve project titles. Turning each of those into its own hook would mean
|
||||||
|
* a variable number of hooks per render, which React forbids outright.
|
||||||
|
*
|
||||||
|
* So a screen loads everything in one `execute`, where a loop is just a loop and
|
||||||
|
* `Promise.all` is available. The cost is that a screen waits for its slowest
|
||||||
|
* read; the benefit is that the page bodies keep computing from plain values and
|
||||||
|
* the markup is unchanged.
|
||||||
|
*
|
||||||
|
* The return is a discriminated union so a page can hand back `view.fallback`
|
||||||
|
* and have `view.data` narrow to present on the line after — without that, every
|
||||||
|
* page would need its own non-null assertion.
|
||||||
|
*/
|
||||||
|
export type PublicContentView<Value> =
|
||||||
|
| Readonly<{ ready: false; fallback: ReactNode; data?: undefined }>
|
||||||
|
| Readonly<{ ready: true; fallback: null; data: Value }>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `Value extends object` is load-bearing, not decoration. `undefined` is how the
|
||||||
|
* query layer says "no result yet", so a loader that returned the record itself
|
||||||
|
* would make a genuinely missing slug — `getRecord` resolving to `undefined` —
|
||||||
|
* indistinguishable from a request still in flight, and the page would sit on a
|
||||||
|
* loading skeleton instead of rendering its not-found route. Wrapping the
|
||||||
|
* screen's reads in an object keeps the two apart.
|
||||||
|
*/
|
||||||
|
export function usePublicContent<Value extends object>(
|
||||||
|
queryKey: readonly unknown[],
|
||||||
|
load: (queries: PublicContentQueries) => Promise<Value>,
|
||||||
|
): PublicContentView<Value> {
|
||||||
|
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
|
||||||
|
|
||||||
|
// `load` is a new closure every render, so depending on it would re-run the
|
||||||
|
// query forever. The key is the declared identity of the request — the same
|
||||||
|
// rule the rest of the query layer follows — so the key is what this closes
|
||||||
|
// over.
|
||||||
|
const execute = useCallback(
|
||||||
|
async () => {
|
||||||
|
try {
|
||||||
|
return { ok: true as const, value: await load(publicContent) };
|
||||||
|
} catch (cause) {
|
||||||
|
return { ok: false as const, error: failureFor(cause) };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- keyed by queryKey, see above
|
||||||
|
[publicContent, ...queryKey],
|
||||||
|
);
|
||||||
|
|
||||||
|
const query = useApplicationQuery<Value>(
|
||||||
|
useMemo(() => ({ queryKey, execute }), [execute, queryKey]),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (query.data !== undefined) {
|
||||||
|
return Object.freeze({ ready: true as const, fallback: null, data: query.data });
|
||||||
|
}
|
||||||
|
const failure = query.state.failure;
|
||||||
|
return Object.freeze({
|
||||||
|
ready: false as const,
|
||||||
|
fallback: failure ? (
|
||||||
|
<TerminalErrorSurface
|
||||||
|
userMessageKey={failure.userMessageKey}
|
||||||
|
action={failure.action}
|
||||||
|
onAction={() => void query.retry()}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<LoadingSurface />
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adapters throw. One that knows what went wrong attaches the classified
|
||||||
|
* failure to the error; anything else arriving here is a defect in this layer
|
||||||
|
* rather than a server condition, and is not reported as one.
|
||||||
|
*/
|
||||||
|
function failureFor(cause: unknown) {
|
||||||
|
const attached = (cause as { failure?: unknown } | null)?.failure;
|
||||||
|
if (isAppFailure(attached)) return attached;
|
||||||
|
return createFailure("UNKNOWN_CLIENT_FAILURE", "TECH_LOG_PUBLIC_CONTENT", 0, {
|
||||||
|
code: "PUBLIC_CONTENT_UNAVAILABLE",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAppFailure(
|
||||||
|
value: unknown,
|
||||||
|
): value is ReturnType<typeof createFailure> {
|
||||||
|
return (
|
||||||
|
typeof value === "object" &&
|
||||||
|
value !== null &&
|
||||||
|
typeof (value as { kind?: unknown }).kind === "string" &&
|
||||||
|
typeof (value as { code?: unknown }).code === "string"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,10 +8,44 @@ function assertNever(value: never): never {
|
|||||||
throw new Error(`Unsupported inline value: ${JSON.stringify(value)}`);
|
throw new Error(`Unsupported inline value: ${JSON.stringify(value)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 문단 안의 줄바꿈을 그대로 보여준다.
|
||||||
|
*
|
||||||
|
* <p>Markdown 은 한 번의 줄바꿈을 문단을 잇는 공백으로 읽는다. 파서는 그 줄바꿈을 텍스트에
|
||||||
|
* 남겨 두는데, 여기서 그대로 내보내면 HTML 이 다시 공백으로 접는다 — 작성자가 엔터로 나눠 쓴
|
||||||
|
* 글이 한 줄로 이어져 보였다. 미리보기만의 현상이 아니었다: 공개 화면도 같은 렌더러를 쓴다.
|
||||||
|
*
|
||||||
|
* <p>빈 줄로 나눈 문단은 파서가 이미 문단 둘로 만들어 두므로 여기 오지 않는다. 이 함수가 보는
|
||||||
|
* 것은 한 문단 안의 줄바꿈뿐이고, 작성자가 의도한 것도 그것이다.
|
||||||
|
*/
|
||||||
|
function renderText(text: string, key: number) {
|
||||||
|
const lines = text.split("\n");
|
||||||
|
if (lines.length === 1) return <Fragment key={key}>{text}</Fragment>;
|
||||||
|
return (
|
||||||
|
<Fragment key={key}>
|
||||||
|
{lines.map((line, index) => (
|
||||||
|
<Fragment key={index}>
|
||||||
|
{index > 0 ? <br /> : null}
|
||||||
|
{line}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작성자가 직접 쓴 평문. 요약·문제·결론·환경처럼 Markdown 을 거치지 않고 그대로 그려지는 칸들이
|
||||||
|
* 여기 온다 — 그 칸들은 텍스트 노드에 줄바꿈이 그대로 들어가고, HTML 이 그것을 공백으로 접어
|
||||||
|
* 작성자가 나눠 쓴 줄이 이어져 보였다. 본문(Markdown)만 고쳤을 때 이 칸들이 남아 있던 이유다.
|
||||||
|
*/
|
||||||
|
export function PlainText({ text }: { text: string }) {
|
||||||
|
return renderText(text, 0);
|
||||||
|
}
|
||||||
|
|
||||||
function renderInline(inline: Inline, key: number) {
|
function renderInline(inline: Inline, key: number) {
|
||||||
switch (inline.type) {
|
switch (inline.type) {
|
||||||
case "TEXT":
|
case "TEXT":
|
||||||
return <Fragment key={key}>{inline.text}</Fragment>;
|
return renderText(inline.text, key);
|
||||||
case "INLINE_CODE":
|
case "INLINE_CODE":
|
||||||
return <code key={key}>{inline.code}</code>;
|
return <code key={key}>{inline.code}</code>;
|
||||||
case "EMPHASIS":
|
case "EMPHASIS":
|
||||||
|
|||||||
+15
-14
@@ -2,6 +2,7 @@ import { Link } from "react-router-dom";
|
|||||||
|
|
||||||
import type { components } from "../../../contracts/studio/generated.ts";
|
import type { components } from "../../../contracts/studio/generated.ts";
|
||||||
import { inlinePlainText } from "../../../domain/content-format/inline-plain-text.ts";
|
import { inlinePlainText } from "../../../domain/content-format/inline-plain-text.ts";
|
||||||
|
import { PlainText } from "./inline-renderer.tsx";
|
||||||
import type {
|
import type {
|
||||||
ResolveEvidenceAsset,
|
ResolveEvidenceAsset,
|
||||||
ResolvePublishedLabel,
|
ResolvePublishedLabel,
|
||||||
@@ -92,7 +93,7 @@ function ModelDocumentHeader({
|
|||||||
) : null}
|
) : null}
|
||||||
</nav>
|
</nav>
|
||||||
<h1>{model.title}</h1>
|
<h1>{model.title}</h1>
|
||||||
<p>{model.summary}</p>
|
<p><PlainText text={model.summary} /></p>
|
||||||
<dl>
|
<dl>
|
||||||
<div>
|
<div>
|
||||||
<dt>유형</dt>
|
<dt>유형</dt>
|
||||||
@@ -149,21 +150,21 @@ function GenericCase({
|
|||||||
<section className="document-snapshot" aria-label="문제와 결론">
|
<section className="document-snapshot" aria-label="문제와 결론">
|
||||||
<div>
|
<div>
|
||||||
<p className="snapshot-label">문제</p>
|
<p className="snapshot-label">문제</p>
|
||||||
<p>{model.problem}</p>
|
<p><PlainText text={model.problem} /></p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="snapshot-label snapshot-label--answer">결론</p>
|
<p className="snapshot-label snapshot-label--answer">결론</p>
|
||||||
<p>{model.conclusion}</p>
|
<p><PlainText text={model.conclusion} /></p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<dl className="document-facts">
|
<dl className="document-facts">
|
||||||
<div>
|
<div>
|
||||||
<dt>검증 환경</dt>
|
<dt>검증 환경</dt>
|
||||||
<dd>{model.environment}</dd>
|
<dd><PlainText text={model.environment} /></dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt>검증 데이터</dt>
|
<dt>검증 데이터</dt>
|
||||||
<dd>{model.reproduction}</dd>
|
<dd><PlainText text={model.reproduction} /></dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
<article className="public-document-body">
|
<article className="public-document-body">
|
||||||
@@ -213,27 +214,27 @@ function FetchJoinCase({
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<h1>{model.title}</h1>
|
<h1>{model.title}</h1>
|
||||||
<p className="case-summary">{model.summary}</p>
|
<p className="case-summary"><PlainText text={model.summary} /></p>
|
||||||
|
|
||||||
<section className="case-snapshot" aria-label="문제와 결론">
|
<section className="case-snapshot" aria-label="문제와 결론">
|
||||||
<div>
|
<div>
|
||||||
<p className="snapshot-label">문제</p>
|
<p className="snapshot-label">문제</p>
|
||||||
<p>{model.problem}</p>
|
<p><PlainText text={model.problem} /></p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="snapshot-label snapshot-label--answer">결론</p>
|
<p className="snapshot-label snapshot-label--answer">결론</p>
|
||||||
<p>{model.conclusion}</p>
|
<p><PlainText text={model.conclusion} /></p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<dl className="case-meta">
|
<dl className="case-meta">
|
||||||
<div>
|
<div>
|
||||||
<dt>검증 환경</dt>
|
<dt>검증 환경</dt>
|
||||||
<dd>{model.environment}</dd>
|
<dd><PlainText text={model.environment} /></dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt>데이터셋</dt>
|
<dt>데이터셋</dt>
|
||||||
<dd>{model.reproduction.replace(/^Dataset:\s*/, "")}</dd>
|
<dd><PlainText text={model.reproduction.replace(/^Dataset:\s*/, "")} /></dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt>기록</dt>
|
<dt>기록</dt>
|
||||||
@@ -287,7 +288,7 @@ function ReferenceDocument({
|
|||||||
<section className="reference-purpose" aria-labelledby="purpose-title">
|
<section className="reference-purpose" aria-labelledby="purpose-title">
|
||||||
<p className="section-kicker">Purpose</p>
|
<p className="section-kicker">Purpose</p>
|
||||||
<h2 id="purpose-title">이 기준을 쓰는 이유</h2>
|
<h2 id="purpose-title">이 기준을 쓰는 이유</h2>
|
||||||
<p>{model.purpose}</p>
|
<p><PlainText text={model.purpose} /></p>
|
||||||
</section>
|
</section>
|
||||||
<article className="public-document-body reference-body">
|
<article className="public-document-body reference-body">
|
||||||
<section aria-labelledby="rules-title">
|
<section aria-labelledby="rules-title">
|
||||||
@@ -434,7 +435,7 @@ function QuestionDocument({
|
|||||||
>
|
>
|
||||||
<p className="section-kicker">Next</p>
|
<p className="section-kicker">Next</p>
|
||||||
<h2 id="next-validation-title">다음 검증</h2>
|
<h2 id="next-validation-title">다음 검증</h2>
|
||||||
<p>{model.nextValidation}</p>
|
<p><PlainText text={model.nextValidation} /></p>
|
||||||
</section>
|
</section>
|
||||||
</article>
|
</article>
|
||||||
<PublicDocumentRelations relations={publicRelations(model.relations)} />
|
<PublicDocumentRelations relations={publicRelations(model.relations)} />
|
||||||
@@ -476,11 +477,11 @@ function ProjectDecisionDocument({
|
|||||||
<time dateTime={model.decidedOn}>{displayDate(model.decidedOn)}</time>
|
<time dateTime={model.decidedOn}>{displayDate(model.decidedOn)}</time>
|
||||||
</div>
|
</div>
|
||||||
<h2>{model.title}</h2>
|
<h2>{model.title}</h2>
|
||||||
<p>{model.statement}</p>
|
<p><PlainText text={model.statement} /></p>
|
||||||
</header>
|
</header>
|
||||||
<section>
|
<section>
|
||||||
<h3>판단 이유</h3>
|
<h3>판단 이유</h3>
|
||||||
<p>{model.rationale}</p>
|
<p><PlainText text={model.rationale} /></p>
|
||||||
</section>
|
</section>
|
||||||
<section>
|
<section>
|
||||||
<h3>영향</h3>
|
<h3>영향</h3>
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { useEffect, useId, useState, type FormEvent } from "react";
|
|||||||
|
|
||||||
import type { Asset } from "../../../contracts/studio/contract.ts";
|
import type { Asset } from "../../../contracts/studio/contract.ts";
|
||||||
import type { StudioAssetGateway } from "../../../application/ports/studio-asset-gateway.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. */
|
/** One screenful of candidates; searching, not scrolling, reaches the rest. */
|
||||||
const PAGE_SIZE = 50;
|
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
|
// than fewer, and no trailing request after the author stops), and it is the
|
||||||
// pair `document-list.tsx` already uses for the same job.
|
// pair `document-list.tsx` already uses for the same job.
|
||||||
const [searchDraft, setSearchDraft] = useState("");
|
const [searchDraft, setSearchDraft] = useState("");
|
||||||
|
/**
|
||||||
|
* 삽입할 때 확대를 허용할지. 예전에는 {@code kind === "DIAGRAM"} 일 때만 켰는데, 작성자가
|
||||||
|
* 스크린샷을 ATTACHMENT 나 IMAGE 로 올리면 확대가 꺼진 채로 들어갔고 켜는 방법도 없었다 —
|
||||||
|
* "줌이 왜 꺼져 있는지 모르겠다" 가 그것이다. 그림이면 켜 두고, 끄고 싶으면 여기서 끈다.
|
||||||
|
*/
|
||||||
|
const [allowZoom, setAllowZoom] = useState(true);
|
||||||
|
const [removingId, setRemovingId] = useState<string | null>(null);
|
||||||
|
const [notice, setNotice] = useState("");
|
||||||
const [q, setQ] = useState("");
|
const [q, setQ] = useState("");
|
||||||
const searchId = useId();
|
const searchId = useId();
|
||||||
|
|
||||||
@@ -99,6 +109,27 @@ export function AssetPicker({
|
|||||||
setQ(searchDraft.trim());
|
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"
|
const listMessage = status === "LOADING"
|
||||||
? "Asset 목록을 불러오는 중입니다."
|
? "Asset 목록을 불러오는 중입니다."
|
||||||
: selectable.length > 0
|
: selectable.length > 0
|
||||||
@@ -124,6 +155,15 @@ export function AssetPicker({
|
|||||||
<button type="submit">검색</button>
|
<button type="submit">검색</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
<label className="asset-picker-option">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={allowZoom}
|
||||||
|
onChange={(event) => setAllowZoom(event.currentTarget.checked)}
|
||||||
|
/>
|
||||||
|
<span>삽입할 때 크게 보기 허용</span>
|
||||||
|
</label>
|
||||||
|
{notice ? <p className="studio-asset-picker-empty" role="status">{notice}</p> : null}
|
||||||
{status === "ERROR"
|
{status === "ERROR"
|
||||||
? <p className="studio-error" role="alert">Asset 목록을 불러오지 못했습니다.</p>
|
? <p className="studio-error" role="alert">Asset 목록을 불러오지 못했습니다.</p>
|
||||||
: <p className="studio-asset-picker-empty" role="status">{listMessage}</p>}
|
: <p className="studio-asset-picker-empty" role="status">{listMessage}</p>}
|
||||||
@@ -135,11 +175,23 @@ export function AssetPicker({
|
|||||||
assetKey: asset.assetKey,
|
assetKey: asset.assetKey,
|
||||||
alt: asset.decorative ? "" : (asset.altText ?? ""),
|
alt: asset.decorative ? "" : (asset.altText ?? ""),
|
||||||
caption: "",
|
caption: "",
|
||||||
zoom: asset.kind === "DIAGRAM",
|
zoom: allowZoom && asset.mediaType.startsWith("image/"),
|
||||||
}))}
|
}))}
|
||||||
>
|
>
|
||||||
{asset.assetKey}
|
{asset.assetKey}
|
||||||
</button>
|
</button>
|
||||||
|
{/*
|
||||||
|
문서를 쓰다가 잘못 올린 Asset 을 여기서 바로 지운다. 예전에는 Asset 화면으로 나가야
|
||||||
|
했고, 그러면 편집 중인 작업본을 떠나야 했다. 쓰이고 있는 Asset 은 서버가 거절한다.
|
||||||
|
*/}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="studio-secondary-button"
|
||||||
|
disabled={removingId !== null}
|
||||||
|
onClick={() => { void removeAsset(asset); }}
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</button>
|
||||||
</li>)}
|
</li>)}
|
||||||
</ul> : null}
|
</ul> : null}
|
||||||
</div>;
|
</div>;
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export function CommonDocumentFields({
|
|||||||
<div className="studio-editor-section-heading"><p className="studio-eyebrow">DOCUMENT</p><h2 id="studio-common-fields-title">기본 정보</h2></div>
|
<div className="studio-editor-section-heading"><p className="studio-eyebrow">DOCUMENT</p><h2 id="studio-common-fields-title">기본 정보</h2></div>
|
||||||
<div className="studio-field-grid">
|
<div className="studio-field-grid">
|
||||||
<label className="studio-field studio-field--wide"><span>제목</span><input value={draft.title} maxLength={120} onChange={(event) => onUpdate({ title: event.currentTarget.value })} /></label>
|
<label className="studio-field studio-field--wide"><span>제목</span><input value={draft.title} maxLength={120} onChange={(event) => onUpdate({ title: event.currentTarget.value })} /></label>
|
||||||
<label className="studio-field"><span>slug</span><input value={draft.slug} maxLength={100} onChange={(event) => onUpdate({ slug: event.currentTarget.value as WorkingCopyInput["slug"] })} /></label>
|
<label className="studio-field"><span>slug</span><input value={draft.slug} maxLength={100} placeholder="비우면 제목에서 만듭니다 (영문 소문자·숫자·하이픈)" onChange={(event) => onUpdate({ slug: event.currentTarget.value as WorkingCopyInput["slug"] })} /></label>
|
||||||
<label className="studio-field studio-field--wide"><span>요약</span><textarea value={draft.summary} maxLength={300} onChange={(event) => onUpdate({ summary: event.currentTarget.value })} /></label>
|
<label className="studio-field studio-field--wide"><span>요약</span><textarea value={draft.summary} maxLength={300} onChange={(event) => onUpdate({ summary: event.currentTarget.value })} /></label>
|
||||||
<label className="studio-field"><span>Topic</span><select value={draft.topicId ?? ""} onChange={(event) => onUpdate({ topicId: event.currentTarget.value || null })}><option value="">선택하지 않음</option>{topics.map((entry) => <option key={entry.id} value={entry.id}>{entry.label}</option>)}</select></label>
|
<label className="studio-field"><span>Topic</span><select value={draft.topicId ?? ""} onChange={(event) => onUpdate({ topicId: event.currentTarget.value || null })}><option value="">선택하지 않음</option>{topics.map((entry) => <option key={entry.id} value={entry.id}>{entry.label}</option>)}</select></label>
|
||||||
<label className="studio-field"><span>Project</span><select value={draft.projectId ?? ""} onChange={(event) => onUpdate({ projectId: event.currentTarget.value || null })}><option value="">미지정</option>{projects.map((entry) => <option key={entry.id} value={entry.id}>{entry.label}</option>)}</select></label>
|
<label className="studio-field"><span>Project</span><select value={draft.projectId ?? ""} onChange={(event) => onUpdate({ projectId: event.currentTarget.value || null })}><option value="">미지정</option>{projects.map((entry) => <option key={entry.id} value={entry.id}>{entry.label}</option>)}</select></label>
|
||||||
|
|||||||
@@ -8,6 +8,12 @@ export type DocumentEditorController = {
|
|||||||
saved: WorkingCopy;
|
saved: WorkingCopy;
|
||||||
draft: WorkingCopyInput;
|
draft: WorkingCopyInput;
|
||||||
status: StudioEditorStatus;
|
status: StudioEditorStatus;
|
||||||
|
/**
|
||||||
|
* 저장이 실패한 이유. 이전에는 `setRequestAnnouncement` 로만 알렸는데 그것은 aria-live 라
|
||||||
|
* 눈에는 아무것도 보이지 않았다 — 한글 slug 로 저장이 422 로 거절돼도 값은 화면에 그대로
|
||||||
|
* 남아 있어, 작성자는 저장된 줄 알고 검증에서 "slug 이 없다" 를 만났다.
|
||||||
|
*/
|
||||||
|
saveError: string;
|
||||||
update(patch: Partial<WorkingCopyInput>): void;
|
update(patch: Partial<WorkingCopyInput>): void;
|
||||||
replace(draft: WorkingCopyInput): void;
|
replace(draft: WorkingCopyInput): void;
|
||||||
save(): Promise<void>;
|
save(): Promise<void>;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { createLocalId } from "../../../domain/studio/local-id.ts";
|
|||||||
import type { DocumentEditorController } from "./document-editor-controller.ts";
|
import type { DocumentEditorController } from "./document-editor-controller.ts";
|
||||||
import { DocumentEditor } from "./document-editor.tsx";
|
import { DocumentEditor } from "./document-editor.tsx";
|
||||||
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
|
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
|
||||||
|
import { slugFromName } from "./slug-from-name.ts";
|
||||||
import { useStudio, useStudioEditorSession } from "../use-studio.ts";
|
import { useStudio, useStudioEditorSession } from "../use-studio.ts";
|
||||||
|
|
||||||
type CatalogEntry = components["schemas"]["CatalogEntry"];
|
type CatalogEntry = components["schemas"]["CatalogEntry"];
|
||||||
@@ -25,6 +26,13 @@ function inputOf(document: WorkingCopy): WorkingCopyInput {
|
|||||||
return input as WorkingCopyInput;
|
return input as WorkingCopyInput;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 백엔드가 문서 slug 에 요구하는 모양 (`WorkingCopyInputValidator.SLUG`). 저장 전에 여기서 먼저
|
||||||
|
* 보는 이유는, 어긋난 값을 보내면 서버가 details 없는 422 로 거절하고 편집기는 그것을 이유 없는
|
||||||
|
* 실패로만 보여 주기 때문이다 — 작성자에게는 어느 칸이 문제인지 알 방법이 없었다.
|
||||||
|
*/
|
||||||
|
const SLUG_SHAPE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
|
||||||
|
|
||||||
export function DocumentEditorScreen({ documentId }: { documentId: string }) {
|
export function DocumentEditorScreen({ documentId }: { documentId: string }) {
|
||||||
const studio = useStudio();
|
const studio = useStudio();
|
||||||
const session = useStudioEditorSession();
|
const session = useStudioEditorSession();
|
||||||
@@ -48,6 +56,7 @@ export function DocumentEditorScreen({ documentId }: { documentId: string }) {
|
|||||||
// which can only grow the catalog -- the one legitimate reset is the effect
|
// which can only grow the catalog -- the one legitimate reset is the effect
|
||||||
// below, when the screen switches to a different document.
|
// below, when the screen switches to a different document.
|
||||||
const [assets, setAssets] = useState<readonly Asset[]>([]);
|
const [assets, setAssets] = useState<readonly Asset[]>([]);
|
||||||
|
const [saveError, setSaveError] = useState("");
|
||||||
const observeAssets = useCallback((observed: readonly Asset[]) => {
|
const observeAssets = useCallback((observed: readonly Asset[]) => {
|
||||||
setAssets((current) => mergeAssetCatalog(current, observed));
|
setAssets((current) => mergeAssetCatalog(current, observed));
|
||||||
}, []);
|
}, []);
|
||||||
@@ -97,13 +106,26 @@ export function DocumentEditorScreen({ documentId }: { documentId: string }) {
|
|||||||
current.status === "SAVING" ||
|
current.status === "SAVING" ||
|
||||||
current.status === "CONFLICT"
|
current.status === "CONFLICT"
|
||||||
) return;
|
) return;
|
||||||
|
// 비워 두면 제목에서 만든다. 한글 제목도 로마자로 옮겨 유효한 slug 가 되므로, 작성자가
|
||||||
|
// slug 규칙을 몰라도 저장이 막히지 않는다.
|
||||||
|
const slug = current.draft.slug.trim() || slugFromName(current.draft.title);
|
||||||
|
if (slug && !SLUG_SHAPE.test(slug)) {
|
||||||
|
setSaveError(
|
||||||
|
"slug 은 영문 소문자·숫자·하이픈만 쓸 수 있습니다. 비워 두면 제목에서 만들어 드립니다.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const draft = slug === current.draft.slug
|
||||||
|
? current.draft
|
||||||
|
: ({ ...current.draft, slug } as typeof current.draft);
|
||||||
|
setSaveError("");
|
||||||
setStatus("SAVING");
|
setStatus("SAVING");
|
||||||
try {
|
try {
|
||||||
const detail = await studio.gateway.saveDocument(
|
const detail = await studio.gateway.saveDocument(
|
||||||
current.documentId,
|
current.documentId,
|
||||||
{
|
{
|
||||||
expectedVersion: current.saved.version,
|
expectedVersion: current.saved.version,
|
||||||
document: current.draft,
|
document: draft,
|
||||||
},
|
},
|
||||||
{ idempotencyKey: createLocalId("studio-editor-save") },
|
{ idempotencyKey: createLocalId("studio-editor-save") },
|
||||||
);
|
);
|
||||||
@@ -115,11 +137,11 @@ export function DocumentEditorScreen({ documentId }: { documentId: string }) {
|
|||||||
const conflict = isStudioGatewayError(error) &&
|
const conflict = isStudioGatewayError(error) &&
|
||||||
error.code === "VERSION_CONFLICT";
|
error.code === "VERSION_CONFLICT";
|
||||||
setStatus(conflict ? "CONFLICT" : "DIRTY");
|
setStatus(conflict ? "CONFLICT" : "DIRTY");
|
||||||
studio.setRequestAnnouncement(
|
const detail = isStudioGatewayError(error)
|
||||||
isStudioGatewayError(error)
|
? error.problem.detail
|
||||||
? error.problem.detail
|
: "저장하지 못했습니다.";
|
||||||
: "저장하지 못했습니다.",
|
setSaveError(detail);
|
||||||
);
|
studio.setRequestAnnouncement(detail);
|
||||||
}
|
}
|
||||||
}, [begin, editor, setStatus, studio]);
|
}, [begin, editor, setStatus, studio]);
|
||||||
|
|
||||||
@@ -129,6 +151,7 @@ export function DocumentEditorScreen({ documentId }: { documentId: string }) {
|
|||||||
saved: editor.saved,
|
saved: editor.saved,
|
||||||
draft: editor.draft,
|
draft: editor.draft,
|
||||||
status: editor.status,
|
status: editor.status,
|
||||||
|
saveError,
|
||||||
update(patch) {
|
update(patch) {
|
||||||
updateDraft({ ...editor.draft, ...patch, kind: editor.draft.kind } as WorkingCopyInput);
|
updateDraft({ ...editor.draft, ...patch, kind: editor.draft.kind } as WorkingCopyInput);
|
||||||
},
|
},
|
||||||
@@ -137,7 +160,7 @@ export function DocumentEditorScreen({ documentId }: { documentId: string }) {
|
|||||||
},
|
},
|
||||||
save,
|
save,
|
||||||
};
|
};
|
||||||
}, [documentId, editor, save, updateDraft]);
|
}, [documentId, editor, save, saveError, updateDraft]);
|
||||||
|
|
||||||
const currentResult = result?.key === requestKey ? result : null;
|
const currentResult = result?.key === requestKey ? result : null;
|
||||||
const problem = currentResult?.problem ?? null;
|
const problem = currentResult?.problem ?? null;
|
||||||
|
|||||||
@@ -30,7 +30,9 @@ function isAbortError(error: unknown): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function DocumentList() {
|
export function DocumentList() {
|
||||||
const { gateway } = useStudio();
|
const { gateway, managementGateway, setRequestAnnouncement } = useStudio();
|
||||||
|
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||||
|
const [deleteError, setDeleteError] = useState("");
|
||||||
const [searchDraft, setSearchDraft] = useState("");
|
const [searchDraft, setSearchDraft] = useState("");
|
||||||
const [q, setQ] = useState("");
|
const [q, setQ] = useState("");
|
||||||
const [kind, setKind] = useState<ListDocumentsQuery["kind"]>();
|
const [kind, setKind] = useState<ListDocumentsQuery["kind"]>();
|
||||||
@@ -42,6 +44,54 @@ export function DocumentList() {
|
|||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [retryGeneration, setRetryGeneration] = useState(0);
|
const [retryGeneration, setRetryGeneration] = useState(0);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 목록 행에는 version 이 없다 (계약의 `DocumentSummary`). 삭제는 expectedVersion 을 요구하므로
|
||||||
|
* 지우기 직전에 작업본을 한 번 읽어 그 시점의 version 을 쓴다 — 목록을 띄워 둔 채 다른 곳에서
|
||||||
|
* 수정된 경우 여기서 409 로 걸리는 편이, 목록이 기억하던 낡은 version 으로 지우는 것보다 낫다.
|
||||||
|
*/
|
||||||
|
const removeDocument = async (item: DocumentPage["items"][number]) => {
|
||||||
|
if (deletingId !== null) return;
|
||||||
|
setDeletingId(item.id);
|
||||||
|
setDeleteError("");
|
||||||
|
try {
|
||||||
|
const detail = await gateway.getDocument(item.id);
|
||||||
|
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 || "제목 없음"} 을(를) 삭제했습니다.`);
|
||||||
|
// 다시 불러오지 않고 이 행만 지운다. 목록은 커서 페이지네이션이라 재조회하면 다음
|
||||||
|
// 항목이 빈 자리를 즉시 채우고, 개수도 20 그대로다 — 작성자에게는 삭제가 아무 일도
|
||||||
|
// 하지 않은 것처럼 보인다. 삭제가 성공한 뒤의 화면은 그 행이 없는 화면이 맞다.
|
||||||
|
setPage((current) =>
|
||||||
|
current
|
||||||
|
? { ...current, items: current.items.filter((row) => row.id !== item.id) }
|
||||||
|
: current,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
setDeleteError(
|
||||||
|
"삭제하지 못했습니다. 게시 중이거나, 이 기록을 참조하는 곳이 있거나, 다른 곳에서 먼저 수정되었을 수 있습니다.",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setDeletingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -168,7 +218,19 @@ export function DocumentList() {
|
|||||||
) : null}
|
) : null}
|
||||||
{page && !loading && !error ? (
|
{page && !loading && !error ? (
|
||||||
<>
|
<>
|
||||||
<p className="studio-result-count">{page.items.length}개의 작업본</p>
|
{/*
|
||||||
|
이 숫자는 전체가 아니라 이 페이지에 실린 수다. 예전 문구는 그것을 전체처럼 읽히게
|
||||||
|
해서, 28건 중 20건이 보이는 동안 무엇을 지워도 "20개" 가 그대로였다.
|
||||||
|
*/}
|
||||||
|
<p className="studio-result-count">
|
||||||
|
<span>{page.items.length}개 표시 중</span>
|
||||||
|
{page.nextCursor ? <span> · 더 있습니다</span> : null}
|
||||||
|
</p>
|
||||||
|
{deleteError ? (
|
||||||
|
<p className="studio-screen-error" role="alert">
|
||||||
|
{deleteError}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
{page.items.length ? (
|
{page.items.length ? (
|
||||||
<div className="studio-document-list">
|
<div className="studio-document-list">
|
||||||
{page.items.map((item) => (
|
{page.items.map((item) => (
|
||||||
@@ -202,6 +264,14 @@ export function DocumentList() {
|
|||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
|
<button
|
||||||
|
className="studio-secondary-button"
|
||||||
|
type="button"
|
||||||
|
disabled={deletingId !== null}
|
||||||
|
onClick={() => void removeDocument(item)}
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</button>
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -24,7 +24,18 @@ export function DocumentStatusRail({ controller }: { controller: DocumentEditorC
|
|||||||
<dl><div><dt>저장 버전</dt><dd>{controller.saved.version}</dd></div><div><dt>종류</dt><dd>{kindLabels[controller.draft.kind]}</dd></div></dl>
|
<dl><div><dt>저장 버전</dt><dd>{controller.saved.version}</dd></div><div><dt>종류</dt><dd>{kindLabels[controller.draft.kind]}</dd></div></dl>
|
||||||
<button type="button" onClick={() => { void controller.save(); }} disabled={controller.status === "CLEAN" || controller.status === "SAVING" || controller.status === "CONFLICT"}>{controller.status === "SAVING" ? "저장 중…" : "저장"}</button>
|
<button type="button" onClick={() => { void controller.save(); }} disabled={controller.status === "CLEAN" || controller.status === "SAVING" || controller.status === "CONFLICT"}>{controller.status === "SAVING" ? "저장 중…" : "저장"}</button>
|
||||||
<GuardedStudioLink className="studio-editor-next-link" href={`/studio/documents/${controller.saved.id}/validation`}>저장본 검증</GuardedStudioLink>
|
<GuardedStudioLink className="studio-editor-next-link" href={`/studio/documents/${controller.saved.id}/validation`}>저장본 검증</GuardedStudioLink>
|
||||||
{controller.status === "CONFLICT" ? <p className="studio-editor-conflict" role="alert">서버 최신본과 충돌했습니다. 이 세션에서는 다시 열어 비교해 주세요.</p> : <p>불완전한 초안도 저장할 수 있습니다. 게시 가능 여부는 이후 검증 단계에서 확인합니다.</p>}
|
{/*
|
||||||
|
경고는 하나만 띄운다. 충돌은 그 자체로 무엇을 해야 하는지 말해 주므로 서버가 준 문구보다
|
||||||
|
앞서고, 그 밖의 실패는 서버가 준 이유를 그대로 보여 준다. 둘을 함께 띄우면 같은 실패를
|
||||||
|
두 번 말하게 된다.
|
||||||
|
*/}
|
||||||
|
{controller.status === "CONFLICT" ? (
|
||||||
|
<p className="studio-editor-conflict" role="alert">서버 최신본과 충돌했습니다. 이 세션에서는 다시 열어 비교해 주세요.</p>
|
||||||
|
) : controller.saveError ? (
|
||||||
|
<p className="studio-editor-conflict" role="alert">{controller.saveError}</p>
|
||||||
|
) : (
|
||||||
|
<p>불완전한 초안도 저장할 수 있습니다. 게시 가능 여부는 이후 검증 단계에서 확인합니다.</p>
|
||||||
|
)}
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,427 @@
|
|||||||
|
import { useCallback, useEffect, useState, type FormEvent } from "react";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ReleaseEditResponse,
|
||||||
|
ReleaseIndexItem,
|
||||||
|
ReleaseUpdateRequest,
|
||||||
|
} from "../../../contracts/management/contract.ts";
|
||||||
|
import { useStudio } from "../use-studio.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 릴리즈 작성.
|
||||||
|
*
|
||||||
|
* <p>공개 사이트에 변경 기록 화면과 푸터 링크가 있는데 둘 다 비어 있었다 — 읽기는 처음부터
|
||||||
|
* 있었고 쓰는 곳이 없었다. 그래서 이 화면의 범위는 목록·작성·발행이다.
|
||||||
|
*
|
||||||
|
* <p>본문이 마크다운 여섯 칸으로 나뉜 것은 계약의 모양이자 릴리즈 노트의 성격이다. 한 칸짜리
|
||||||
|
* 자유 서술이었다면 "검증을 안 썼다"를 아무도 알아채지 못한다.
|
||||||
|
*
|
||||||
|
* <p>새 CSS 를 만들지 않는다 — 문서 편집기가 쓰는 field 클래스와 작업본 목록의 행 클래스를 그대로
|
||||||
|
* 재사용하므로 Studio 의 나머지와 같은 간격·타이포·색을 따른다.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 백엔드 {@code UpdateReleaseUseCase.CHANGE_TYPES} 와 같은 집합이다. */
|
||||||
|
const CHANGE_TYPES = [
|
||||||
|
{ value: "FEATURE", label: "기능" },
|
||||||
|
{ value: "FIX", label: "수정" },
|
||||||
|
{ value: "REFACTOR", label: "구조" },
|
||||||
|
{ value: "DOCS", label: "문서" },
|
||||||
|
{ value: "INFRA", label: "인프라" },
|
||||||
|
{ value: "BREAKING", label: "호환 깨짐" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const SECTIONS = [
|
||||||
|
{ key: "reasonMarkdown", label: "왜 바꿨나" },
|
||||||
|
{ key: "changesMarkdown", label: "무엇을 바꿨나" },
|
||||||
|
{ key: "userImpactMarkdown", label: "사용자에게 달라지는 것" },
|
||||||
|
{ key: "implementationImpactMarkdown", label: "구현에 남는 것" },
|
||||||
|
{ key: "verificationMarkdown", label: "어떻게 검증했나" },
|
||||||
|
{ key: "knownLimitationsMarkdown", label: "아직 못 한 것" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type Draft = Readonly<{
|
||||||
|
expectedVersion: number;
|
||||||
|
versionLabel: string;
|
||||||
|
title: string;
|
||||||
|
summary: string;
|
||||||
|
releasedOn: string;
|
||||||
|
changeTypes: readonly string[];
|
||||||
|
reasonMarkdown: string;
|
||||||
|
changesMarkdown: string;
|
||||||
|
userImpactMarkdown: string;
|
||||||
|
implementationImpactMarkdown: string;
|
||||||
|
verificationMarkdown: string;
|
||||||
|
knownLimitationsMarkdown: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
function toDraft(release: ReleaseEditResponse): Draft {
|
||||||
|
return {
|
||||||
|
expectedVersion: release.version,
|
||||||
|
versionLabel: release.versionLabel,
|
||||||
|
title: release.title,
|
||||||
|
summary: release.summary ?? "",
|
||||||
|
releasedOn: release.releasedOn ?? "",
|
||||||
|
changeTypes: release.changeTypes ?? [],
|
||||||
|
reasonMarkdown: release.reasonMarkdown ?? "",
|
||||||
|
changesMarkdown: release.changesMarkdown ?? "",
|
||||||
|
userImpactMarkdown: release.userImpactMarkdown ?? "",
|
||||||
|
implementationImpactMarkdown: release.implementationImpactMarkdown ?? "",
|
||||||
|
verificationMarkdown: release.verificationMarkdown ?? "",
|
||||||
|
knownLimitationsMarkdown: release.knownLimitationsMarkdown ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_LABELS: Readonly<Record<string, string>> = {
|
||||||
|
DRAFT: "작성 중",
|
||||||
|
PUBLISHED: "공개",
|
||||||
|
ARCHIVED: "보관",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ReleaseManager() {
|
||||||
|
const { managementGateway, setRequestAnnouncement } = useStudio();
|
||||||
|
const [releases, setReleases] = useState<ReleaseIndexItem[] | null>(null);
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
const [draft, setDraft] = useState<Draft | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
const [generation, setGeneration] = useState(0);
|
||||||
|
const [newTitle, setNewTitle] = useState("");
|
||||||
|
|
||||||
|
const reload = useCallback(() => setGeneration((value) => value + 1), []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
void managementGateway.listReleases(0, 50).then(
|
||||||
|
(page) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setReleases(page.items ?? []);
|
||||||
|
setLoading(false);
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setError("릴리즈를 불러오지 못했습니다.");
|
||||||
|
setLoading(false);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [managementGateway, generation]);
|
||||||
|
|
||||||
|
// 선택된 릴리즈의 본문은 목록에 없다 — 목록 행은 마크다운을 싣지 않으므로 따로 읽는다.
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedId === null) {
|
||||||
|
setDraft(null);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
void managementGateway.getRelease(selectedId).then(
|
||||||
|
(release) => {
|
||||||
|
if (!cancelled) setDraft(toDraft(release));
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
if (!cancelled) setError("릴리즈를 불러오지 못했습니다.");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [managementGateway, selectedId, generation]);
|
||||||
|
|
||||||
|
const update = (patch: Partial<Draft>) =>
|
||||||
|
setDraft((current) => (current === null ? current : { ...current, ...patch }));
|
||||||
|
|
||||||
|
const submitNew = async (event: FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (pending) return;
|
||||||
|
const title = newTitle.trim();
|
||||||
|
if (!title) {
|
||||||
|
setError("릴리즈 제목을 입력해 주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPending(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const created = await managementGateway.createRelease(title);
|
||||||
|
setNewTitle("");
|
||||||
|
setRequestAnnouncement(`릴리즈 ${title} 초안을 만들었습니다.`);
|
||||||
|
setSelectedId(created.id);
|
||||||
|
reload();
|
||||||
|
} catch {
|
||||||
|
setError("릴리즈를 만들지 못했습니다.");
|
||||||
|
} finally {
|
||||||
|
setPending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (pending || draft === null || selectedId === null) return;
|
||||||
|
setPending(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const body: ReleaseUpdateRequest = {
|
||||||
|
expectedVersion: draft.expectedVersion,
|
||||||
|
versionLabel: draft.versionLabel.trim(),
|
||||||
|
title: draft.title.trim(),
|
||||||
|
summary: draft.summary,
|
||||||
|
changeTypes: [...draft.changeTypes],
|
||||||
|
changesMarkdown: draft.changesMarkdown,
|
||||||
|
verificationMarkdown: draft.verificationMarkdown,
|
||||||
|
...(draft.releasedOn ? { releasedOn: draft.releasedOn } : {}),
|
||||||
|
reasonMarkdown: draft.reasonMarkdown,
|
||||||
|
userImpactMarkdown: draft.userImpactMarkdown,
|
||||||
|
implementationImpactMarkdown: draft.implementationImpactMarkdown,
|
||||||
|
knownLimitationsMarkdown: draft.knownLimitationsMarkdown,
|
||||||
|
} as ReleaseUpdateRequest;
|
||||||
|
const saved = await managementGateway.updateRelease(selectedId, body);
|
||||||
|
setDraft(toDraft(saved));
|
||||||
|
setRequestAnnouncement(`릴리즈 ${saved.versionLabel} 을(를) 저장했습니다.`);
|
||||||
|
reload();
|
||||||
|
} catch {
|
||||||
|
setError("저장하지 못했습니다. 같은 버전이 이미 있거나 다른 곳에서 먼저 수정되었을 수 있습니다.");
|
||||||
|
} finally {
|
||||||
|
setPending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const publish = async () => {
|
||||||
|
if (pending || draft === null || selectedId === null) return;
|
||||||
|
setPending(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const published = await managementGateway.publishRelease(selectedId, draft.expectedVersion);
|
||||||
|
setRequestAnnouncement(`릴리즈를 공개했습니다: ${published.canonicalPath}`);
|
||||||
|
reload();
|
||||||
|
} catch {
|
||||||
|
// 발행은 저장보다 요구가 많다. 무엇이 비었는지는 서버가 알고 있지만, 그 목록을 그대로
|
||||||
|
// 옮기려면 오류 details 를 읽는 화면이 필요하다 — 여기서는 필수 항목을 그대로 안내한다.
|
||||||
|
setError(
|
||||||
|
"공개하지 못했습니다. 버전, 제목, 한 줄 요약, 변경 유형, 변경 내용, 검증, 공개일이 모두 채워져야 합니다.",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setPending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const archive = async () => {
|
||||||
|
if (pending || draft === null || selectedId === null) return;
|
||||||
|
setPending(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
await managementGateway.archiveRelease(selectedId, draft.expectedVersion);
|
||||||
|
setRequestAnnouncement("릴리즈를 공개에서 내렸습니다.");
|
||||||
|
reload();
|
||||||
|
} catch {
|
||||||
|
setError("공개에서 내리지 못했습니다.");
|
||||||
|
} finally {
|
||||||
|
setPending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = async (release: ReleaseIndexItem) => {
|
||||||
|
if (pending) return;
|
||||||
|
setPending(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
await managementGateway.deleteRelease(release.id, release.version);
|
||||||
|
if (selectedId === release.id) setSelectedId(null);
|
||||||
|
setRequestAnnouncement(`릴리즈 ${release.versionLabel} 을(를) 삭제했습니다.`);
|
||||||
|
reload();
|
||||||
|
} catch {
|
||||||
|
setError("삭제하지 못했습니다. 공개된 릴리즈는 삭제 대신 공개에서 내려야 합니다.");
|
||||||
|
} finally {
|
||||||
|
setPending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleChangeType = (value: string) => {
|
||||||
|
if (draft === null) return;
|
||||||
|
const next = draft.changeTypes.includes(value)
|
||||||
|
? draft.changeTypes.filter((entry) => entry !== value)
|
||||||
|
: [...draft.changeTypes, value];
|
||||||
|
update({ changeTypes: next });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="studio-page studio-documents-page">
|
||||||
|
<header className="studio-page-top">
|
||||||
|
<div className="studio-page-heading">
|
||||||
|
<p className="studio-eyebrow">RELEASES</p>
|
||||||
|
<h1>릴리즈</h1>
|
||||||
|
<p>무엇을 왜 바꿨는지 버전으로 묶어 공개 변경 기록에 남깁니다.</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="studio-document-tools" aria-label="릴리즈 만들기">
|
||||||
|
<form onSubmit={submitNew}>
|
||||||
|
<label htmlFor="release-new-title">새 릴리즈</label>
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
id="release-new-title"
|
||||||
|
type="text"
|
||||||
|
value={newTitle}
|
||||||
|
placeholder="릴리즈 제목"
|
||||||
|
onChange={(event) => setNewTitle(event.target.value)}
|
||||||
|
/>
|
||||||
|
<button type="submit" disabled={pending}>
|
||||||
|
추가
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<p className="studio-screen-error" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p className="studio-empty-inline">불러오는 중입니다.</p>
|
||||||
|
) : (releases?.length ?? 0) === 0 ? (
|
||||||
|
<p className="studio-empty-inline">아직 릴리즈가 없습니다.</p>
|
||||||
|
) : (
|
||||||
|
<section aria-label="릴리즈 목록">
|
||||||
|
<p className="studio-result-count">{releases?.length}개의 릴리즈</p>
|
||||||
|
<div className="studio-document-list">
|
||||||
|
{(releases ?? []).map((release) => (
|
||||||
|
<article
|
||||||
|
key={release.id}
|
||||||
|
className="studio-document-row"
|
||||||
|
aria-current={selectedId === release.id ? "true" : undefined}
|
||||||
|
>
|
||||||
|
<div className="studio-document-title">
|
||||||
|
<h2>{release.title}</h2>
|
||||||
|
<p>
|
||||||
|
{release.versionLabel}
|
||||||
|
{release.releasedOn ? ` · ${release.releasedOn}` : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<dl>
|
||||||
|
<div>
|
||||||
|
<dt>상태</dt>
|
||||||
|
<dd>{STATUS_LABELS[release.workflowStatus] ?? release.workflowStatus}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
<button
|
||||||
|
className="studio-secondary-button"
|
||||||
|
type="button"
|
||||||
|
disabled={pending}
|
||||||
|
onClick={() => setSelectedId(selectedId === release.id ? null : release.id)}
|
||||||
|
>
|
||||||
|
{selectedId === release.id ? "닫기" : "편집"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="studio-secondary-button"
|
||||||
|
type="button"
|
||||||
|
disabled={pending}
|
||||||
|
onClick={() => void remove(release)}
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</button>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{draft === null ? null : (
|
||||||
|
<section aria-label="릴리즈 편집">
|
||||||
|
<h2 className="studio-section-title">{draft.title || "릴리즈"} 편집</h2>
|
||||||
|
<div className="studio-field-grid">
|
||||||
|
<label className="studio-field">
|
||||||
|
<span>버전</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={draft.versionLabel}
|
||||||
|
placeholder="0.1.0"
|
||||||
|
onChange={(event) => update({ versionLabel: event.currentTarget.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="studio-field">
|
||||||
|
<span>제목</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={draft.title}
|
||||||
|
onChange={(event) => update({ title: event.currentTarget.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="studio-field">
|
||||||
|
<span>공개일</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={draft.releasedOn}
|
||||||
|
onChange={(event) => update({ releasedOn: event.currentTarget.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="studio-field studio-field--wide">
|
||||||
|
<span>한 줄 요약</span>
|
||||||
|
<textarea
|
||||||
|
value={draft.summary}
|
||||||
|
onChange={(event) => update({ summary: event.currentTarget.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<fieldset className="studio-field-grid">
|
||||||
|
<legend className="studio-field">
|
||||||
|
<span>변경 유형</span>
|
||||||
|
</legend>
|
||||||
|
{CHANGE_TYPES.map((changeType) => (
|
||||||
|
<label key={changeType.value} className="studio-field studio-field--checkbox">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={draft.changeTypes.includes(changeType.value)}
|
||||||
|
onChange={() => toggleChangeType(changeType.value)}
|
||||||
|
/>
|
||||||
|
<span>{changeType.label}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div className="studio-field-grid">
|
||||||
|
{SECTIONS.map((section) => (
|
||||||
|
<label key={section.key} className="studio-field studio-field--wide">
|
||||||
|
<span>{section.label}</span>
|
||||||
|
<textarea
|
||||||
|
value={draft[section.key]}
|
||||||
|
onChange={(event) => update({ [section.key]: event.currentTarget.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="studio-create-footer">
|
||||||
|
<button
|
||||||
|
className="studio-primary-button"
|
||||||
|
type="button"
|
||||||
|
disabled={pending}
|
||||||
|
onClick={() => void save()}
|
||||||
|
>
|
||||||
|
저장
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="studio-secondary-button"
|
||||||
|
type="button"
|
||||||
|
disabled={pending}
|
||||||
|
onClick={() => void publish()}
|
||||||
|
>
|
||||||
|
공개
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="studio-secondary-button"
|
||||||
|
type="button"
|
||||||
|
disabled={pending}
|
||||||
|
onClick={() => void archive()}
|
||||||
|
>
|
||||||
|
공개에서 내리기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* 이름에서 slug 를 만든다.
|
||||||
|
*
|
||||||
|
* <p>원래는 `[^a-z0-9]` 를 전부 하이픈으로 바꿨다. 한글은 전부 거기 걸리므로 이름에 섞인 영문과
|
||||||
|
* 숫자만 남았고, 결과는 두 가지로 나빴다 — `인증` 은 빈 문자열이 되어 저장 자체가 막혔고,
|
||||||
|
* `Redis 캐시` 와 `Redis 클러스터` 는 둘 다 `redis` 가 되어 두 번째 주제가 "같은 slug 가 이미
|
||||||
|
* 있습니다" 로 거절됐다. 작성자에게는 "가끔 안 되다가 이름을 바꾸면 되는" 현상으로 보인다.
|
||||||
|
*
|
||||||
|
* <p>그래서 한글을 버리지 않고 로마자로 옮긴다. 한글 음절은 (초성, 중성, 종성) 로 산술 분해되므로
|
||||||
|
* 표가 필요 없고 결과가 결정적이다. 표기법은 국어의 로마자 표기법의 자모 대응만 쓴다 — 음운 변동
|
||||||
|
* (자음동화 같은 것) 은 반영하지 않는다. slug 는 읽히기 위한 것이지 발음을 옮기기 위한 것이 아니고,
|
||||||
|
* 변동 규칙을 넣으면 같은 이름이 문맥에 따라 다른 slug 가 될 수 있다.
|
||||||
|
*
|
||||||
|
* <p>결과는 문서 slug 와 같은 모양이다 (`^[a-z0-9]+(?:-[a-z0-9]+)*$`) — 한 저장소가 두 가지 slug
|
||||||
|
* 규칙을 갖지 않도록.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const SYLLABLE_BASE = 0xac00;
|
||||||
|
const SYLLABLE_LAST = 0xd7a3;
|
||||||
|
const MEDIAL_COUNT = 21;
|
||||||
|
const FINAL_COUNT = 28;
|
||||||
|
|
||||||
|
const INITIALS = [
|
||||||
|
"g", "kk", "n", "d", "tt", "r", "m", "b", "pp", "s",
|
||||||
|
"ss", "", "j", "jj", "ch", "k", "t", "p", "h",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const MEDIALS = [
|
||||||
|
"a", "ae", "ya", "yae", "eo", "e", "yeo", "ye", "o", "wa",
|
||||||
|
"wae", "oe", "yo", "u", "wo", "we", "wi", "yu", "eu", "ui", "i",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const FINALS = [
|
||||||
|
"", "k", "k", "ks", "n", "nj", "nh", "t", "l", "lg",
|
||||||
|
"lm", "lb", "ls", "lt", "lp", "lh", "m", "b", "bs", "s",
|
||||||
|
"ss", "ng", "j", "ch", "k", "t", "p", "h",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** 한글 자모가 아닌 문자는 그대로 돌려준다 — 뒤의 필터가 처리한다. */
|
||||||
|
function romanizeSyllable(codePoint: number): string {
|
||||||
|
if (codePoint < SYLLABLE_BASE || codePoint > SYLLABLE_LAST) {
|
||||||
|
return String.fromCodePoint(codePoint);
|
||||||
|
}
|
||||||
|
const offset = codePoint - SYLLABLE_BASE;
|
||||||
|
const initial = Math.floor(offset / (MEDIAL_COUNT * FINAL_COUNT));
|
||||||
|
const medial = Math.floor((offset % (MEDIAL_COUNT * FINAL_COUNT)) / FINAL_COUNT);
|
||||||
|
const final = offset % FINAL_COUNT;
|
||||||
|
return `${INITIALS[initial]}${MEDIALS[medial]}${FINALS[final]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function slugFromName(value: string): string {
|
||||||
|
const romanized = [...value.trim()]
|
||||||
|
.map((character) => romanizeSyllable(character.codePointAt(0) ?? 0))
|
||||||
|
.join("");
|
||||||
|
return romanized
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/gu, "-")
|
||||||
|
.replace(/^-+|-+$/gu, "");
|
||||||
|
}
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
import { useId, useState } from "react";
|
import { useId, useState } from "react";
|
||||||
|
|
||||||
|
import { useLocale } from "../../../../../presentation/i18n/index.ts";
|
||||||
|
import { useSession } from "../../../../../presentation/providers/session-provider.tsx";
|
||||||
|
|
||||||
import { techLogNavigation } from "../../tech-log-navigation.ts";
|
import { techLogNavigation } from "../../tech-log-navigation.ts";
|
||||||
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
|
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
|
||||||
|
|
||||||
@@ -40,10 +43,38 @@ function StudioNavigation({
|
|||||||
</GuardedStudioLink>
|
</GuardedStudioLink>
|
||||||
))}
|
))}
|
||||||
<a href="/">공개 사이트 보기</a>
|
<a href="/">공개 사이트 보기</a>
|
||||||
|
<StudioSessionAction />
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로그아웃 자리. 세션 조작은 템플릿의 {@code AppShell} 에만 있었는데 TechLog 는 자체 셸을 쓰므로
|
||||||
|
* 어느 화면에도 렌더되지 않았다 — 로그인은 되는데 로그아웃할 방법이 없었다.
|
||||||
|
*
|
||||||
|
* 인증된 상태에서만 그린다. 미인증이면 라우터의 인증 게이트 화면이 이미 로그인 조작을 들고 있어서
|
||||||
|
* 여기 같은 버튼을 또 두면 두 개가 생긴다.
|
||||||
|
*/
|
||||||
|
function StudioSessionAction() {
|
||||||
|
const { sessionState, signOut } = useSession();
|
||||||
|
const { message } = useLocale();
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
if (sessionState !== "authenticated") return null;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="studio-session-action"
|
||||||
|
disabled={pending}
|
||||||
|
onClick={() => {
|
||||||
|
setPending(true);
|
||||||
|
void signOut().finally(() => setPending(false));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{message("action.signOut")}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function StudioHeader({ currentPath }: Readonly<{ currentPath: string }>) {
|
export function StudioHeader({ currentPath }: Readonly<{ currentPath: string }>) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const mobileId = useId();
|
const mobileId = useId();
|
||||||
|
|||||||
@@ -0,0 +1,273 @@
|
|||||||
|
import { useCallback, useEffect, useState, type FormEvent } from "react";
|
||||||
|
|
||||||
|
import type { ProjectIndexItem, TopicEdit } from "../../../contracts/management/contract.ts";
|
||||||
|
import { slugFromName } from "./slug-from-name.ts";
|
||||||
|
import { useStudio } from "../use-studio.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 주제와 프로젝트 관리.
|
||||||
|
*
|
||||||
|
* <p>이 화면이 존재하는 이유는 문서 발행이 주제를 요구하는데 주제를 만들 곳이 없었기 때문이다.
|
||||||
|
* 그래서 범위를 목록·생성·삭제로 끊었다 — 편집(이름 변경, 단계 전환, 공개 범위)은 계약에 있고
|
||||||
|
* 백엔드도 구현돼 있으나, 그 화면은 별도 설계가 필요하다.
|
||||||
|
*
|
||||||
|
* <p>새 CSS 를 만들지 않는다. 작업본 목록이 쓰는 클래스만 재사용하므로 이 화면은 Studio 의
|
||||||
|
* 나머지와 같은 간격·타이포·색을 그대로 따른다.
|
||||||
|
*/
|
||||||
|
export function TaxonomyManager() {
|
||||||
|
const { managementGateway, setRequestAnnouncement } = useStudio();
|
||||||
|
const [topics, setTopics] = useState<TopicEdit[] | null>(null);
|
||||||
|
const [projects, setProjects] = useState<ProjectIndexItem[] | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
const [generation, setGeneration] = useState(0);
|
||||||
|
|
||||||
|
const [topicName, setTopicName] = useState("");
|
||||||
|
const [topicSlug, setTopicSlug] = useState("");
|
||||||
|
const [projectTitle, setProjectTitle] = useState("");
|
||||||
|
|
||||||
|
const reload = useCallback(() => setGeneration((value) => value + 1), []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
void Promise.all([managementGateway.listTopics(), managementGateway.listProjects(0, 50)]).then(
|
||||||
|
([topicList, projectPage]) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setTopics(topicList);
|
||||||
|
setProjects(projectPage.items ?? []);
|
||||||
|
setLoading(false);
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setError("주제와 프로젝트를 불러오지 못했습니다.");
|
||||||
|
setLoading(false);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [managementGateway, generation]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* slug 를 비워 두면 이름에서 만든다. 한글 이름이 흔한데 이전 규칙은 한글을 전부 버려서, 이름에
|
||||||
|
* 섞인 영문·숫자만 남았다 — `인증` 은 빈 slug 가 되고 `Redis 캐시` 와 `Redis 클러스터` 는 둘 다
|
||||||
|
* `redis` 가 됐다. 지금은 로마자로 옮긴다 ({@link slugFromName}).
|
||||||
|
*/
|
||||||
|
const slugify = slugFromName;
|
||||||
|
|
||||||
|
const submitTopic = async (event: FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (pending) return;
|
||||||
|
const name = topicName.trim();
|
||||||
|
const slug = slugify(topicSlug || topicName);
|
||||||
|
if (!name || !slug) {
|
||||||
|
setError("주제 이름과 slug 를 입력해 주세요. slug 는 영문·숫자·하이픈만 가능합니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPending(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
await managementGateway.createTopic({ name, slug } as TopicEdit);
|
||||||
|
setTopicName("");
|
||||||
|
setTopicSlug("");
|
||||||
|
setRequestAnnouncement(`주제 ${name} 을(를) 만들었습니다.`);
|
||||||
|
reload();
|
||||||
|
} catch {
|
||||||
|
setError("주제를 만들지 못했습니다. 같은 이름이나 slug 가 이미 있을 수 있습니다.");
|
||||||
|
} finally {
|
||||||
|
setPending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitProject = async (event: FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (pending) return;
|
||||||
|
const title = projectTitle.trim();
|
||||||
|
if (!title) {
|
||||||
|
setError("프로젝트 이름을 입력해 주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPending(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
await managementGateway.createProject(title);
|
||||||
|
setProjectTitle("");
|
||||||
|
setRequestAnnouncement(`프로젝트 ${title} 을(를) 만들었습니다.`);
|
||||||
|
reload();
|
||||||
|
} catch {
|
||||||
|
setError("프로젝트를 만들지 못했습니다.");
|
||||||
|
} finally {
|
||||||
|
setPending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeTopic = async (topic: TopicEdit) => {
|
||||||
|
if (pending || topic.id === undefined || topic.version === undefined) return;
|
||||||
|
setPending(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
await managementGateway.deleteTopic(topic.id, topic.version);
|
||||||
|
setRequestAnnouncement(`주제 ${topic.name} 을(를) 삭제했습니다.`);
|
||||||
|
reload();
|
||||||
|
} catch {
|
||||||
|
setError("주제를 삭제하지 못했습니다. 이 주제를 쓰는 기록이 있을 수 있습니다.");
|
||||||
|
} finally {
|
||||||
|
setPending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeProject = async (project: ProjectIndexItem) => {
|
||||||
|
if (pending) return;
|
||||||
|
setPending(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
await managementGateway.deleteProject(project.id, project.version);
|
||||||
|
setRequestAnnouncement(`프로젝트 ${project.name} 을(를) 삭제했습니다.`);
|
||||||
|
reload();
|
||||||
|
} catch {
|
||||||
|
setError("프로젝트를 삭제하지 못했습니다. 연결된 기록이 있을 수 있습니다.");
|
||||||
|
} finally {
|
||||||
|
setPending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="studio-page studio-documents-page">
|
||||||
|
<header className="studio-page-top">
|
||||||
|
<div className="studio-page-heading">
|
||||||
|
<p className="studio-eyebrow">TAXONOMY</p>
|
||||||
|
<h1>주제와 프로젝트</h1>
|
||||||
|
<p>문서를 게시하려면 주제가 필요합니다. 여기서 만들고 정리합니다.</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="studio-document-tools" aria-label="주제 만들기">
|
||||||
|
<form onSubmit={submitTopic}>
|
||||||
|
<label htmlFor="taxonomy-topic-name">새 주제</label>
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
id="taxonomy-topic-name"
|
||||||
|
type="text"
|
||||||
|
value={topicName}
|
||||||
|
placeholder="주제 이름"
|
||||||
|
onChange={(event) => setTopicName(event.target.value)}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={topicSlug}
|
||||||
|
placeholder="slug (비우면 이름에서 생성)"
|
||||||
|
aria-label="주제 slug"
|
||||||
|
onChange={(event) => setTopicSlug(event.target.value)}
|
||||||
|
/>
|
||||||
|
<button type="submit" disabled={pending}>
|
||||||
|
추가
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<form onSubmit={submitProject}>
|
||||||
|
<label htmlFor="taxonomy-project-title">새 프로젝트</label>
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
id="taxonomy-project-title"
|
||||||
|
type="text"
|
||||||
|
value={projectTitle}
|
||||||
|
placeholder="프로젝트 이름"
|
||||||
|
onChange={(event) => setProjectTitle(event.target.value)}
|
||||||
|
/>
|
||||||
|
<button type="submit" disabled={pending}>
|
||||||
|
추가
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p className="studio-loading" role="status">
|
||||||
|
주제와 프로젝트를 불러오는 중입니다.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{error ? (
|
||||||
|
<p className="studio-screen-error" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!loading && topics ? (
|
||||||
|
<>
|
||||||
|
<p className="studio-result-count">{topics.length}개의 주제</p>
|
||||||
|
{topics.length ? (
|
||||||
|
<div className="studio-document-list">
|
||||||
|
{topics.map((topic) => (
|
||||||
|
<article className="studio-document-row" key={topic.id ?? topic.slug}>
|
||||||
|
<p className="studio-row-label">TOPIC</p>
|
||||||
|
<div className="studio-document-title">
|
||||||
|
<h2>{topic.name}</h2>
|
||||||
|
<p>{topic.slug}</p>
|
||||||
|
</div>
|
||||||
|
<dl>
|
||||||
|
<div>
|
||||||
|
<dt>상태</dt>
|
||||||
|
<dd>{topic.status === "ARCHIVED" ? "보관" : "사용 중"}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
<button
|
||||||
|
className="studio-secondary-button"
|
||||||
|
type="button"
|
||||||
|
disabled={pending}
|
||||||
|
onClick={() => void removeTopic(topic)}
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</button>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="studio-empty">아직 주제가 없습니다. 위에서 하나 만들어 주세요.</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!loading && projects ? (
|
||||||
|
<>
|
||||||
|
<p className="studio-result-count">{projects.length}개의 프로젝트</p>
|
||||||
|
{projects.length ? (
|
||||||
|
<div className="studio-document-list">
|
||||||
|
{projects.map((project) => (
|
||||||
|
<article className="studio-document-row" key={project.id}>
|
||||||
|
<p className="studio-row-label">PROJECT</p>
|
||||||
|
<div className="studio-document-title">
|
||||||
|
<h2>{project.name}</h2>
|
||||||
|
<p>{project.currentObjective ?? "목표 미지정"}</p>
|
||||||
|
</div>
|
||||||
|
<dl>
|
||||||
|
<div>
|
||||||
|
<dt>단계</dt>
|
||||||
|
<dd>{project.phase}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>공개</dt>
|
||||||
|
<dd>{project.targetVisibility}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
<button
|
||||||
|
className="studio-secondary-button"
|
||||||
|
type="button"
|
||||||
|
disabled={pending}
|
||||||
|
onClick={() => void removeProject(project)}
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</button>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="studio-empty">아직 프로젝트가 없습니다.</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { ReleaseManager } from "../components/release-manager.tsx";
|
||||||
|
|
||||||
|
export function StudioReleasesPage() {
|
||||||
|
return <ReleaseManager />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { TaxonomyManager } from "../components/taxonomy-manager.tsx";
|
||||||
|
|
||||||
|
export function TaxonomyPage() {
|
||||||
|
return <TaxonomyManager />;
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
import { isStudioGatewayError } from "../../application/ports/studio-gateway-error.ts";
|
import { isStudioGatewayError } from "../../application/ports/studio-gateway-error.ts";
|
||||||
import type { StudioAssetGateway } from "../../application/ports/studio-asset-gateway.ts";
|
import type { StudioAssetGateway } from "../../application/ports/studio-asset-gateway.ts";
|
||||||
import type { StudioGateway } from "../../application/ports/studio-gateway.ts";
|
import type { StudioGateway } from "../../application/ports/studio-gateway.ts";
|
||||||
|
import type { ManagementGateway } from "../../application/ports/management-gateway.ts";
|
||||||
import type { ResolvePublishedLabel } from "../../domain/public-render-content.ts";
|
import type { ResolvePublishedLabel } from "../../domain/public-render-content.ts";
|
||||||
import type {
|
import type {
|
||||||
WorkingCopy,
|
WorkingCopy,
|
||||||
@@ -29,6 +30,7 @@ type StudioProviderProps = Readonly<{
|
|||||||
// Optional so test harnesses that only exercise the document gateway keep
|
// Optional so test harnesses that only exercise the document gateway keep
|
||||||
// working unchanged. `StudioShell` always supplies one in the running app.
|
// working unchanged. `StudioShell` always supplies one in the running app.
|
||||||
createAssetGateway?: () => StudioAssetGateway;
|
createAssetGateway?: () => StudioAssetGateway;
|
||||||
|
createManagementGateway: () => ManagementGateway;
|
||||||
resolvePublishedLabel?: ResolvePublishedLabel;
|
resolvePublishedLabel?: ResolvePublishedLabel;
|
||||||
now?: () => Date;
|
now?: () => Date;
|
||||||
navigate?: (href: string) => void;
|
navigate?: (href: string) => void;
|
||||||
@@ -53,6 +55,7 @@ export function StudioProvider({
|
|||||||
children,
|
children,
|
||||||
createGateway,
|
createGateway,
|
||||||
createAssetGateway,
|
createAssetGateway,
|
||||||
|
createManagementGateway,
|
||||||
resolvePublishedLabel = missingPublishedLabel,
|
resolvePublishedLabel = missingPublishedLabel,
|
||||||
now = () => new Date("2026-08-14T01:00:00.000Z"),
|
now = () => new Date("2026-08-14T01:00:00.000Z"),
|
||||||
navigate = defaultNavigate,
|
navigate = defaultNavigate,
|
||||||
@@ -64,6 +67,7 @@ export function StudioProvider({
|
|||||||
const [assetGateway] = useState<StudioAssetGateway | null>(
|
const [assetGateway] = useState<StudioAssetGateway | null>(
|
||||||
() => createAssetGateway?.() ?? null,
|
() => createAssetGateway?.() ?? null,
|
||||||
);
|
);
|
||||||
|
const [managementGateway] = useState<ManagementGateway>(() => createManagementGateway());
|
||||||
const [editor, setEditor] = useState<StudioEditorState | null>(null);
|
const [editor, setEditor] = useState<StudioEditorState | null>(null);
|
||||||
const [pendingHref, setPendingHref] = useState<string | null>(null);
|
const [pendingHref, setPendingHref] = useState<string | null>(null);
|
||||||
const [requestAnnouncement, setRequestAnnouncement] = useState("");
|
const [requestAnnouncement, setRequestAnnouncement] = useState("");
|
||||||
@@ -159,6 +163,7 @@ export function StudioProvider({
|
|||||||
const value = useMemo<StudioContextValue>(
|
const value = useMemo<StudioContextValue>(
|
||||||
() => ({
|
() => ({
|
||||||
gateway,
|
gateway,
|
||||||
|
managementGateway,
|
||||||
assetGateway,
|
assetGateway,
|
||||||
resolvePublishedLabel,
|
resolvePublishedLabel,
|
||||||
now,
|
now,
|
||||||
@@ -177,6 +182,7 @@ export function StudioProvider({
|
|||||||
clearEditor,
|
clearEditor,
|
||||||
editor,
|
editor,
|
||||||
gateway,
|
gateway,
|
||||||
|
managementGateway,
|
||||||
navigateInternal,
|
navigateInternal,
|
||||||
now,
|
now,
|
||||||
requestAnnouncement,
|
requestAnnouncement,
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
||||||
|
|
||||||
|
import { usePublicContent } from "../public/use-public-content.tsx";
|
||||||
import { useLocation, useNavigate } from "react-router-dom";
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import { useApplication } from "../../../../presentation/providers/application-provider.tsx";
|
import { useApplication } from "../../../../presentation/providers/application-provider.tsx";
|
||||||
@@ -6,6 +8,7 @@ import { TECH_LOG_FEATURE_ID } from "../../application/tech-log-feature-input.ts
|
|||||||
import { StudioHeader } from "./components/studio-header.tsx";
|
import { StudioHeader } from "./components/studio-header.tsx";
|
||||||
import { StudioProvider } from "./studio-provider.tsx";
|
import { StudioProvider } from "./studio-provider.tsx";
|
||||||
import { StudioRuntimeBoundary } from "./studio-runtime-boundary.tsx";
|
import { StudioRuntimeBoundary } from "./studio-runtime-boundary.tsx";
|
||||||
|
import { useSession } from "../../../../presentation/providers/session-provider.tsx";
|
||||||
import { useStudio } from "./use-studio.ts";
|
import { useStudio } from "./use-studio.ts";
|
||||||
|
|
||||||
type StudioShellProps = Readonly<{ children: ReactNode }>;
|
type StudioShellProps = Readonly<{ children: ReactNode }>;
|
||||||
@@ -13,12 +16,20 @@ type StudioShellProps = Readonly<{ children: ReactNode }>;
|
|||||||
function StudioFrame({ children }: StudioShellProps) {
|
function StudioFrame({ children }: StudioShellProps) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { requestAnnouncement } = useStudio();
|
const { requestAnnouncement } = useStudio();
|
||||||
|
const { sessionState } = useSession();
|
||||||
|
// The router gates the page, not the chrome, so a signed-out visitor who
|
||||||
|
// typed /studio still got the Studio navigation — every workspace link, by
|
||||||
|
// name. No data leaks through an href, but the checklist item is that a
|
||||||
|
// signed-out visitor does not see the Studio screen, and the navigation is
|
||||||
|
// the Studio screen. `children` here is the router's sign-in surface, which
|
||||||
|
// is the whole of what such a visitor should get.
|
||||||
|
const signedIn = sessionState === "authenticated";
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<a className="studio-skip-link" href="#main-content">
|
<a className="studio-skip-link" href="#main-content">
|
||||||
본문으로 건너뛰기
|
본문으로 건너뛰기
|
||||||
</a>
|
</a>
|
||||||
<StudioHeader currentPath={location.pathname} />
|
{signedIn ? <StudioHeader currentPath={location.pathname} /> : null}
|
||||||
<main id="main-content" className="studio-main" tabIndex={-1}>
|
<main id="main-content" className="studio-main" tabIndex={-1}>
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
@@ -47,17 +58,28 @@ export function StudioShell({ children }: StudioShellProps) {
|
|||||||
() => application.features.get(TECH_LOG_FEATURE_ID).createStudioGateway(),
|
() => application.features.get(TECH_LOG_FEATURE_ID).createStudioGateway(),
|
||||||
[application],
|
[application],
|
||||||
);
|
);
|
||||||
|
const createManagementGateway = useCallback(
|
||||||
|
() => application.features.get(TECH_LOG_FEATURE_ID).createManagementGateway(),
|
||||||
|
[application],
|
||||||
|
);
|
||||||
const createAssetGateway = useCallback(
|
const createAssetGateway = useCallback(
|
||||||
() => application.features.get(TECH_LOG_FEATURE_ID).createStudioAssetGateway(),
|
() => application.features.get(TECH_LOG_FEATURE_ID).createStudioAssetGateway(),
|
||||||
[application],
|
[application],
|
||||||
);
|
);
|
||||||
|
// `ResolvePublishedLabel` is called from inside the public renderer, which is
|
||||||
|
// synchronous by design — making it async would push awaits through the whole
|
||||||
|
// render tree. So the catalog is loaded once here and the callback stays a
|
||||||
|
// lookup over what has already arrived. Before it arrives the renderer falls
|
||||||
|
// back to its own "게시 전" label, which is what it showed for an unknown path
|
||||||
|
// anyway.
|
||||||
|
const publishedLabels = usePublicContent(
|
||||||
|
["tech-log", "studio", "published-labels"],
|
||||||
|
async (queries) => ({ records: await queries.listRecords() }),
|
||||||
|
);
|
||||||
|
const records = publishedLabels.data?.records;
|
||||||
const resolvePublishedLabel = useCallback(
|
const resolvePublishedLabel = useCallback(
|
||||||
(path: string) =>
|
(path: string) => records?.find((record) => record.path === path)?.publishedLabel,
|
||||||
application.features
|
[records],
|
||||||
.get(TECH_LOG_FEATURE_ID)
|
|
||||||
.publicContent.listRecords()
|
|
||||||
.find((record) => record.path === path)?.publishedLabel,
|
|
||||||
[application],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -74,6 +96,7 @@ export function StudioShell({ children }: StudioShellProps) {
|
|||||||
key={generation}
|
key={generation}
|
||||||
createGateway={createGateway}
|
createGateway={createGateway}
|
||||||
createAssetGateway={createAssetGateway}
|
createAssetGateway={createAssetGateway}
|
||||||
|
createManagementGateway={createManagementGateway}
|
||||||
resolvePublishedLabel={resolvePublishedLabel}
|
resolvePublishedLabel={resolvePublishedLabel}
|
||||||
navigate={navigateInternal}
|
navigate={navigateInternal}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { createContext, useContext, useMemo } from "react";
|
|||||||
|
|
||||||
import type { StudioAssetGateway } from "../../application/ports/studio-asset-gateway.ts";
|
import type { StudioAssetGateway } from "../../application/ports/studio-asset-gateway.ts";
|
||||||
import type { StudioGateway } from "../../application/ports/studio-gateway.ts";
|
import type { StudioGateway } from "../../application/ports/studio-gateway.ts";
|
||||||
|
import type { ManagementGateway } from "../../application/ports/management-gateway.ts";
|
||||||
import type { ResolvePublishedLabel } from "../../domain/public-render-content.ts";
|
import type { ResolvePublishedLabel } from "../../domain/public-render-content.ts";
|
||||||
import type {
|
import type {
|
||||||
WorkingCopy,
|
WorkingCopy,
|
||||||
@@ -23,6 +24,9 @@ export type StudioContextValue = Readonly<{
|
|||||||
// `createAssetGateway` prop. `StudioShell` — the real app path — always
|
// `createAssetGateway` prop. `StudioShell` — the real app path — always
|
||||||
// supplies one, so production code sees this populated.
|
// supplies one, so production code sees this populated.
|
||||||
assetGateway: StudioAssetGateway | null;
|
assetGateway: StudioAssetGateway | null;
|
||||||
|
// 주제·프로젝트 관리. `assetGateway` 와 같은 이유로 nullable 이 아니다 — 이 표면은
|
||||||
|
// MOCK 소스가 없어 항상 HTTP 이고, 없는 경우가 존재하지 않는다.
|
||||||
|
managementGateway: ManagementGateway;
|
||||||
resolvePublishedLabel: ResolvePublishedLabel;
|
resolvePublishedLabel: ResolvePublishedLabel;
|
||||||
now(): Date;
|
now(): Date;
|
||||||
editor: StudioEditorState | null;
|
editor: StudioEditorState | null;
|
||||||
|
|||||||
@@ -79,7 +79,39 @@ a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.site-frame {
|
.site-frame {
|
||||||
min-height: 100vh;
|
/* Flex column with the footer pushed down by `margin-top: auto` instead of
|
||||||
|
sitting wherever the flow leaves it. The route chunk loads after first
|
||||||
|
paint, so in flow the footer rendered just under the header and then jumped
|
||||||
|
when the content arrived — a single 0.192 layout shift, which is most of
|
||||||
|
what the page scored. Pinned to the bottom of the frame it starts where it
|
||||||
|
ends up. */
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100dvh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The route chunk loads after first paint, and while it does the shell renders
|
||||||
|
the Suspense fallback — a `section.ui-page`, not a `main`. Both slots need the
|
||||||
|
same reserved height: with only `main` covered the footer still sat at the
|
||||||
|
viewport bottom during loading and then dropped out of view when the content
|
||||||
|
arrived, which is the whole 0.192 the page scored. */
|
||||||
|
.site-frame > main,
|
||||||
|
.site-frame > .ui-page {
|
||||||
|
/* Takes the slack so the footer stays put whether the route rendered a long
|
||||||
|
document or nothing yet.
|
||||||
|
|
||||||
|
`min-height` is what actually removes the shift. The route chunk loads
|
||||||
|
after first paint; with only `flex` the footer sat at the bottom edge of
|
||||||
|
the viewport — visible — and then dropped to y≈2500 when the content
|
||||||
|
arrived, which is a visible element moving and so counts in full. Holding
|
||||||
|
the content area to a viewport tall puts the footer below the fold from the
|
||||||
|
first frame, and later growth only pushes it further out of sight. */
|
||||||
|
flex: 1 0 auto;
|
||||||
|
min-height: 100dvh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-footer {
|
||||||
|
margin-top: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shell {
|
.shell {
|
||||||
@@ -1926,6 +1958,11 @@ dialog::backdrop {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Shown where a public list would be, when the site has nothing published yet.
|
||||||
|
Takes the same top border and muted tone the lists it replaces already use,
|
||||||
|
so an empty page reads as a page rather than as a failure. */
|
||||||
|
.public-empty-note { margin: 70px 0 0; padding: 44px 0; border-top: 1px solid var(--line-strong); color: var(--muted); font-size: 14px; }
|
||||||
|
|
||||||
.release-index-list { margin: 70px 0 0; padding: 0; border-top: 1px solid var(--line-strong); list-style: none; }
|
.release-index-list { margin: 70px 0 0; padding: 0; border-top: 1px solid var(--line-strong); list-style: none; }
|
||||||
.release-index-list > li { border-bottom: 1px solid var(--line); }
|
.release-index-list > li { border-bottom: 1px solid var(--line); }
|
||||||
.release-index-list a { display: grid; min-height: 150px; grid-template-columns: 150px minmax(0, 1fr) 24px; gap: 28px; padding: 30px 0 33px; }
|
.release-index-list a { display: grid; min-height: 150px; grid-template-columns: 150px minmax(0, 1fr) 24px; gap: 28px; padding: 30px 0 33px; }
|
||||||
|
|||||||
@@ -357,8 +357,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dialog {
|
.dialog {
|
||||||
|
/* studio.css 의 두 다이얼로그와 같은 이유로 명시한다 — UA 기본 margin:auto 에
|
||||||
|
맡기면 좌상단에 붙는다. */
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
margin: auto;
|
||||||
width: min(580px, calc(100% - 32px));
|
width: min(580px, calc(100% - 32px));
|
||||||
max-height: calc(100vh - 32px);
|
max-height: calc(100dvh - 32px);
|
||||||
padding: 0;
|
padding: 0;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
border: 1px solid var(--line-strong);
|
border: 1px solid var(--line-strong);
|
||||||
|
|||||||
@@ -86,6 +86,10 @@
|
|||||||
a flex container both default to an automatic minimum of their min-content
|
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
|
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). */
|
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 { 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 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; }
|
.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; }
|
||||||
|
|||||||
@@ -30,7 +30,11 @@
|
|||||||
.studio-app .studio-route-state button { min-height: 44px; margin-top: 18px; padding-inline: 16px; border: 1px solid var(--signal); border-radius: 5px; background: var(--signal); color: #fff; }
|
.studio-app .studio-route-state button { min-height: 44px; margin-top: 18px; padding-inline: 16px; border: 1px solid var(--signal); border-radius: 5px; background: var(--signal); color: #fff; }
|
||||||
.studio-app .studio-loading { min-height: 180px; }
|
.studio-app .studio-loading { min-height: 180px; }
|
||||||
.studio-app .studio-visually-hidden { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
|
.studio-app .studio-visually-hidden { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
|
||||||
.studio-app .studio-unsaved-dialog { width: min(560px, calc(100% - 32px)); padding: 0; border: 1px solid var(--line-strong); border-radius: 8px; background: var(--paper); color: var(--ink); }
|
/* 모달 dialog 를 화면 가운데에 둔다. UA 기본값(margin:auto)에 맡기면 이 빌드에서는
|
||||||
|
좌상단에 붙는다 — 공개 화면의 .search-dialog 가 같은 이유로 position/inset/margin 을
|
||||||
|
이미 명시하고 있고, 여기도 같은 방식을 쓴다. max-height/overflow 는 내용이 길어졌을 때
|
||||||
|
화면 밖으로 나가지 않게 하는 짝이다. */
|
||||||
|
.studio-app .studio-unsaved-dialog { position: fixed; inset: 0; margin: auto; max-height: calc(100dvh - 32px); overflow: auto; width: min(560px, calc(100% - 32px)); padding: 0; border: 1px solid var(--line-strong); border-radius: 8px; background: var(--paper); color: var(--ink); }
|
||||||
.studio-app .studio-unsaved-dialog::backdrop { background: rgba(23, 24, 27, 0.48); }
|
.studio-app .studio-unsaved-dialog::backdrop { background: rgba(23, 24, 27, 0.48); }
|
||||||
.studio-app .studio-dialog-body { padding: 30px; }
|
.studio-app .studio-dialog-body { padding: 30px; }
|
||||||
.studio-app .studio-dialog-body h2 { margin: 0 0 12px; font-size: 26px; }
|
.studio-app .studio-dialog-body h2 { margin: 0 0 12px; font-size: 26px; }
|
||||||
@@ -38,7 +42,7 @@
|
|||||||
.studio-app .studio-dialog-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; margin-top: 28px; }
|
.studio-app .studio-dialog-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; margin-top: 28px; }
|
||||||
.studio-app .studio-dialog-actions button { min-height: 44px; padding-inline: 14px; border: 1px solid var(--line-strong); border-radius: 5px; background: var(--paper); color: var(--ink); }
|
.studio-app .studio-dialog-actions button { min-height: 44px; padding-inline: 14px; border: 1px solid var(--line-strong); border-radius: 5px; background: var(--paper); color: var(--ink); }
|
||||||
.studio-app .studio-dialog-actions button:last-child { border-color: var(--signal); background: var(--signal); color: #fff; }
|
.studio-app .studio-dialog-actions button:last-child { border-color: var(--signal); background: var(--signal); color: #fff; }
|
||||||
.studio-app .studio-asset-upload-dialog { width: min(560px, calc(100% - 32px)); padding: 0; border: 1px solid var(--line-strong); border-radius: 8px; background: var(--paper); color: var(--ink); }
|
.studio-app .studio-asset-upload-dialog { position: fixed; inset: 0; margin: auto; max-height: calc(100dvh - 32px); overflow: auto; width: min(560px, calc(100% - 32px)); padding: 0; border: 1px solid var(--line-strong); border-radius: 8px; background: var(--paper); color: var(--ink); }
|
||||||
.studio-app .studio-asset-upload-dialog::backdrop { background: rgba(23, 24, 27, 0.48); }
|
.studio-app .studio-asset-upload-dialog::backdrop { background: rgba(23, 24, 27, 0.48); }
|
||||||
.studio-app .studio-asset-upload-dialog .studio-dialog-status { min-height: 20px; margin: 16px 0 0; color: var(--muted); font-size: 13px; }
|
.studio-app .studio-asset-upload-dialog .studio-dialog-status { min-height: 20px; margin: 16px 0 0; color: var(--muted); font-size: 13px; }
|
||||||
.studio-app .studio-asset-upload-dialog .studio-field + .studio-field { margin-top: 18px; }
|
.studio-app .studio-asset-upload-dialog .studio-field + .studio-field { margin-top: 18px; }
|
||||||
|
|||||||
@@ -209,6 +209,20 @@ export const TECH_LOG_ROUTE_RUNTIME = Object.freeze({
|
|||||||
"AssetsPage",
|
"AssetsPage",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
TECH_LOG_STUDIO_TAXONOMY: runtime(
|
||||||
|
"TECH_LOG_STUDIO_TAXONOMY",
|
||||||
|
routeModule(
|
||||||
|
() => import("./studio/pages/taxonomy-page.tsx"),
|
||||||
|
"TaxonomyPage",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TECH_LOG_STUDIO_RELEASES: runtime(
|
||||||
|
"TECH_LOG_STUDIO_RELEASES",
|
||||||
|
routeModule(
|
||||||
|
() => import("./studio/pages/releases-page.tsx"),
|
||||||
|
"StudioReleasesPage",
|
||||||
|
),
|
||||||
|
),
|
||||||
TECH_LOG_STUDIO_NOT_FOUND: runtime(
|
TECH_LOG_STUDIO_NOT_FOUND: runtime(
|
||||||
"TECH_LOG_STUDIO_NOT_FOUND",
|
"TECH_LOG_STUDIO_NOT_FOUND",
|
||||||
routeModule(
|
routeModule(
|
||||||
|
|||||||
@@ -143,6 +143,32 @@ const PLATFORM_KO_MESSAGES = {
|
|||||||
"error.asset_mismatch": "화면 자산 구성이 현재 릴리스와 일치하지 않습니다.",
|
"error.asset_mismatch": "화면 자산 구성이 현재 릴리스와 일치하지 않습니다.",
|
||||||
"error.render_failure": "화면을 표시하지 못했습니다.",
|
"error.render_failure": "화면을 표시하지 못했습니다.",
|
||||||
"error.unknown_failure": "예상하지 못한 문제가 발생했습니다.",
|
"error.unknown_failure": "예상하지 못한 문제가 발생했습니다.",
|
||||||
|
// 아래 22개는 문구가 비어 있어 화면에 "요청한 문구를 표시할 수 없습니다."(common.unavailable)
|
||||||
|
// 가 대신 나가고 있었다 — 실패 종류 38개 중 절반 이상이 그 상태였다. 사용자에게는
|
||||||
|
// 원인 분류가 아니라 무엇이 안 됐고 무엇을 하면 되는지가 필요하므로, 기술 용어를
|
||||||
|
// 그대로 옮기지 않는다. 구분이 필요한 진단 정보는 로그와 error.code 가 들고 있다.
|
||||||
|
"error.request_aborted": "요청이 취소되었습니다.",
|
||||||
|
"error.content_type_mismatch": "서버 응답 형식이 예상과 달라 표시하지 못했습니다.",
|
||||||
|
"error.malformed_json": "서버 응답을 읽지 못했습니다.",
|
||||||
|
"error.response_body_limit": "응답이 너무 커서 표시하지 못했습니다.",
|
||||||
|
"error.envelope_mismatch": "서버 응답 형식이 예상과 달라 표시하지 못했습니다.",
|
||||||
|
"error.schema_mismatch": "서버 응답 형식이 예상과 달라 표시하지 못했습니다.",
|
||||||
|
"error.mapping_contract_violation": "서버 응답을 화면에 옮기지 못했습니다.",
|
||||||
|
"error.result_limit_exceeded": "결과가 너무 많습니다. 조건을 좁혀 주세요.",
|
||||||
|
"error.scope_generation_changed": "화면이 바뀌어 이전 요청을 버렸습니다. 다시 시도해 주세요.",
|
||||||
|
"error.identity_intern_limit_exceeded": "한 번에 처리할 수 있는 항목 수를 넘었습니다.",
|
||||||
|
"error.duplicate_in_flight": "같은 요청이 이미 처리 중입니다.",
|
||||||
|
"error.pagination_contract_violation": "다음 페이지를 불러오지 못했습니다.",
|
||||||
|
"error.conflict": "다른 곳에서 먼저 바뀌었습니다. 새로 불러온 뒤 다시 시도해 주세요.",
|
||||||
|
"error.validation_rejected": "입력 값이 올바르지 않습니다.",
|
||||||
|
"error.unknown_client_failure": "요청을 처리하지 못했습니다.",
|
||||||
|
"error.boot_config_failure": "설정을 불러오지 못했습니다.",
|
||||||
|
"error.release_manifest_failure": "릴리스 정보를 불러오지 못했습니다.",
|
||||||
|
"error.deploy_mismatch": "배포 버전이 현재 화면과 일치하지 않습니다.",
|
||||||
|
"error.storage_unavailable": "브라우저 저장소를 사용할 수 없습니다.",
|
||||||
|
"error.storage_quota_exceeded": "브라우저 저장 공간이 부족합니다.",
|
||||||
|
"error.telemetry_failure": "사용 기록을 전송하지 못했습니다.",
|
||||||
|
"error.query_cache_failure": "화면 데이터를 갱신하지 못했습니다.",
|
||||||
"boot.failure.title": "애플리케이션을 시작할 수 없습니다.",
|
"boot.failure.title": "애플리케이션을 시작할 수 없습니다.",
|
||||||
"boot.field.error": "오류",
|
"boot.field.error": "오류",
|
||||||
"boot.field.code": "코드",
|
"boot.field.code": "코드",
|
||||||
@@ -301,6 +327,30 @@ const PLATFORM_EN_MESSAGES = {
|
|||||||
"error.asset_mismatch": "The page assets do not match this release.",
|
"error.asset_mismatch": "The page assets do not match this release.",
|
||||||
"error.render_failure": "The page could not be displayed.",
|
"error.render_failure": "The page could not be displayed.",
|
||||||
"error.unknown_failure": "An unexpected problem occurred.",
|
"error.unknown_failure": "An unexpected problem occurred.",
|
||||||
|
// Mirrors the ko-KR additions: every failure kind needs copy, or the surface
|
||||||
|
// falls back to `common.unavailable` and tells the reader nothing.
|
||||||
|
"error.request_aborted": "The request was cancelled.",
|
||||||
|
"error.content_type_mismatch": "The server replied in an unexpected format.",
|
||||||
|
"error.malformed_json": "The server reply could not be read.",
|
||||||
|
"error.response_body_limit": "The reply was too large to display.",
|
||||||
|
"error.envelope_mismatch": "The server replied in an unexpected format.",
|
||||||
|
"error.schema_mismatch": "The server replied in an unexpected format.",
|
||||||
|
"error.mapping_contract_violation": "The reply could not be shown on this screen.",
|
||||||
|
"error.result_limit_exceeded": "Too many results. Narrow the filters.",
|
||||||
|
"error.scope_generation_changed": "The screen changed, so the earlier request was dropped. Try again.",
|
||||||
|
"error.identity_intern_limit_exceeded": "More items than this screen can handle at once.",
|
||||||
|
"error.duplicate_in_flight": "The same request is already in progress.",
|
||||||
|
"error.pagination_contract_violation": "The next page could not be loaded.",
|
||||||
|
"error.conflict": "It changed elsewhere first. Reload and try again.",
|
||||||
|
"error.validation_rejected": "Some values are not valid.",
|
||||||
|
"error.unknown_client_failure": "The request could not be completed.",
|
||||||
|
"error.boot_config_failure": "Configuration could not be loaded.",
|
||||||
|
"error.release_manifest_failure": "Release information could not be loaded.",
|
||||||
|
"error.deploy_mismatch": "The deployed version does not match this screen.",
|
||||||
|
"error.storage_unavailable": "Browser storage is unavailable.",
|
||||||
|
"error.storage_quota_exceeded": "Browser storage is full.",
|
||||||
|
"error.telemetry_failure": "Usage data could not be sent.",
|
||||||
|
"error.query_cache_failure": "Screen data could not be refreshed.",
|
||||||
"boot.failure.title": "The application could not start.",
|
"boot.failure.title": "The application could not start.",
|
||||||
"boot.field.error": "Error",
|
"boot.field.error": "Error",
|
||||||
"boot.field.code": "Code",
|
"boot.field.code": "Code",
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import { render } from "@testing-library/react";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
InlineRenderer,
|
||||||
|
PlainText,
|
||||||
|
} from "../../src/features/tech-log/presentation/shared/public-render/inline-renderer.tsx";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작성자가 엔터로 나눠 쓴 글이 한 줄로 이어져 보였다. Markdown 이 한 번의 줄바꿈을 문단 내 공백으로
|
||||||
|
* 읽고, 파서가 그 줄바꿈을 텍스트에 남긴 뒤, HTML 이 다시 공백으로 접기 때문이다. 미리보기와 공개
|
||||||
|
* 화면이 같은 렌더러를 쓰므로 두 곳 모두에서 그랬다.
|
||||||
|
*/
|
||||||
|
describe("문단 안의 줄바꿈", () => {
|
||||||
|
it("줄바꿈을 <br> 로 그린다", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<InlineRenderer content={[{ type: "TEXT", text: "첫째 줄\n둘째 줄" }]} />,
|
||||||
|
);
|
||||||
|
expect(container.querySelectorAll("br")).toHaveLength(1);
|
||||||
|
expect(container.textContent).toBe("첫째 줄둘째 줄");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("줄바꿈이 없으면 <br> 를 넣지 않는다", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<InlineRenderer content={[{ type: "TEXT", text: "한 줄" }]} />,
|
||||||
|
);
|
||||||
|
expect(container.querySelectorAll("br")).toHaveLength(0);
|
||||||
|
expect(container.textContent).toBe("한 줄");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("여러 번 엔터를 친 만큼 내려간다", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<InlineRenderer content={[{ type: "TEXT", text: "가\n나\n다" }]} />,
|
||||||
|
);
|
||||||
|
expect(container.querySelectorAll("br")).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 요약·문제·결론·환경은 Markdown 을 거치지 않고 그대로 그려진다. 본문만 고쳤을 때 이 칸들이
|
||||||
|
* 여전히 이어져 보인 이유이고, 작성자가 "아직 안 고쳐졌다" 고 본 것도 이쪽이다.
|
||||||
|
*/
|
||||||
|
describe("Markdown 을 거치지 않는 평문 칸", () => {
|
||||||
|
it("작성자가 나눠 쓴 줄을 지킨다", () => {
|
||||||
|
const { container } = render(<PlainText text={"문제 첫 줄\n문제 둘째 줄"} />);
|
||||||
|
expect(container.querySelectorAll("br")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("한 줄이면 <br> 를 넣지 않는다", () => {
|
||||||
|
const { container } = render(<PlainText text="한 줄" />);
|
||||||
|
expect(container.querySelectorAll("br")).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -49,10 +49,12 @@ const { AppRouter } = await import("../../src/presentation/routes/app-router.tsx
|
|||||||
const { ApplicationProvider } = await import(
|
const { ApplicationProvider } = await import(
|
||||||
"../../src/presentation/providers/application-provider.tsx"
|
"../../src/presentation/providers/application-provider.tsx"
|
||||||
);
|
);
|
||||||
|
const { renderWithQueryProviders } = await import("../helpers/query-providers.tsx");
|
||||||
|
|
||||||
function renderAt(path: string, disabled: boolean) {
|
function renderAt(path: string, disabled: boolean) {
|
||||||
window.history.pushState({}, "", path);
|
window.history.pushState({}, "", path);
|
||||||
return render(
|
return render(
|
||||||
|
renderWithQueryProviders(
|
||||||
<ApplicationProvider
|
<ApplicationProvider
|
||||||
application={createTestApplication({
|
application={createTestApplication({
|
||||||
session: createAnonymousSessionAdapter(),
|
session: createAnonymousSessionAdapter(),
|
||||||
@@ -68,6 +70,7 @@ function renderAt(path: string, disabled: boolean) {
|
|||||||
>
|
>
|
||||||
<AppRouter />
|
<AppRouter />
|
||||||
</ApplicationProvider>,
|
</ApplicationProvider>,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,11 +11,13 @@ import {
|
|||||||
RouterProvider,
|
RouterProvider,
|
||||||
} from "react-router-dom";
|
} from "react-router-dom";
|
||||||
|
|
||||||
|
import { createExternalAuthSessionAdapter } from "../../src/adapters/auth/external-session-adapter.ts";
|
||||||
import { createAnonymousSessionAdapter } from "../../src/adapters/auth/external-session-adapter.ts";
|
import { createAnonymousSessionAdapter } from "../../src/adapters/auth/external-session-adapter.ts";
|
||||||
import { createTechLogFeatureInstalledInput } from "../../src/features/tech-log/adapters/create-tech-log-feature-input.ts";
|
import { createTechLogFeatureInstalledInput } from "../../src/features/tech-log/adapters/create-tech-log-feature-input.ts";
|
||||||
import { MOCK_STUDIO_INSTALL_CONTEXT } from "../helpers/studio-install-context.ts";
|
import { MOCK_STUDIO_INSTALL_CONTEXT } from "../helpers/studio-install-context.ts";
|
||||||
import { TECH_LOG_FEATURE_ID } from "../../src/features/tech-log/application/tech-log-feature-input.ts";
|
import { TECH_LOG_FEATURE_ID } from "../../src/features/tech-log/application/tech-log-feature-input.ts";
|
||||||
import { ApplicationProvider } from "../../src/presentation/providers/application-provider.tsx";
|
import { ApplicationProvider } from "../../src/presentation/providers/application-provider.tsx";
|
||||||
|
import { renderWithQueryProviders } from "../helpers/query-providers.tsx";
|
||||||
import {
|
import {
|
||||||
AppRouter,
|
AppRouter,
|
||||||
createGroupedRouteObjects,
|
createGroupedRouteObjects,
|
||||||
@@ -102,17 +104,36 @@ function compileTimeGroupedRouteContract() {
|
|||||||
}
|
}
|
||||||
void compileTimeGroupedRouteContract;
|
void compileTimeGroupedRouteContract;
|
||||||
|
|
||||||
function renderRouter() {
|
/**
|
||||||
|
* Studio routes are `session-required`, so a Studio assertion needs a session
|
||||||
|
* that says so — with the anonymous adapter the router correctly renders the
|
||||||
|
* sign-in surface instead of the page, which is what the gate is for.
|
||||||
|
*/
|
||||||
|
function createSignedInSessionAdapter() {
|
||||||
|
return createExternalAuthSessionAdapter({
|
||||||
|
readState: () => "authenticated" as const,
|
||||||
|
subscribe: () => () => {},
|
||||||
|
beginSignIn: async () => {},
|
||||||
|
signOut: async () => {},
|
||||||
|
attachCredential: async () => ({ headers: {} }),
|
||||||
|
recoverSession: async () => "restored" as const,
|
||||||
|
notifyUnauthenticated: () => {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRouter(session = createAnonymousSessionAdapter()) {
|
||||||
const techLog = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT);
|
const techLog = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT);
|
||||||
return render(
|
return render(
|
||||||
<ApplicationProvider
|
renderWithQueryProviders(
|
||||||
application={createTestApplication({
|
<ApplicationProvider
|
||||||
session: createAnonymousSessionAdapter(),
|
application={createTestApplication({
|
||||||
featureInputs: { [TECH_LOG_FEATURE_ID]: techLog.input },
|
session,
|
||||||
})}
|
featureInputs: { [TECH_LOG_FEATURE_ID]: techLog.input },
|
||||||
>
|
})}
|
||||||
<AppRouter />
|
>
|
||||||
</ApplicationProvider>,
|
<AppRouter />
|
||||||
|
</ApplicationProvider>,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,15 +157,17 @@ describe("generic application router", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<ApplicationProvider application={createTestApplication()}>
|
renderWithQueryProviders(
|
||||||
<LocaleProvider>
|
<ApplicationProvider application={createTestApplication()}>
|
||||||
<ThemeProvider>
|
<LocaleProvider>
|
||||||
<SessionProvider>
|
<ThemeProvider>
|
||||||
<RouterProvider router={router} />
|
<SessionProvider>
|
||||||
</SessionProvider>
|
<RouterProvider router={router} />
|
||||||
</ThemeProvider>
|
</SessionProvider>
|
||||||
</LocaleProvider>
|
</ThemeProvider>
|
||||||
</ApplicationProvider>,
|
</LocaleProvider>
|
||||||
|
</ApplicationProvider>,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
@@ -260,15 +283,17 @@ describe("generic application router", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<ApplicationProvider application={createTestApplication()}>
|
renderWithQueryProviders(
|
||||||
<LocaleProvider>
|
<ApplicationProvider application={createTestApplication()}>
|
||||||
<ThemeProvider>
|
<LocaleProvider>
|
||||||
<SessionProvider>
|
<ThemeProvider>
|
||||||
<RouterProvider router={router} />
|
<SessionProvider>
|
||||||
</SessionProvider>
|
<RouterProvider router={router} />
|
||||||
</ThemeProvider>
|
</SessionProvider>
|
||||||
</LocaleProvider>
|
</ThemeProvider>
|
||||||
</ApplicationProvider>,
|
</LocaleProvider>
|
||||||
|
</ApplicationProvider>,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(await screen.findByTestId("studio-layout")).toBeVisible();
|
expect(await screen.findByTestId("studio-layout")).toBeVisible();
|
||||||
@@ -307,15 +332,17 @@ describe("generic application router", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<ApplicationProvider application={createTestApplication()}>
|
renderWithQueryProviders(
|
||||||
<LocaleProvider>
|
<ApplicationProvider application={createTestApplication()}>
|
||||||
<ThemeProvider>
|
<LocaleProvider>
|
||||||
<SessionProvider>
|
<ThemeProvider>
|
||||||
<RouterProvider router={router} />
|
<SessionProvider>
|
||||||
</SessionProvider>
|
<RouterProvider router={router} />
|
||||||
</ThemeProvider>
|
</SessionProvider>
|
||||||
</LocaleProvider>
|
</ThemeProvider>
|
||||||
</ApplicationProvider>,
|
</LocaleProvider>
|
||||||
|
</ApplicationProvider>,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
@@ -355,10 +382,27 @@ describe("generic application router", () => {
|
|||||||
expect(screen.queryByText("Not Found", { exact: true })).not.toBeInTheDocument();
|
expect(screen.queryByText("Not Found", { exact: true })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps Studio routes inside the persistent Studio layout", async () => {
|
it("keeps a signed-out visitor out of the Studio layout entirely", async () => {
|
||||||
window.history.pushState({}, "", "/studio");
|
window.history.pushState({}, "", "/studio");
|
||||||
renderRouter();
|
renderRouter();
|
||||||
|
|
||||||
|
// Not merely "no data": the Studio surface itself must not mount. Every
|
||||||
|
// TechLog route used to register as `access: "public"`, so a signed-out
|
||||||
|
// visitor who typed /studio got the shell, the navigation, and the page —
|
||||||
|
// and the page then issued Studio API calls.
|
||||||
|
await screen.findByRole("heading", { name: /세션|로그인/ });
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("heading", { name: "작업 흐름" }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("navigation", { name: "Studio 주 탐색" }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps Studio routes inside the persistent Studio layout", async () => {
|
||||||
|
window.history.pushState({}, "", "/studio");
|
||||||
|
renderRouter(createSignedInSessionAdapter());
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
await screen.findByRole("heading", { name: "작업 흐름" }),
|
await screen.findByRole("heading", { name: "작업 흐름" }),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
@@ -371,7 +415,7 @@ describe("generic application router", () => {
|
|||||||
|
|
||||||
it("gives the Studio wildcard precedence over the Public not-found route", async () => {
|
it("gives the Studio wildcard precedence over the Public not-found route", async () => {
|
||||||
window.history.pushState({}, "", "/studio/missing");
|
window.history.pushState({}, "", "/studio/missing");
|
||||||
renderRouter();
|
renderRouter(createSignedInSessionAdapter());
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
await screen.findByRole("heading", {
|
await screen.findByRole("heading", {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user