diff --git a/.claude/commands/release-readiness.md b/.claude/commands/release-readiness.md new file mode 100644 index 00000000..98b34f96 --- /dev/null +++ b/.claude/commands/release-readiness.md @@ -0,0 +1,19 @@ +--- +description: Review the current branch for release readiness against its base +argument-hint: "[base-ref, default origin/main]" +--- + +You are running a release-readiness review for ReadMates. + +Base ref: `${ARGUMENTS:-origin/main}`. Review range: `${ARGUMENTS:-origin/main}..HEAD`. + +Do NOT limit the review to the latest implementation plan. Review the whole diff of the branch against its base, following `docs/development/release-readiness-review.md`. + +Steps: + +1. `git fetch` the base ref if needed, then read the full diff and commit list for `${ARGUMENTS:-origin/main}..HEAD`. +2. Walk every checklist item in `docs/development/release-readiness-review.md`: CHANGELOG/Unreleased, CI/deploy scripts, operator-facing behavior changes, security-code hygiene, architecture-test baselines/exceptions, and public-release safety. +3. Run the smallest relevant verification for the touched surfaces (see `AGENTS.md`): frontend `pnpm --dir front lint|test|build`, server `./server/gradlew -p server clean test`, e2e `pnpm --dir front test:e2e` for auth/BFF/route changes. +4. For public-release-affecting work, run `./scripts/build-public-release-candidate.sh` then `./scripts/public-release-check.sh .tmp/public-release-candidate`. + +Report: changed surfaces, checks actually run (with results), and any remaining risk or skipped validation. Passing tests are evidence, not proof that no operational or release risk remains. diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000..5b0e7cb0 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://json.schemastore.org/claude-code-settings.json", + "permissions": { + "allow": [ + "Bash(pnpm --dir front lint:*)", + "Bash(pnpm --dir front test:*)", + "Bash(pnpm --dir front build:*)", + "Bash(pnpm --dir front test:e2e:*)", + "Bash(./server/gradlew:*)", + "Bash(./scripts/build-public-release-candidate.sh:*)", + "Bash(./scripts/public-release-check.sh:*)", + "Bash(git status:*)", + "Bash(git diff:*)", + "Bash(git log:*)" + ] + } +} diff --git a/.gitignore b/.gitignore index d1e3e113..3930b6b8 100644 --- a/.gitignore +++ b/.gitignore @@ -47,6 +47,16 @@ replay_pid* # ============================================================================= node_modules/ *.tsbuildinfo +dist/ +.vite/ +.turbo/ + +# ============================================================================= +# Test / e2e artifacts (root-level safety net; front/ has its own too) +# ============================================================================= +coverage/ +test-results/ +playwright-report/ # ============================================================================= # Logs, PIDs, runtime artifacts @@ -75,7 +85,10 @@ node_modules/ .cloudflare .vercel .superpowers/ -.claude/ +.claude/* +!.claude/settings.json +!.claude/commands/ +!.claude/commands/** .orchestrator/ .codex-orchestrator/ .waygent/ diff --git a/CHANGELOG.md b/CHANGELOG.md index f53ad05f..2d0692c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,13 +8,33 @@ ReadMates는 Git tag와 GitHub Releases를 함께 사용합니다. 이 파일은 ### Highlights -- **Admin vNext S1 IA Foundation**: `/admin` 단일 페이지를 9-라우트 lazy-split 패밀리로 분해했습니다. 공유 좌측 nav · 상단 status strip · 권한 매트릭스 · URL-state onboarding modal · 표준 "준비 중" empty state를 갖춘 `AdminShellLayout` 위에서 5개 READY 라우트(`today`·`clubs`·`clubs/:clubId`·`ai-ops`·`support`)와 4개 COMING-SOON 라우트(`health`·`notifications`·`audit`·`analytics`)가 `admin-route-catalog` SSOT로 구동됩니다. 후속 슬라이스는 자기 라우트를 `coming_soon → ready`로 토글하는 한 줄 변경으로 자기 자리를 채울 수 있습니다. +- 다음 릴리즈 후보 변경을 이 섹션에 기록합니다. + +## v1.12.0 - 2026-05-31 + +### Highlights + +- **Admin vNext route family**: `/admin` 단일 페이지를 9-라우트 lazy-split 패밀리로 분해했습니다. 공유 좌측 nav · 상단 status strip · 권한 매트릭스 · URL-state onboarding modal을 갖춘 `AdminShellLayout` 위에서 `today`·`health`·`clubs`·`clubs/:clubId`·`notifications`·`ai-ops`·`support`·`audit`·`analytics` 9개 READY 라우트가 `admin-route-catalog` SSOT로 구동됩니다. +- **Admin vNext 운영 콘솔 확장**: `/admin/notifications`를 READY 라우트로 전환해 outbox, delivery, 실패 cluster, club별 알림 health와 two-step replay preview/confirm을 제공합니다. `/admin/clubs/:clubId`는 readiness, 멤버/세션/알림/AI 사용량을 하나의 운영 상세로 묶고, `/admin/support`는 사용자 검색 기반 grant 생성과 ledger/revoke 흐름을 제공합니다. - **AI 운영 콘솔 + 호스트 복구**: `/admin`에서 AI job 상태, 실패 코드, 비용 추정, stale 후보를 보는 AI Ops 표면을 추가하고, 호스트 세션 편집기에서 자기 세션의 in-flight AI job을 다시 찾아 안전하게 취소/재시도할 수 있게 했습니다. - **Query foundation 완주**: `archive`, `feedback`, `public` read path를 Query loader seeding으로 이전하고, AI commit 후 full page reload 대신 관련 Query cache invalidation으로 화면을 갱신합니다. - **운영 안전망 보강**: 일일 MySQL 백업 systemd timer와 복구 runbook, release-tag `Unreleased` guard(`--release` gated, `--no-changelog-check` 비상 우회), graphify 기반 코드베이스 탐색 워크플로를 도입했습니다. +- `/admin/clubs`: triage list now orders clubs by operational severity (긴급/주의/정상), shows the blocking reasons inline, and adds a severity filter so operators see at-risk clubs first. +- `/admin/clubs`: triage now counts each club's recent (7-day) notification-delivery and AI-generation failures, ranks any club with a failure as 긴급, and shows `알림 실패 N건` / `AI 실패 N건` as the leading reasons so operators see member-impacting failures first. +- `/admin/clubs/:clubId`: 운영 스냅샷에 최근 7일 알림/AI 실패 추이(지난 7일 대비 델타)와 readiness 차단 신호별 next-action 링크를 추가하고, 플랫폼 운영/호스트 운영 섹션을 구분했습니다. +- **Admin vNext S8 분석/리포팅 lite**: `/admin/analytics`를 마지막 COMING-SOON 라우트에서 READY로 전환했습니다. 7/30/90일 윈도우 선택(URL state)으로 활성 멤버·세션 완료율·RSVP 응답률·AI 비용/세션·알림 도달률을 현재-대비-직전 윈도우 델타로 보여주고, 클럽 간 비교(cross-club benchmark)를 제공합니다. 분모가 0인 지표는 차트를 지어내지 않고 "데이터 부족" empty state로 정직하게 표기합니다. 새 read-only 서버 슬라이스 `admin.analytics`(controller → service → JDBC adapter)가 클럽 전반의 원시 카운트를 집계하고, 비율·델타·가용성 파생은 순수 application service에서 단위 테스트로 검증합니다. +- **member/host reading loop:** host dashboard의 다음 운영 행동과 member home/current-session의 다음 읽기 행동을 role-safe reading-loop 상태로 정렬했습니다. 공유 모델은 admin-only 신호를 노출하지 않고, showcase 문서는 private workflow를 guest에게 열지 않은 채 sanitized 테스트와 문서 evidence로 설명합니다. ### Engineering +- **observability:** clarified `readmates.aigen.queue.depth` as Redis active AI job backlog (`PENDING` + `RUNNING`) rather than a placeholder or Kafka consumer-lag metric. Metrics catalog, dashboard copy, alert wording, runbook triage, and KDoc now use the same meaning without adding high-cardinality labels. +- **release-readiness:** reclassified the v1.11.0 production OAuth and backup-timer residuals, then recorded both as closed after browser-profile OAuth return evidence, VM timer installation, and a manual Object Storage upload proof. The release-readiness checklist now distinguishes automated closure, manual operator evidence, and out-of-scope pre-existing risk. +- **platform-admin/a11y:** admin 전 라우트와 host dashboard에 하드닝 베이스라인을 적용했습니다. admin shell에 skip-link와 라벨된 nav/main 랜드마크를 추가하고, 이름 없는 상호작용 요소를 막는 의존성 없는 테스트 가드(`findUnnamedInteractiveElements`)와 각 라우트 a11y 어서션을 도입했습니다. 색 대비·키보드 순서·모바일 360px는 `docs/development/admin-hardening-baseline.md` 의 수동 게이트로 문서화했습니다. +- **host-surface:** club operations의 host-적절 신호(준비 상태·세션 진행·AI 사용량)를 중립 계약 `front/shared/model/club-operations.ts`로 분리해 admin·host가 공유합니다. host dashboard는 `/api/host/club-operations`(host 인증, 자기 클럽만)로 read-only 운영 신호 카드를 렌더합니다. admin 전용 신호(support grant, raw member email, notification replay, safeLinks)는 host projection에서 제외하며, admin↔host 직접 import는 경계 테스트로 차단합니다. host에 write 명령은 추가하지 않습니다. +- **platform-admin:** `/admin/audit`의 AI 운영(AI_OPS) 감사 행 상세에 `/admin/ai-ops?clubId=…` 드릴다운 링크를 추가해, 운영자가 감사 신호에서 해당 클럽의 AI job 필터 뷰(원인→조치)로 바로 이동할 수 있게 했습니다. `/admin/health`의 AI provider 카드는 이미 `/admin/ai-ops`로 연결됩니다. 신규 서버 계약 없이 기존 `target.clubId`만 사용하며, P1 필터 모델(`?clubId=`/`?errorCode=`)을 SSOT로 재사용합니다. raw provider error/transcript는 노출하지 않습니다. +- **platform-admin:** `/admin/ai-ops` now offers an OWNER/OPERATOR `Retry commit` action on jobs stuck in `COMMITTING`. It recovers the job to `SUCCEEDED` (reusing the commit service's existing recovery transition) so the host can re-commit, without admin writing any session content and without deleting the result snapshot. The action is audit-logged (`RETRY_COMMIT`, COMMITTING→SUCCEEDED) and SUPPORT is denied. No new generation state or transition semantics were introduced. +- **platform-admin:** `/admin/ai-ops` summary now shows a 7/30/90-day cost/usage trend (`?window=`) with current-vs-prior delta. The JDBC adapter returns only raw window cost/count; the application service derives delta/availability (pure, unit-tested) and reports `NOT_ENOUGH_DATA` honestly when the prior window had no jobs. No charting library added; the month-to-date headline is unchanged. aigen-local window enum keeps the slice framework-independent. +- **platform-admin:** `/admin/ai-ops` failure codes are now drilldown controls. Selecting a failure code filters the job list to the affected clubs/sessions and reflects the filter in URL state (`?errorCode=`), with a "전체 보기" control to clear it. Filtered empty states stay honest ("이 필터에 해당하는 AI job이 없습니다.") and no raw provider error/content fields are exposed. - **deploy:** `deploy/oci/backup-mysql.service` + `backup-mysql.timer`를 추가해 04:15 UTC에 MySQL dump → OCI Object Storage 업로드를 자동화합니다. 복구·검증·보존(30/6/1) 절차는 [`docs/operations/runbooks/db-backup.md`](docs/operations/runbooks/db-backup.md)에 정리합니다. - **deploy:** post-deploy watch가 부모 attempt id를 자식 attempt로 전파하도록 수정해 배포 ledger의 attempt 계보가 정확히 이어집니다 (`deploy/oci/watch-compose-post-deploy.sh`, `deploy/oci/tests/watch-attempt-id.test.sh`). - **scripts:** `scripts/pre-push-check.sh`에 `--release`/`READMATES_PRE_PUSH_RELEASE=true` 조건의 `CHANGELOG Unreleased` guard를 추가했습니다. concrete 카테고리 헤더, feature-style bold marker, 두 개 이상 placeholder를 거부합니다. `--no-changelog-check`로 비상 우회하며, branch protection bypass 정책은 [`docs/development/release-management.md`](docs/development/release-management.md)에 함께 문서화했습니다. @@ -35,6 +55,43 @@ ReadMates는 Git tag와 GitHub Releases를 함께 사용합니다. 이 파일은 notification dispatch success ratio. 10-second `@Scheduled` refresh into an `AtomicReference` cache; per-card failures stay isolated (one provider down → that card only is `status=unknown`). - Hardened `/admin/health` with a pinned camelCase snapshot contract, seven-card fixture coverage, refresh/stale UI, deploy strip rendering, and isolated provider refresh behavior. +- **platform-admin:** ship `/admin/audit` as a read-only operating ledger over platform, club, notification replay, and AI audit sources. The route uses safe metadata projection, role-aware masking, cursor pagination, and S8-compatible filter vocabulary without exposing raw provider errors, email bodies, transcripts, or generated result JSON. +- **platform-admin:** `/admin/today` now shows an operations ledger that prioritizes club readiness, domain, notification, and AI Ops work. +- **platform-admin:** expand the operating console with notification operations, club operations, and support workbench slices. + `/api/admin/notifications/*` adds read-only ledgers plus OWNER/OPERATOR two-step replay preview/confirm with an audit trail in Flyway V35. + `/api/admin/clubs/{clubId}/operations` returns aggregate-only readiness, member/session, notification, and AI usage signals without raw member email/body fields. + `/api/admin/support/*` adds masked user search, grant ledger, 24-hour maximum grant creation, duplicate-active protection, and revoke support. +- **frontend/query:** move admin notification ledgers, club operations snapshots, and support search/grants into platform-admin Query modules with route loader seeding and focused invalidation. `/admin/health` outbox drilldowns now target `/admin/notifications?focus=...`. +- **architecture:** server slice registry now covers `admin.audit`, `admin.health`, and `aigen`; `aigen` passes application-safe actor values instead of web/session carriers. Frontend boundary tests also enforce Query module imports, and `/admin/health` keeps data orchestration in the route layer with presentation-only UI components. +- **public-release:** public release documentation now matches the helper scripts: `docs/superpowers/` remains a private historical archive outside the clean release candidate, while current source-of-truth material belongs in `docs/development/`, `docs/deploy/`, or `docs/operations/`. +- **platform-admin:** add the read-only `admin.analytics` slice and `/admin/analytics` overview. + `GET /api/admin/analytics/overview?window=7d|30d|90d` aggregates raw counts across clubs over + current/prior windows; the application service derives rate/delta/availability (pure, unit-tested) + while the JDBC adapter returns only counts. Metric contract pinned as `admin.analytics_overview.v1`: + ACTIVE_MEMBERS, SESSION_COMPLETION, RSVP_RATE, AI_COST_PER_SESSION, NOTIFICATION_DELIVERY, with + `NOT_ENOUGH_DATA` when a denominator is 0 and numeric `deltaDirection` (UP/DOWN/FLAT/NONE) leaving + good/bad coloring to the UI. No charting library added — trend is current-vs-prior delta. Registered + in `ServerArchitectureBoundaryTest` and covered by service/adapter/controller and e2e tests asserting + no `@example.com` or raw JSON bodies leak. + +### Deployment Notes + +- **DB migration**: Flyway V34 (`ai_generation_admin_action_audit` + `ai_generation_audit_log` indexes) supports AI Ops/audit lookups, and Flyway V35 (`admin_notification_replay_previews`) creates the admin notification replay preview/audit table. Both are additive; rollback leaves unused rows/tables until a later cleanup migration. +- **배포 순서**: server image를 먼저 배포해 V34/V35와 새 `/api/admin/**` contract를 적용한 뒤 frontend를 배포합니다. 이전 frontend는 새 endpoint를 호출하지 않으므로 서버 선배포가 안전합니다. +- **운영 확인**: OWNER 또는 OPERATOR 권한으로 `/admin/notifications`에서 replay preview가 10분 TTL과 selection hash를 반환하는지, confirm 후 audit row와 outbox replay 상태가 남는지 확인합니다. `/admin/support`에서는 grant 만료가 24시간 이내로 제한되고 masked email만 보이는지 확인합니다. AI generation이 켜진 환경에서는 `/admin/ai-ops` summary/job ledger와 `/admin/audit`의 AI source filter가 raw transcript나 provider error body 없이 렌더링되는지도 확인합니다. +- **Branch protection exception**: PR #10 was CI-green but blocked by a one-review/code-owner requirement in a single-collaborator repository, so the release uses an admin merge after documenting the exception in the release-readiness review. Before the next DB/API release, configure a non-author reviewer/code owner or adjust branch protection to avoid requiring an impossible self-review. + +### Verification + +- Local release preparation (2026-05-31): `git diff --check v1.11.0..HEAD -- . ':(exclude)docs/superpowers/**'` — pass. +- Local release preparation (2026-05-31): `./scripts/pre-push-check.sh --release --dry-run` — pass; `CHANGELOG Unreleased` guard accepted the placeholder-only section and printed the release-mode command plan. +- Local release preparation (2026-05-31): `pnpm --dir front lint` — pass. +- Local release preparation (2026-05-31): `pnpm --dir front test` — pass (125 files, 1090 tests). +- Local release preparation (2026-05-31): `pnpm --dir front build` — pass. +- Local release preparation (2026-05-31): `./server/gradlew -p server clean test` — pass (Gradle build successful; `test` task skipped by current Gradle task selection/configuration). +- Local release preparation (2026-05-31): `pnpm --dir front test:e2e` — pass (57/57). +- Local release preparation (2026-05-31): `./scripts/build-public-release-candidate.sh` — pass. +- Local release preparation (2026-05-31): `./scripts/public-release-check.sh .tmp/public-release-candidate` — pass; gitleaks scanned 8.06 MB and found no leaks. ## v1.11.0 - 2026-05-18 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..43c994c2 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/README.md b/README.md index fd784929..6f08af8e 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ ReadMates는 여러 정기 독서모임의 세션 준비, 참여 관리, 기록 처음 보는 리뷰어라면 아래 순서가 가장 빠릅니다. -1. **제품 표면 확인** — 게스트로 공개 클럽 소개, 공개 기록, 공개 세션 상세를 확인합니다. 시작점은 [Guest-mode walkthrough](docs/showcase/guest-mode-walkthrough.md)입니다. +1. **제품 표면 확인** — 게스트로 공개 클럽 소개, 공개 기록, 공개 세션 상세를 확인합니다. 멤버/호스트 reading loop는 권한상 비공개이므로 [Guest-mode walkthrough](docs/showcase/guest-mode-walkthrough.md)에서 public surface와 private workflow evidence를 함께 확인합니다. 2. **아키텍처 판단** — Cloudflare Pages Functions BFF, Spring API, MySQL/Flyway, Redis/Kafka, AI generation, release safety가 어떻게 연결되는지 [Architecture evidence](docs/showcase/architecture-evidence.md)에서 봅니다. 3. **유지보수 품질 확인** — frontend boundary, server ArchUnit, query budget, public release scan 같은 검증은 [Engineering confidence](docs/showcase/engineering-confidence.md)에 정리합니다. 4. **운영 증거 확인** — release readiness, deploy runbook, post-deploy watch, postmortem 흐름은 [Operational proof](docs/showcase/operational-proof.md)에서 봅니다. @@ -57,7 +57,7 @@ ReadMates는 이 문제를 단순 게시판이나 CRUD 목록으로 풀지 않 | 둘러보기 멤버 | 초대 없이 Google로 로그인한 계정입니다. 비공개 세션 기록, 현재 세션 현황, 멤버 공개 예정 세션을 읽을 수 있지만 RSVP, 체크인, 질문/서평 작성, 피드백 문서 열람, 호스트 도구는 제한됩니다. | | 정식 멤버 | 초대 링크를 수락했거나 호스트가 전환한 계정입니다. 현재 세션 참여, 예정 세션 확인, RSVP, 읽은 분량 제출, 질문, 한줄평, 장문 서평 작성, 본인 표시 이름과 이메일 알림 설정 변경, `/app/notifications` 알림함 확인이 가능하며 참석한 회차의 피드백 문서를 읽을 수 있습니다. | | 호스트 | 정식 멤버 권한에 운영 권한이 추가됩니다. 초대 생성, 둘러보기 멤버 전환, 멤버 상태와 표시 이름 관리, 예정 세션 생성/수정, 공개 범위 설정, 현재 세션 시작, 참석 확정, 진행 세션 닫기, 닫힌 기록 발행, AI 생성 또는 JSON 가져오기를 통한 세션 기록 패키지 저장, 세션별 수동 알림 발송과 발송 원장 운영을 수행합니다. | -| 플랫폼 관리자 | `/admin/today` 트리아지 · `/admin/clubs[/{clubId}]` 클럽 운영 · `/admin/ai-ops` AI Ops · `/admin/support` 지원 라우트 패밀리에서 좌측 nav, 상단 status strip, OWNER/OPERATOR/SUPPORT 권한 매트릭스로 플랫폼 운영을 합니다. 클럽별 호스트/멤버 권한과 별도 권한입니다. | +| 플랫폼 관리자 | `/admin/today` 트리아지 · `/admin/health` 운영 헬스 · `/admin/notifications` 알림 운영 · `/admin/clubs[/{clubId}]` 클럽 운영 · `/admin/ai-ops` AI Ops · `/admin/support` 지원 · `/admin/audit` 감사 ledger · `/admin/analytics` 운영 분석 라우트 패밀리에서 좌측 nav, 상단 status strip, OWNER/OPERATOR/SUPPORT 권한 매트릭스로 플랫폼 운영을 합니다. 클럽별 호스트/멤버 권한과 별도 권한입니다. | 로그인은 Google OAuth를 사용하며, 로컬 개발에서는 fixture 기반 dev-login을 사용할 수 있습니다. @@ -228,6 +228,7 @@ pnpm --dir front dev | 로컬 실행 | [docs/development/local-setup.md](docs/development/local-setup.md) | | 아키텍처 상세 | [docs/development/architecture.md](docs/development/architecture.md) | | 코드베이스 graph 탐색 | [docs/development/graphify.md](docs/development/graphify.md) | +| Cross-surface 작업 체크리스트 | [docs/development/vertical-slice-checklist.md](docs/development/vertical-slice-checklist.md) | | 세션 기록 JSON 가져오기 | [docs/development/session-import-generator.md](docs/development/session-import-generator.md) | | 디자인 시스템 | [design/README.md](design/README.md) | | 주요 기술적 의사결정 | [docs/development/technical-decisions.md](docs/development/technical-decisions.md) | diff --git a/docs/README.md b/docs/README.md index 7ed7f0aa..20d9a6b8 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,6 +8,7 @@ ReadMates 문서의 진입점입니다. 어떤 일을 할 때 어디 문서를 | --- | --- | | 코드의 현재 동작·경계를 이해하고 싶다 | [`development/architecture.md`](development/architecture.md) | | 로컬에서 실행·테스트해 보고 싶다 | [`development/local-setup.md`](development/local-setup.md), [`development/test-guide.md`](development/test-guide.md) | +| frontend/server/BFF/auth/persistence를 함께 건드리는 변경 기준을 확인한다 | [`development/vertical-slice-checklist.md`](development/vertical-slice-checklist.md) | | 세션 기록 JSON 가져오기 형식을 확인한다 | [`development/session-import-generator.md`](development/session-import-generator.md) | | 디자인 시스템과 gallery catalog를 확인한다 | [`../design/README.md`](../design/README.md) | | 운영에 배포하거나 배포 절차를 확인한다 | [`deploy/README.md`](deploy/README.md) | diff --git a/docs/agents/docs.md b/docs/agents/docs.md index 302f684f..ff981dab 100644 --- a/docs/agents/docs.md +++ b/docs/agents/docs.md @@ -25,6 +25,7 @@ Documentation rules: - Use placeholders such as `https://api.example.com`, ``, and `host@example.com`. - Preserve the Korean-first documentation style in current docs. - Prefer small factual patches over broad rewrites unless the user asks for a rewrite. +- Architecture flexibility changes should keep `docs/development/architecture.md`, ADR-0002, ADR-0003, `docs/agents/front.md`, and `docs/agents/server.md` aligned with the boundary tests that enforce the rule. - For CHANGELOG, release-readiness, or residual-risk review work, use `docs/development/release-readiness-review.md` and do not treat passing tests as sufficient evidence that release or operational risk is closed. - If docs describe frontend, server, or UI rules in detail, read the matching surface guide before changing those claims. - For unstable external facts such as product limits, pricing, APIs, laws, or platform behavior, verify against current official sources or clearly state that the fact was not revalidated. diff --git a/docs/agents/front.md b/docs/agents/front.md index dab67df2..13db6196 100644 --- a/docs/agents/front.md +++ b/docs/agents/front.md @@ -27,9 +27,10 @@ src/app -> src/pages -> features -> shared - `src/app`: router, layouts, guards, providers, route continuity. - `src/pages`: thin route compatibility shells; delegate to feature route modules. - `features//api`: BFF calls and request/response contracts. +- `features//queries`: TanStack Query keys, `queryOptions`, mutation hooks, and invalidation policy; do not import UI, route, app, or page modules. - `features//model`: pure calculation/mapping; no React, router, fetch, or API client imports. - `features//route`: loader/action behavior, API/model calls, route state, UI prop assembly. -- `features//ui`: render from props/callbacks only; no `fetch`, `shared/api`, feature API, or route imports. +- `features//ui`: render from props/callbacks only; no `fetch`, `shared/api`, feature API, feature queries, or route imports. - `shared`: reusable primitives; do not import feature/page/app code. - `functions`: Cloudflare Pages Functions for same-origin BFF and OAuth proxy routes; never expose BFF secrets through `VITE_*`. diff --git a/docs/agents/server.md b/docs/agents/server.md index 2410d2a6..ab143f56 100644 --- a/docs/agents/server.md +++ b/docs/agents/server.md @@ -37,6 +37,8 @@ Operational Flyway migrations live under `server/src/main/resources/db/mysql/mig CQRS read/write package split: write-side feature(`auth`, `club`, `session`, `notification`)는 entity와 도메인 invariant를 갖는 `domain/` 패키지와 트랜잭션 mutation을 수행하는 application service를 둡니다. Read-side feature(`note`, `publication`, `archive`)는 `domain/` 없이 `application/model/`의 read DTO와 `JdbcXxxAdapter` 직접 query만 두고, application service에 `@ReadOnlyApplicationService` 마커(`com.readmates.shared.architecture`)를 부착합니다. `feedback`은 문서 업로드 mutation + 조회를 함께 가진 mixed slice이고, `sessionimport`는 preview read path와 commit write path를 함께 가진 mixed slice입니다. 둘 다 read-only marker 미부착입니다. Read-only service는 mutation port(`*SavePort`/`*UpdatePort`/`*DeletePort`/`*WriterPort`/`*StorePort`/`*WritePort` suffix in `*.port.out.*`)와 `@Transactional`을 모두 금지합니다 — `ServerArchitectureBoundaryTest`가 강제합니다. 자세한 컨벤션은 [docs/development/architecture.md](../development/architecture.md)의 "CQRS Read vs Write Package Split" 섹션을 참고합니다. +Recent architecture work classifies server slices as write-side, read-side, ops read-side, or workflow-side. `admin.audit` is read-side, `admin.health` is ops read-side, and `aigen` is workflow-side. Workflow-side slices may orchestrate transactions and side effects, but provider SDKs, Redis, JDBC, Kafka, and mail details stay behind outbound ports/adapters. + Security boundaries: - Browser traffic should go through Cloudflare/Vite same-origin BFF routes. diff --git a/docs/deploy/security-public-repo.md b/docs/deploy/security-public-repo.md index 70c84b26..528c2df3 100644 --- a/docs/deploy/security-public-repo.md +++ b/docs/deploy/security-public-repo.md @@ -29,7 +29,6 @@ Active 또는 active 가능 secret이 발견되면 문서 수정으로 끝내지 - `docs/deploy/` - `docs/development/` - `docs/operations/README.md`와 `docs/operations/runbooks/` -- `docs/superpowers/`의 sanitized historical design and implementation records - 공개 릴리즈 후보 생성, 검사, fixture 검증, 배포 후 공개 연동 smoke용 `scripts/` 공개 릴리즈 후보에서 제외하는 주요 경로: @@ -38,6 +37,7 @@ Active 또는 active 가능 secret이 발견되면 문서 수정으로 끝내지 - `.env`, `.env.*`, 단 `.env.example`은 예외 - `.envrc`, `.envrc.*` - `front/.env*` +- `docs/superpowers/`의 historical design/spec/implementation records - sanitization을 거치지 않은 private planning docs - 실제 멤버 데이터, 로컬 절대 경로, private domain, provider state, 실제 secret, token-shaped example, 개인 Gmail 주소가 남아 있는 historical planning docs - `design/` @@ -164,7 +164,7 @@ gitleaks dir "$tmp" --config "$tmp/.gitleaks.toml" --no-banner --redact=100 --ve rm -rf "$tmp" ``` -`docs/superpowers/`는 sanitized historical documentation만 공개 후보에 포함할 수 있습니다. 이 경로를 포함하려면 current-tree scan과 candidate scan 모두에서 local path, private domain, Gmail address, provider token, real-looking secret assignment 검사를 통과해야 합니다. no-arg current-tree scan이 `docs/superpowers/`에서 실패하면 공개 후보를 만들기 전에 해당 문서를 먼저 정리합니다. +`docs/superpowers/`는 historical design/spec/implementation record로 보관하되 clean 공개 릴리즈 후보에는 포함하지 않습니다. 현재 동작이나 운영 절차로 승격된 내용은 `docs/development/`, `docs/deploy/`, `docs/operations/` 중 적절한 source-of-truth 문서로 옮긴 뒤 공개 후보 scanner 대상에 둡니다. 현재 private tree에서 `docs/superpowers/` finding이 필요 이상으로 많다면 공개 후보 build/check 결과를 우선하고, 해당 historical 문서를 source-of-truth로 승격하지 않습니다. Git history까지 검사하는 `gitleaks detect --source .`는 이미 공개된 과거 commit의 redacted 예시나 fixture를 계속 보고할 수 있습니다. 현재 tree와 clean 후보가 `gitleaks dir` 및 public-release check를 통과하고 active secret이 아니라는 검토가 끝났다면 history rewrite를 기본 선택으로 삼지 않습니다. History rewrite, force-push, mirror push는 기존 fork, clone, cache, search index에 남은 흔적을 보장해서 제거하지 못하므로 active 또는 active 가능 secret이 확인되고 별도 승인이 있을 때만 검토합니다. diff --git a/docs/development/README.md b/docs/development/README.md index 4a53df6c..0eb92d6d 100644 --- a/docs/development/README.md +++ b/docs/development/README.md @@ -9,6 +9,7 @@ ReadMates를 로컬에서 실행하고, 테스트하고, 구조를 이해하기 | 로컬 실행 | [local-setup.md](local-setup.md) | | 테스트, 공개 릴리즈, 배포 smoke 점검 | [test-guide.md](test-guide.md) | | 제품/기술 구조와 frontend route-first 경계 | [architecture.md](architecture.md) | +| Cross-surface vertical slice 체크리스트 | [vertical-slice-checklist.md](vertical-slice-checklist.md) | | 코드베이스 graph 탐색 | [graphify.md](graphify.md) | | 세션 기록 JSON 생성과 가져오기 | [session-import-generator.md](session-import-generator.md) | | 디자인 시스템과 pattern catalog | [../../design/README.md](../../design/README.md) | @@ -27,6 +28,7 @@ ReadMates를 로컬에서 실행하고, 테스트하고, 구조를 이해하기 ## 주요 구조 문서 - 프런트엔드 route-first 경계, feature `api/model/route/ui` 책임, legacy 예외 제거 기준은 [architecture.md](architecture.md)의 "프런트엔드 route-first 경계" 섹션을 기준으로 합니다. +- frontend, BFF, server API, auth, persistence, public-safety를 함께 건드리는 변경은 [vertical-slice-checklist.md](vertical-slice-checklist.md)로 surface, server, BFF/auth, frontend, test 범위를 먼저 확인합니다. - 코드베이스 graph 탐색과 public-safe Graphify 산출물 정책은 [graphify.md](graphify.md)를 기준으로 합니다. Graphify 결과는 탐색 보조이며 현재 동작의 source of truth는 실제 코드, 테스트, migration, active docs입니다. - 서버 current member 해석, OAuth/login return 경계, optional Redis 계층, 멀티 클럽 context/domain model, 현재/예정 세션 조회, `DRAFT -> OPEN -> CLOSED -> PUBLISHED` lifecycle, 멤버 세션 쓰기, 호스트 세션 쓰기, 세션/기록 공개 범위, 세션 기록 JSON 가져오기와 in-app AI 세션 생성, 이메일 템플릿과 멤버 알림 설정/알림함, 호스트 알림 운영과 수동 발송, 멤버 프로필/표시 이름 경계는 [architecture.md](architecture.md)의 "멀티 클럽 context와 도메인 모델", "서버 내부 구조", "Optional Redis 계층", "세션 lifecycle과 공개 범위", "세션 기록 JSON 가져오기", "AI-assisted 콘텐츠 운영", "이메일 알림, 멤버 알림함, 호스트 운영", "멤버 프로필과 표시 이름" 섹션을 기준으로 합니다. - 핵심 기술 선택의 배경, trade-off, 관련 검증 명령은 [technical-decisions.md](technical-decisions.md)를 기준으로 합니다. 현재 accepted ADR 목록은 [adr/README.md](adr/README.md)에서 개별 결정 문서로 확인할 수 있습니다. diff --git a/docs/development/admin-hardening-baseline.md b/docs/development/admin-hardening-baseline.md new file mode 100644 index 00000000..3bba602d --- /dev/null +++ b/docs/development/admin-hardening-baseline.md @@ -0,0 +1,32 @@ +# Admin 하드닝 베이스라인 체크리스트 + +이 문서는 Post–Admin vNext 고도화 엄브렐러의 H 슬라이스 산출물이며, +A/M/P 슬라이스의 공통 게이트로 재사용된다. + +각 admin 라우트(+host dashboard)는 아래를 만족해야 한다. + +## 1. 접근성 (자동 검증 가능) +- [ ] 라우트 본문에 heading이 1개 이상 존재한다 (`getAllByRole("heading")`). +- [ ] 모든 상호작용 요소(`button`, `a[href]`, `[role=button]`, `[role=link]`)가 + 접근 가능한 이름(가시 텍스트 / `aria-label` / `aria-labelledby` / `title`)을 가진다. + → `findUnnamedInteractiveElements(container)` 가 빈 배열. +- [ ] error/empty 상태가 `role="status"` 또는 `role="alert"` 영역으로 노출된다. + +## 2. 접근성 (수동 검증) +- [ ] 키보드 Tab 순서가 시각 순서와 일치하고, 포커스 링이 보인다. +- [ ] admin shell 진입 시 본문으로 건너뛰는 skip-link가 동작한다. +- [ ] 텍스트/배경 색 대비가 WCAG AA(본문 4.5:1, 큰 텍스트 3:1)를 만족한다. + +## 3. 모바일 (수동 검증) +- [ ] 360px 폭에서 nav·테이블·카드가 가로 스크롤 없이 사용 가능하다. +- [ ] 터치 타깃이 충분한 크기를 가진다. + +## 4. Empty / 에러 카피 안전성 +- [ ] 데이터가 얇을 때 정직한 empty state를 보여준다(가짜 데이터 금지). +- [ ] 실패 카피가 provider raw error / private data / token-shaped 예시를 노출하지 않는다. + +## 5. 일관성 +- [ ] 카드·테이블·필터·badge 톤이 admin shell의 calm operating-ledger 톤과 일치한다. + +## 적용 대상 라우트 +today · health · clubs · clubs/:clubId · notifications · ai-ops · support · audit · analytics · (host dashboard) diff --git a/docs/development/adr/0002-server-clean-architecture-with-archunit.md b/docs/development/adr/0002-server-clean-architecture-with-archunit.md index ee6a7105..3a454326 100644 --- a/docs/development/adr/0002-server-clean-architecture-with-archunit.md +++ b/docs/development/adr/0002-server-clean-architecture-with-archunit.md @@ -158,6 +158,7 @@ ArchUnit은 test scope 의존성이다. production 빌드에 영향을 주지 - feature 단위로 cohesion이 높아 "이 feature를 제거하면 어떤 파일을 지우는가"가 명확해진다. feature 폴더 삭제 = feature 제거. - ArchUnit 테스트는 빌드 타임에 빠르게 실행된다. 실제 DB나 외부 서비스 없이 바이트코드 분석만으로 동작한다. - SQL이 코드에 명시적이어서 schema 변경이 코드 리뷰에서 즉시 보인다. +- 2026-05-27 architecture flexibility update: `ServerArchitectureBoundaryTest`는 slice registry를 통해 `admin.audit`, `admin.health`, `aigen`까지 최근 확장 surface를 명시적으로 등록한다. `aigen`은 workflow-side slice로 분류하고, `CurrentMember` 같은 web/session carrier는 application-safe actor value로 변환해 전달한다. 부정적/감수한 비용: - 작은 feature에도 5계층 구조가 강제된다. 보일러플레이트 파일 수가 많아진다. IDE template으로 초기 파일 생성을 자동화해 단축하고 있다. diff --git a/docs/development/adr/0003-frontend-route-first-architecture.md b/docs/development/adr/0003-frontend-route-first-architecture.md index cd306e8d..96630f91 100644 --- a/docs/development/adr/0003-frontend-route-first-architecture.md +++ b/docs/development/adr/0003-frontend-route-first-architecture.md @@ -66,6 +66,7 @@ front/src/app/ — router 설정, layout, auth context, route guard front/src/pages/ — route 호환 shell (기존 경로 유지 shim 포함) front/features// — 도메인 단위 feature api/ — API 호출 함수, Zod schema, response type + queries/ — TanStack Query key, queryOptions, mutation hook, invalidation policy model/ — domain model, type, 순수 계산 함수 route/ — React Router route module (loader, action, component) ui/ — presentational component (props + callback만) @@ -83,6 +84,7 @@ front/shared/ — cross-cutting utilities - `shared/`는 `features/`, `src/app/`, `src/pages/`를 import할 수 없다. - `features//`는 `features//`를 직접 import할 수 없다. - `features//ui/`는 `features//api/` 또는 `shared/api/`를 import할 수 없다 (presentation이 API 호출 금지). +- `features//queries/`는 API contract와 shared query primitive를 사용할 수 있지만 UI, route, app, page module을 import하지 않는다. - `features//model/`은 React, React Router, API client를 import하지 않는다 (순수 계산만). 이 규칙은 `front/tests/unit/frontend-boundaries.test.ts`로 강제된다. @@ -100,11 +102,11 @@ ReadMates의 화면은 세션 상태, 멤버십, 공개 범위 조합에 따라 ### feature 단위 cohesion -feature 폴더 하나에 `api/model/route/ui`가 함께 있으면: +feature 폴더 하나에 `api/queries/model/route/ui`가 함께 있으면: - 이 feature를 제거할 때 폴더 하나를 삭제하면 된다. - 신규 멤버가 "이 기능의 코드가 어디에 있는가"를 feature 이름으로 즉시 찾을 수 있다. - 동일 feature 내 파일들의 import 그래프가 feature 경계 안에서 닫힌다. -- feature 별로 API 호출 함수와 Zod schema(`api/`), 도메인 모델(`model/`), route module(`route/`), 순수 UI(`ui/`)가 명확히 분리된다. +- feature 별로 API 호출 함수와 Zod schema(`api/`), server-state freshness와 invalidation(`queries/`), 도메인 모델(`model/`), route module(`route/`), 순수 UI(`ui/`)가 명확히 분리된다. ### import 경계 자동화 @@ -113,6 +115,7 @@ feature 폴더 하나에 `api/model/route/ui`가 함께 있으면: 특히 다음 패턴들이 강제된다: - shared-to-feature import 차단: `shared/ui/`가 `features/`를 import하면 테스트 실패 - feature 간 직접 import 차단: `features/session/`에서 `features/host/`를 import하면 테스트 실패 +- query가 UI/route를 import하는 패턴 차단: `features//queries/`에서 `features//ui/` 또는 `features//route/`를 import하면 테스트 실패 - presentation이 API 호출하는 패턴 차단: `features//ui/`에서 `shared/api/client`를 import하면 테스트 실패 ### shared/의 역할 명확화 @@ -158,7 +161,7 @@ front/shared/ — api/, auth/, config/, ui/, model/, security/, routing/ - `shared/` 경계가 명확해 cross-cutting utility(인증 상태, API client, URL helper)가 feature로 누수되지 않는다. 부정적/감수한 비용: -- 작은 feature에도 4개 하위 폴더(`api/model/route/ui`)를 만들어야 한다. 실용적 규칙: route 파일이 1개이고 API call이 없으면 폴더 구조 없이 `features//index.tsx` 단일 파일로 시작한다. +- 작은 feature에도 5개 하위 폴더(`api/queries/model/route/ui`)를 만들어야 한다. 실용적 규칙: route 파일이 1개이고 API call이 없으면 폴더 구조 없이 `features//index.tsx` 단일 파일로 시작한다. - feature 간 공유가 필요한 UI 컴포넌트는 `shared/ui/`로 올려야 한다. 이 판단이 반복되면 `shared/ui/`가 비대해질 수 있다. - `features/` 간 직접 import 금지로 인해, 한 feature가 다른 feature의 상태를 알아야 할 때 URL state 또는 `shared/` 경유가 필요하다. - legacy `src/pages/` shim이 남아 있어 route URL과 feature 폴더 위치가 완전히 1:1로 대응되지 않는다. 점진적 제거 중. @@ -196,7 +199,7 @@ pnpm --dir front test - feature 내 상태 관리 패턴 결정: URL state(React Router search params), URL-independent local state(useState), server state(loader data) 각각의 사용 기준을 명문화. 현재 암묵적 관례로만 유지. - feature 별 Zod schema와 `FrontendZodSchemaContractTest` 커버리지 확대 (ADR-0009 연계). - optimistic UI 패턴 표준화: React Router 7 action/fetcher API를 사용한 optimistic update가 어느 feature에 적용되었는지 목록화하고, 실패 시 rollback 패턴을 feature 간에 통일. -- `frontend-boundaries.test.ts` 커버리지 확장: 현재 `shared`/`features` 간 import 방향만 검증한다. feature 내 `route/` 파일에서 `ui/` 파일이 `api/`를 import하는 패턴도 경계 검증으로 추가 가능. +- `frontend-boundaries.test.ts` 커버리지 확장: 현재 `shared`/`features` 간 import 방향과 feature 내부 `model`/`queries`/`ui`/`route` 경계를 검증한다. route TSX render-only 예외를 더 줄이고, legacy host query/UI 예외를 제거하는 후속 migration이 필요하다. - feature 단위 번들 크기 모니터링: 각 feature route module의 chunk 크기를 빌드 산출물에서 추적해 의도치 않은 bundle bloat를 조기 발견하는 CI check 추가 검토. - shared/auth 세션 상태 갱신 전략: 멤버십 상태가 서버에서 변경된 경우 프런트엔드의 `shared/auth` 캐시된 세션 상태가 stale 될 수 있다. polling 또는 server-sent event로 갱신하는 패턴 결정 필요. - accessibility 표준화: feature 별 UI 컴포넌트가 WCAG 기준을 따르는지 검증. feature 아키텍처에서 `ui/` 컴포넌트가 독립적이어서 accessibility 테스트 대상이 명확하다. diff --git a/docs/development/adr/0010-public-repo-safety-automation.md b/docs/development/adr/0010-public-repo-safety-automation.md index c2a757a5..f45e643f 100644 --- a/docs/development/adr/0010-public-repo-safety-automation.md +++ b/docs/development/adr/0010-public-repo-safety-automation.md @@ -28,7 +28,7 @@ ReadMates는 운영 중인 서비스의 코드베이스를 공개 저장소로 ### 3. 내부 배포 정보 노출 -- OCI OCID(Oracle Cloud resource identifier): `ocid1.instance.oc1...` 형식. 운영 인프라 자원 식별자가 노출되면 대상이 특정된다. +- OCI OCID(Oracle Cloud resource identifier): `` 형식. 운영 인프라 자원 식별자가 노출되면 대상이 특정된다. - 내부 호스트명: 운영 MySQL 호스트, 내부 DNS 이름. - 로컬 개발 환경의 절대 경로: macOS의 홈 디렉토리 기반 경로 — 기여자의 로컬 경로가 주석이나 하드코딩으로 남을 수 있다. - 실제 서비스 도메인: 등록된 운영 도메인명. 구현 코드에 직접 사용하면 노출됨. @@ -101,8 +101,8 @@ keywords = ["PRIVATE KEY"] [[rules]] id = "readmates-oci-ocid" -regex = '''ocid1[.][a-z0-9][a-z0-9._-]{16,}''' -keywords = ["ocid1."] +regex = '''''' +keywords = [""] [[rules]] id = "readmates-github-token" @@ -230,7 +230,7 @@ scanner pattern 자체 검증: 기대: clean manifest 생성 성공 + scanner 통과. scanner regression 검증: -- fixture에 OCI OCID 형식 문자열(`` — 실제 fixture에는 `ocid1....` 형식의 표본 값 사용) 삽입 → `verify-public-release-fixtures.sh` 실행 → `readmates-oci-ocid` finding 보고 확인 +- fixture에 OCI OCID 형식 문자열(`` — 실제 fixture는 스크립트 런타임에 탐지 대상 패턴을 조립) 삽입 → `verify-public-release-fixtures.sh` 실행 → `readmates-oci-ocid` finding 보고 확인 - `.gitleaks.toml`에 새 allowlist 추가 후 `public-release-check.sh` 재실행 → false positive가 사라지는지 확인 ## 후속 작업 diff --git a/docs/development/architecture.md b/docs/development/architecture.md index a7abad08..19ea7956 100644 --- a/docs/development/architecture.md +++ b/docs/development/architecture.md @@ -14,7 +14,7 @@ ReadMates는 여러 정기 독서모임의 공개 소개, 멤버 세션 준비, | 로그인 후 진입 | `/app`, `/clubs/:slug/app`, 등록된 club host의 `/app` | 로그인 사용자 | 가입 클럽이 하나면 해당 클럽 앱으로 이동하고, 여러 개면 클럽 선택 화면을 보여주며, 선택한 클럽 context로 앱에 진입 | | 멤버 앱 | `/clubs/:slug/app`, `/clubs/:slug/app/pending`, `/clubs/:slug/app/session/current`, `/clubs/:slug/app/notes`, `/clubs/:slug/app/archive`, `/clubs/:slug/app/sessions/:sessionId`, `/clubs/:slug/app/feedback/:sessionId`, `/clubs/:slug/app/feedback/:sessionId/print`, `/clubs/:slug/app/me`, `/clubs/:slug/app/notifications`, 등록된 club host의 `/app/**` | 둘러보기 멤버, 정식 멤버, 호스트 | 현재 세션 확인, 멤버 공개 예정 세션 확인, 둘러보기 멤버 안내, RSVP, 읽은 분량, 질문, 한줄평, 장문 서평, 아카이브, 참석 회차 피드백 문서, 본인 표시 이름과 알림 설정 변경, 클럽별 멤버 알림함 확인 | | 호스트 앱 | `/clubs/:slug/app/host`, `/clubs/:slug/app/host/notifications`, `/clubs/:slug/app/host/members`, `/clubs/:slug/app/host/invitations`, `/clubs/:slug/app/host/sessions/new`, `/clubs/:slug/app/host/sessions/:sessionId/edit`, 등록된 club host의 `/app/host/**` | 현재 클럽의 호스트 | 예정 세션 생성/수정, 공개 범위 설정, 현재 세션 시작, 참석 확정, 진행 세션 닫기, 닫힌 기록 발행, 세션 기록 완성(AI 생성 또는 외부 JSON 가져오기), 초대 관리, 멤버 상태와 표시 이름 관리, 세션 기록 패키지 저장, 알림 발송 운영 | -| 플랫폼 관리 | `/admin` | platform admin | 클럽 생성, 클럽 목록 확인, 공개/비공개 상태 관리, 공개 소개 정보 관리, 등록형 domain alias 요청과 상태 확인, 첫 호스트 온보딩 상태 확인. 세션/멤버/알림 같은 클럽 내부 운영은 호스트 앱 책임 | +| 플랫폼 관리 | `/admin`, `/admin/today`, `/admin/health`, `/admin/notifications`, `/admin/clubs`, `/admin/clubs/:clubId`, `/admin/support`, `/admin/ai-ops`, `/admin/audit`, `/admin/analytics` | platform admin | `/admin/today`의 운영 ledger에서 클럽 공개 readiness, domain action, 알림 실패, AI job 이상을 오늘 처리할 queue로 모아 보고, 클럽 생성, 클럽 목록 확인, 공개/비공개 상태 관리, 공개 소개 정보 관리, 등록형 domain alias 요청과 상태 확인, 첫 호스트 온보딩 상태 확인, 운영 health와 알림 outbox/delivery 상태 확인, 클럽 운영 readiness 집계, 제한된 support access grant 관리, AI job 운영 조회와 강제 취소, 통합 감사 ledger 조회를 수행합니다. `/admin/analytics`는 S8 coming-soon placeholder입니다. 세션/멤버/알림 발송 같은 클럽 내부 운영은 기본적으로 호스트 앱 책임이고, platform admin 표면은 aggregate/read-only 진단과 감사 가능한 복구 작업만 다룹니다. | ## 프런트엔드 route-first 경계 @@ -22,12 +22,13 @@ ReadMates는 여러 정기 독서모임의 공개 소개, 멤버 세션 준비, `shared/ui`는 재사용 가능한 presentation primitive만 소유하며 `src/app`, `src/pages`, `features`를 import하지 않습니다. Router, route continuity, provider context처럼 app이 소유한 의존성은 app/page/feature route composition에서 주입하거나 shared boundary 밖의 primitive로 옮긴 뒤 사용합니다. -Feature는 가능한 범위에서 `api`, `model`, `route`, `ui`로 나눕니다. +Feature는 가능한 범위에서 `api`, `queries`, `model`, `route`, `ui`로 나눕니다. - `features//api`는 해당 feature의 BFF endpoint 호출과 request/response contract만 담당합니다. -- `features//model`은 React, React Router, API client를 import하지 않는 순수 화면 모델 계산만 둡니다. -- `features//route`는 loader/action, route error/loading state, API/model 호출, UI props 조립을 담당합니다. -- `features//ui`는 props와 callback으로만 렌더링하며 `fetch`, `shared/api`, feature API, route module을 직접 import하지 않습니다. +- `features//queries`는 TanStack Query key, `queryOptions`, mutation hook, invalidation policy를 담당합니다. UI와 route module을 import하지 않습니다. +- `features//model`은 React, React Router, TanStack Query, API client를 import하지 않는 순수 화면 모델 계산만 둡니다. +- `features//route`는 loader/action, route error/loading state, query seeding, API/model 호출, UI props 조립을 담당합니다. +- `features//ui`는 props와 callback으로만 렌더링하며 `fetch`, `shared/api`, feature API, feature queries, route module을 직접 import하지 않습니다. `shared/api/readmates` compatibility module은 제거되었고, feature route/page는 feature-owned API contract 또는 `shared/api` primitive를 사용해야 합니다. `features/*/components`는 `ui`로 이동하지 않은 legacy presentation surface에만 남길 수 있습니다. `ui` directory가 있는 feature에서는 외부 source가 `features//components`를 public surface처럼 import하지 않습니다. Host feature는 `features/host/ui`가 공개 presentation surface이며, host components legacy public surface는 없습니다. @@ -102,6 +103,10 @@ Platform admin의 domain 상태 확인은 `https:///.well-known/readma 사용자 역할은 club membership마다 독립적입니다. 현재 club membership role은 `HOST`와 `MEMBER`이고, platform admin 권한은 `platform_admins`의 `OWNER`, `OPERATOR`, `SUPPORT`로 별도 판정합니다. Platform `OPERATOR`는 club host가 아니며, 특정 클럽의 호스트 도구를 쓰려면 그 클럽 membership에서도 `HOST` 권한이 있어야 합니다. +Platform admin today ledger는 `/admin/today` route에서 기존 admin summary, clubs, notification snapshot, AI Ops summary/jobs query를 조합해 클럽 공개 readiness, domain action, 알림 위험, AI job failure/stale 신호를 하나의 작업 queue와 선택 항목 brief로 계산합니다. Summary와 clubs query는 기본 queue를 만들기 위한 필수 데이터이고, notification/AI query 실패는 해당 운영 신호만 `확인 불가` 또는 disabled 상태로 격리해 클럽 readiness queue를 blank 처리하지 않습니다. Platform admin club operations는 `/api/admin/clubs/{clubId}/operations`에서 현재 클럽의 readiness, lifecycle, host/member/session counts, notification health, AI usage summary를 aggregate-only read model로 반환합니다. Support workbench는 `/api/admin/support/search`, `/api/admin/support/grants`를 사용해 masked email 중심 사용자 검색, active grant ledger, grant create/revoke를 처리합니다. Support access grant 생성은 OWNER 권한, 활성 platform admin grantee, eligible club, 중복 active grant 없음, 24시간 이내 만료, non-blank reason을 모두 만족해야 합니다. + +Platform admin AI Ops는 `readmates.aigen.enabled=true`일 때 `/api/admin/ai-generation/summary`, `/api/admin/ai-generation/jobs`, `/api/admin/ai-generation/jobs/{jobId}`, `/api/admin/ai-generation/jobs/{jobId}/force-cancel`을 사용합니다. 요약과 job ledger는 provider/model/status/error/cost 중심의 안전한 projection만 반환하고, force-cancel은 platform admin actor, 이전/다음 상태, 결과, 안전한 error code를 Flyway V34 `ai_generation_admin_action_audit`에 기록합니다. + Public cache, 멤버 알림 deep link, host 알림 운영 ledger는 club id 또는 club slug를 포함해 scope를 나눕니다. 공개 cache key는 club id 기준으로 분리하고, 알림 link는 `/clubs/:slug/app/**` canonical path를 사용해 로그인 후에도 원래 클럽 화면으로 복귀합니다. ## 서버 내부 구조 @@ -116,7 +121,7 @@ adapter.in.web -> adapter.out.persistence ``` -현재 full port/outbound adapter chain을 따르는 범위는 `publication`, `archive`, `feedback`, `session`, `note`, `auth`의 운영 API surface와 `notification` 운영 slice입니다. Disabled password/password-reset/dev-invitation accept endpoint는 `410 Gone` stub으로 남습니다. Auth의 OAuth filter, success handler, cookie/session 보안 구성은 `auth.infrastructure.security`와 `auth.adapter.in.security`에 따로 둡니다. +현재 full port/outbound adapter chain을 따르는 범위는 `publication`, `archive`, `feedback`, `session`, `note`, `auth`의 운영 API surface, `notification` 운영 slice, `club`의 platform-admin 운영 slice, `admin.audit`의 platform-admin 감사 read slice, `admin.health`의 ops read slice, `aigen`의 workflow slice입니다. Disabled password/password-reset/dev-invitation accept endpoint는 `410 Gone` stub으로 남습니다. Auth의 OAuth filter, success handler, cookie/session 보안 구성은 `auth.infrastructure.security`와 `auth.adapter.in.security`에 따로 둡니다. Notification slice는 MySQL `notification_event_outbox`를 이벤트 source of truth로 유지합니다. Relay scheduler가 publish 가능한 row를 Kafka topic `readmates.notification.events.v1`로 발행하고, 같은 Spring Boot 모듈의 Kafka consumer가 이벤트별 수신자를 계산해 멤버 선호도를 적용한 뒤 `notification_deliveries`와 `member_notifications`를 만듭니다. 이메일 발송은 `notification_deliveries`의 `EMAIL` row를 기준으로 재시도 가능한 side effect로 처리하고, in-app 알림은 `member_notifications`가 멤버 inbox source of truth입니다. 이벤트 발행 상태는 `notification_event_outbox`, 채널별 발송/skip 상태는 `notification_deliveries`, 멤버 inbox 상태는 `member_notifications`에 저장합니다. @@ -124,6 +129,10 @@ Notification slice는 MySQL `notification_event_outbox`를 이벤트 source of t `NotificationDeliveryEngine`은 claimed email delivery의 SMTP 전송, retry/dead 전환, redacted error 저장, metrics/logging을 한 곳에서 처리하고, automatic event dispatch path, manual event dispatch path, pending-delivery worker path가 같은 engine을 사용합니다. 이메일 copy는 `notification.application.model`의 순수 template helper가 in-app 제목/본문/deep link, 이메일 subject, plain text, HTML을 함께 렌더링하고, SMTP adapter는 HTML이 있으면 plain text fallback을 포함한 multipart MIME으로 발송합니다. 호스트 알림 상세 API는 subject, masked recipient, deep link, allowlist metadata만 노출하고 plain/HTML body는 노출하지 않습니다. 테스트 메일 audit은 별도 `notification_test_mail_audit` table에 masked email과 hash만 저장합니다. 발행 조건, 생성 시점, relay/consumer 주기, 재시도 정책은 [OCI backend runbook](../deploy/oci-backend.md#email-notification-operations)을 기준으로 운영합니다. 패키지 경계는 아래처럼 web/scheduler/Kafka inbound adapter, application service, outbound port, persistence/mail/Kafka adapter로 나눕니다. +Platform admin 알림 운영은 `/api/admin/notifications/snapshot`, `/api/admin/notifications/events`, `/api/admin/notifications/deliveries`, `/api/admin/notifications/replay-preview`, `/api/admin/notifications/replay-confirm`을 사용합니다. Snapshot과 ledgers는 outbox/delivery 상태, relay lag, 실패 cluster, club별 health를 aggregate 중심으로 보여주며 raw email body나 원문 recipient를 노출하지 않습니다. Replay는 OWNER/OPERATOR만 사용할 수 있고 preview selection hash, actor, 10분 TTL, confirm reason을 확인한 뒤 감사 가능한 replay outbox row를 만듭니다. Preview/audit 저장소는 Flyway V35 `admin_notification_replay_previews` table입니다. + +Platform admin 감사 ledger는 `/api/admin/audit/events`와 `/admin/audit`에서 기존 `platform_audit_events`, `club_audit_events`, `ai_generation_audit_log`, `admin_notification_replay_previews`를 읽기 전용 cursor ledger로 통합합니다. Source별 allowlist projection만 응답하며 raw metadata JSON, provider raw error, email body, transcript, generated result JSON은 노출하지 않습니다. S8 analytics와 호환되도록 date range, club scope, source slice, action category, actor role, outcome 필터 이름을 고정합니다. + ```text notification adapter.in.web / adapter.in.scheduler / adapter.in.kafka @@ -135,12 +144,12 @@ notification | 영역 | 현재 패키지 | 역할 | | --- | --- | --- | -| Web/scheduler/Kafka adapter | `publication.adapter.in.web`, `archive.adapter.in.web`, `feedback.adapter.in.web`, `session.adapter.in.web`, `sessionimport.adapter.in.web`, `note.adapter.in.web`, `auth.adapter.in.web`, `notification.adapter.in.web`, `notification.adapter.in.scheduler`, `notification.adapter.in.kafka`, `shared.adapter.in.web` | HTTP request validation, `CurrentMember` 주입, use case 호출, scheduler trigger, Kafka listener dispatch, response mapping; shared health endpoint | +| Web/scheduler/Kafka adapter | `publication.adapter.in.web`, `archive.adapter.in.web`, `feedback.adapter.in.web`, `session.adapter.in.web`, `sessionimport.adapter.in.web`, `note.adapter.in.web`, `auth.adapter.in.web`, `notification.adapter.in.web`, `notification.adapter.in.scheduler`, `notification.adapter.in.kafka`, `admin.audit.adapter.in.web`, `admin.health.adapter.in.web`, `aigen.adapter.in.web`, `aigen.adapter.in.messaging`, `shared.adapter.in.web` | HTTP request validation, `CurrentMember` 주입, use case 호출, scheduler trigger, Kafka listener dispatch, response mapping; shared health endpoint | | Security adapter/infrastructure | `auth.adapter.in.security`, `auth.infrastructure.security` | Spring Security `Authentication` 해석, OAuth/session filter, cookie/security wiring | -| Inbound port | `publication.application.port.in`, `archive.application.port.in`, `feedback.application.port.in`, `session.application.port.in`, `sessionimport.application.port.in`, `note.application.port.in`, `auth.application.port.in`, `notification.application.port.in`, `club.application.port.in` | controller나 scheduler가 의존하는 use case contract | -| Application service | `publication.application.service`, `archive.application.service`, `feedback.application.service`, `session.application.service`, `sessionimport.application.service`, `note.application.service`, `auth.application`/`auth.application.service`, `notification.application.service`, `club.application.service` | command/query orchestration과 권한 확인, retryable side effect 처리 | -| Outbound port | `publication.application.port.out`, `archive.application.port.out`, `feedback.application.port.out`, `session.application.port.out`, `sessionimport.application.port.out`, `note.application.port.out`, `auth.application.port.out`, `notification.application.port.out`, `club.application.port.out` | application service가 persistence/mail/HTTP 세부사항 없이 호출하는 contract | -| Persistence/mail/Kafka/HTTP adapter | `publication.adapter.out.persistence`, `archive.adapter.out.persistence`, `feedback.adapter.out.persistence`, `session.adapter.out.persistence`, `sessionimport.adapter.out.persistence`, `note.adapter.out.persistence`, `auth.adapter.out.persistence`, `club.adapter.out.persistence`, `club.adapter.out.http`, `notification.adapter.out.persistence`, `notification.adapter.out.mail`, `notification.adapter.out.kafka` | JDBC query와 row mapping, domain marker HTTP check, 외부 mail delivery와 Kafka publish 세부 구현을 소유하는 outbound adapter | +| Inbound port | `publication.application.port.in`, `archive.application.port.in`, `feedback.application.port.in`, `session.application.port.in`, `sessionimport.application.port.in`, `note.application.port.in`, `auth.application.port.in`, `notification.application.port.in`, `club.application.port.in`, `admin.audit.application.port.in`, `admin.health.application.port.in`, `aigen.application.port.in` | controller나 scheduler가 의존하는 use case contract | +| Application service | `publication.application.service`, `archive.application.service`, `feedback.application.service`, `session.application.service`, `sessionimport.application.service`, `note.application.service`, `auth.application`/`auth.application.service`, `notification.application.service`, `club.application.service`, `admin.audit.application.service`, `admin.health.application.service`, `aigen.application.service` | command/query orchestration과 권한 확인, retryable side effect 처리 | +| Outbound port | `publication.application.port.out`, `archive.application.port.out`, `feedback.application.port.out`, `session.application.port.out`, `sessionimport.application.port.out`, `note.application.port.out`, `auth.application.port.out`, `notification.application.port.out`, `club.application.port.out`, `admin.audit.application.port.out`, `admin.health.application.port.out`, `aigen.application.port.out` | application service가 persistence/mail/HTTP 세부사항 없이 호출하는 contract | +| Persistence/mail/Kafka/HTTP adapter | `publication.adapter.out.persistence`, `archive.adapter.out.persistence`, `feedback.adapter.out.persistence`, `session.adapter.out.persistence`, `sessionimport.adapter.out.persistence`, `note.adapter.out.persistence`, `auth.adapter.out.persistence`, `club.adapter.out.persistence`, `club.adapter.out.http`, `notification.adapter.out.persistence`, `notification.adapter.out.mail`, `notification.adapter.out.kafka`, `admin.audit.adapter.out.persistence`, `admin.health.adapter.out`, `aigen.adapter.out.persistence`, `aigen.adapter.out.redis`, `aigen.adapter.out.messaging`, `aigen.adapter.out.llm` | JDBC query와 row mapping, domain marker HTTP check, 외부 provider/mail/Kafka publish 세부 구현을 소유하는 outbound adapter | | Redis adapter | `auth.adapter.out.redis`, `publication.adapter.out.redis`, `note.adapter.out.redis`, `aigen.adapter.out.redis`, `shared.adapter.out.redis` | 선택적 Redis rate limit/cache/invalidation, AI generation job handoff/cost counter 구현. application service는 Redis adapter가 아니라 port에만 의존 | 전환된 controller는 legacy repository, `JdbcTemplate`, persistence adapter를 직접 주입받지 않습니다. 인증된 사용자는 controller method에서 `CurrentMember`로 받으며, resolver가 `ResolveCurrentMemberUseCase`를 통해 멤버 정보를 조회합니다. @@ -149,7 +158,7 @@ notification Application package는 Spring Web/HTTP type, HTTP client, adapter 구현체에 의존하지 않습니다. Application service는 feature application error를 던지고, HTTP status와 response mapping은 `adapter.in.web`의 controller 또는 error handler가 맡습니다. 외부 HTTP가 필요한 기능은 application outbound port를 정의하고 `adapter.out.http` 구현으로 분리합니다. -아키텍처 경계는 `ServerArchitectureBoundaryTest`에서 강제합니다 (ADR-0002). 이 테스트는 전환된 web adapter가 legacy repository, `JdbcTemplate`, outbound persistence/Redis adapter, Spring Data Redis에 직접 의존하지 않는지, `session`/`publication`/`archive`/`feedback`/`note`/`auth`/`notification`/`club` application package가 adapter, Spring JDBC, Spring DAO, Spring Data Redis, Spring Web/HTTP 세부사항에 의존하지 않는지, domain package가 web/JDBC/persistence 세부사항에 의존하지 않는지 확인합니다. +아키텍처 경계는 `ServerArchitectureBoundaryTest`에서 강제합니다 (ADR-0002). 이 테스트는 slice registry로 `admin.audit`, `admin.health`, `aigen`을 포함한 전환 surface를 등록하고, 전환된 web adapter가 legacy repository, `JdbcTemplate`, outbound persistence/Redis adapter, Spring Data Redis에 직접 의존하지 않는지, application package가 adapter, Spring JDBC, Spring DAO, Spring Data Redis, Spring Web/HTTP 세부사항에 의존하지 않는지, `aigen.application`이 `CurrentMember` 같은 web/session carrier 대신 application-safe actor value를 사용하는지, domain package가 web/JDBC/persistence 세부사항에 의존하지 않는지 확인합니다. ## CQRS Read vs Write Package Split @@ -162,14 +171,19 @@ ReadMates 서버는 도메인 패키지를 다음 두 형태로 운영합니다. - 트랜잭션 mutation을 수행 ### Read-side (domain/ 없음) -- `note`, `publication`, `archive` +- `note`, `publication`, `archive`, `admin.audit` - `application/model/` 의 read DTO + `JdbcXxxAdapter` 직접 query - 도메인 엔티티 없이 query result 모델만 정의 - `@ReadOnlyApplicationService` 마커로 식별 (`shared/architecture/`) -### Mixed -- `feedback` — 문서 업로드 mutation + 조회를 함께 보유. 향후 분리 후보지만 현재 단일 service에 응집. +### Ops Read-side +- `admin.health` — 운영 상태 snapshot과 deploy ledger를 provider/adapter에서 읽어 aggregate card model로 반환합니다. +- Mutation surface가 아니며, application service는 provider 결과를 card-local failure로 격리합니다. + +### Mixed / Workflow-side +- `feedback` — 문서 업로드 mutation + 조회를 함께 보유합니다. - `sessionimport` — 호스트 세션 편집기의 preview는 검증 전용 read path이고 commit은 공개 요약, 하이라이트, 한줄평, 피드백 문서를 같은 트랜잭션에서 교체하는 write path입니다. `domain/` 없이 application model과 write port로 응집합니다. +- `aigen` — 외부 LLM provider, Redis job handoff, Kafka worker, commit/recovery orchestration을 포함합니다. Application layer는 provider SDK, Redis, JDBC, Kafka detail이 아니라 outbound port에 의존합니다. ### 강제 규칙 - ArchUnit `ServerArchitectureBoundaryTest` 가 다음을 차단: @@ -296,7 +310,7 @@ ReadMates는 클럽별로 하나의 현재 `OPEN` 세션과 여러 개의 예정 범위가 있는 목록 endpoint는 cursor 기반 page object를 반환합니다. 공통 응답 필드는 `{ "items": [...], "nextCursor": string | null }`이고, 다음 page가 없으면 `nextCursor`는 `null`입니다. Endpoint에 따라 `/api/me/notifications`의 `unreadCount`처럼 목록 전체 상태를 나타내는 추가 field가 붙을 수 있습니다. Request query는 endpoint별 기본값과 최대값을 둔 `limit`, `cursor`를 사용합니다. -이 contract를 따르는 목록은 archive의 `/api/archive/sessions`, `/api/archive/me/questions`, `/api/archive/me/reviews`, notes의 `/api/notes/sessions`, `/api/notes/feed`, feedback의 `/api/feedback-documents/me`, host의 `/api/host/sessions`, `/api/host/members`, `/api/host/members/viewers`, `/api/host/members/pending-approvals`, `/api/host/invitations`, notification의 `/api/me/notifications`, `/api/host/notifications/items`, `/api/host/notifications/events`, `/api/host/notifications/deliveries`, `/api/host/notifications/manual/dispatches`, `/api/host/notifications/test-mail/audit`입니다. `GET /api/host/notifications/manual/options`도 멤버 선택 목록을 같은 cursor page shape로 반환합니다. 예를 들어 `GET /api/host/members/pending-approvals?limit=2`는 pending viewer approval 목록의 첫 page를 반환하고, 다음 page는 응답의 `nextCursor`를 `cursor` query로 넘겨 요청합니다. +이 contract를 따르는 목록은 archive의 `/api/archive/sessions`, `/api/archive/me/questions`, `/api/archive/me/reviews`, notes의 `/api/notes/sessions`, `/api/notes/feed`, feedback의 `/api/feedback-documents/me`, host의 `/api/host/sessions`, `/api/host/members`, `/api/host/members/viewers`, `/api/host/members/pending-approvals`, `/api/host/invitations`, notification의 `/api/me/notifications`, `/api/host/notifications/items`, `/api/host/notifications/events`, `/api/host/notifications/deliveries`, `/api/host/notifications/manual/dispatches`, `/api/host/notifications/test-mail/audit`, platform admin의 `/api/admin/notifications/events`, `/api/admin/notifications/deliveries`, `/api/admin/audit/events`입니다. `GET /api/host/notifications/manual/options`도 멤버 선택 목록을 같은 cursor page shape로 반환합니다. 예를 들어 `GET /api/host/members/pending-approvals?limit=2`는 pending viewer approval 목록의 첫 page를 반환하고, 다음 page는 응답의 `nextCursor`를 `cursor` query로 넘겨 요청합니다. 위 scoped endpoint에는 legacy array response contract가 없습니다. 프런트엔드 loader와 route action은 `items`를 누적하고 `nextCursor`로 명시적인 더보기 control을 보여줘야 하며, 새 scoped 목록 API도 같은 공통 page field를 사용합니다. @@ -437,9 +451,10 @@ ai_generation_audit_log row (PII-safe: provider/model/status/token/cost — no t - `aigen:cost:host::` (String, TTL 31d) — 호스트 월별 누적 비용 (감사 보조). - **Job state machine**: `PENDING -> RUNNING -> SUCCEEDED -> COMMITTING -> COMMITTED`가 정상 commit 경로입니다. `FAILED`와 `CANCELLED`는 terminal 상태입니다. Worker start/completion, regenerate, commit, cancel은 `AiGenerationJobTransitionPolicy`와 Redis Lua CAS(`transitionStatus`, `saveResultIfStatus`)로 현재 상태가 맞을 때만 진행합니다. Commit/cancel 이후에는 `:transcript`와 `:result` payload만 삭제하고 `aigen:job:` hash는 terminal 상태 조회를 위해 TTL까지 남깁니다. Pre-terminal job에서 transcript key가 사라진 경우에는 stale job으로 보고 세 키를 모두 삭제합니다. - **LLM call cap**: `llmCallCount`는 full generation, provider retry, validation retry, regeneration 호출 전마다 Redis hash에서 원자 증가합니다. `readmates.aigen.job.max-llm-calls-per-job`를 넘으면 provider 호출 없이 `MAX_CALLS_EXCEEDED` typed error로 종료합니다. -- **MySQL 테이블** (Flyway V30/V31): +- **MySQL 테이블** (Flyway V30/V31/V34): - `ai_generation_audit_log` — job/session/club/host_user 인덱스 + provider/model/status/`input_tokens`/`cached_input_tokens`/`output_tokens`/`cost_estimate_usd`/`latency_ms`. **transcript 본문 컬럼 없음** (감사 invariant). - `ai_generation_club_defaults` — 클럽별 default provider/model. `clubs(id)` FK. + - `ai_generation_admin_action_audit` — platform admin AI Ops action ledger. `job_id`, `club_id`, `session_id`, `admin_user_id`, `admin_role`, action/result, 이전/다음 상태, safe error code만 저장합니다. - **Frontend 모듈**: `front/features/host/aigen/` — `api/` (BFF 호출 + DTO), `hooks/useAiGenerationJob.ts` (TanStack Query v5 adaptive polling), `ui/` (TranscriptUploadForm, GenerationProgressView, PreviewView + 4개 section, RegenerateModal), `storage/aigen-draft-storage.ts` (PREVIEW 수동 편집 localStorage 저장). Polling은 `COMMITTING` 동안 계속되고 `COMMITTED`/`FAILED`/`CANCELLED`에서 멈춥니다. 호스트 세션 편집기는 `세션 기록 완성` 패널에서 `AI로 생성` / `외부 JSON 가져오기` 모드로 같은 commit 경로를 분기합니다. - **운영 표면**: Micrometer meter는 `com.readmates.aigen.application.service.AiGenerationMetrics`의 8개 meter(`jobs`, `jobs.completed`, `latency`, `tokens`, `cost.usd`, `validation.failures`, `cap.denials`, `queue.depth`)를 노출합니다. `MetricLabel` allowlist가 `club_id`/`user_id` 같은 high-cardinality tag를 막습니다. Prometheus alert는 `ops/prometheus/alerts/aigen-rules.yml`, Grafana 대시보드는 `ops/grafana/dashboards/aigen.json`. 운영 절차와 alert response anchor는 [ai-session-generation runbook](../operations/runbooks/ai-session-generation.md). - **Kill switch**: `readmates.aigen.enabled=false`이면 `AiGenerationKillSwitchFilter`가 `/api/host/sessions/*/ai-generate/**`와 `/api/host/clubs/*/ai-defaults`를 503 + RFC 7807 `AI_DISABLED`로 응답합니다. Controller bean과 Kafka consumer도 `@ConditionalOnProperty`로 컨텍스트에서 빠집니다. `enabled-providers`에서 provider를 제외하면 catalog 단계에서 해당 provider 모델 전체가 사라집니다. 두 flag 모두 기본 off. diff --git a/docs/development/release-readiness-review.md b/docs/development/release-readiness-review.md index d0daf91b..46a276c3 100644 --- a/docs/development/release-readiness-review.md +++ b/docs/development/release-readiness-review.md @@ -9,11 +9,38 @@ - Task 1 (Redis aigen residual): 2026-05-18T12:12Z UTC, automated. Keys: 0. Action: no-op. Ledger event: AIGEN_RESIDUAL_VERIFIED. - Task 2 Step 1 (Local Playwright E2E): 2026-05-18T12:18Z UTC, automated. Specs: 17 pass / 0 fail (grep fallback `@aigen|host`; initial `@aigen|host session editor|platform-admin` matched 0 specs). Log: .tmp/v1.11.0-followups/playwright-e2e-output.log. - Task 2 Step 2-3 (Production host smoke): 2026-05-18T12:18Z UTC, MANUAL REQUIRED. Google OAuth automation blocked at https://accounts.google.com/v3/signin/identifier (no redirect back to readmates.pages.dev under automated browser, per spec S1.4.3). -- [ ] [MANUAL REQUIRED] Task 2 production host smoke — Google OAuth automation blocked. Owner: kws. Target: within 7 days. +- [x] [CLOSED BY OPERATIONAL EVIDENCE] Task 2 production host smoke — 2026-05-31T12:17Z UTC, browser-profile OAuth smoke. Started from `https://readmates.pages.dev/login`, selected an existing Google account in Chrome, and confirmed redirect back to the ReadMates production origin at `/clubs//app`. No credentials, cookies, or account identifiers were captured. - Task 5 (OAuth happy path): 2026-05-18T12:24Z UTC, MANUAL REQUIRED. Playwright MCP redirect from https://readmates.pages.dev/login reached https://accounts.google.com/v3/signin/identifier; Google blocked credential entry under automated browser (spec §S1.4.3 escape hatch). Artifact: .tmp/v1.11.0-followups/oauth-flow-results.json. -- [ ] [MANUAL REQUIRED] Task 5 OAuth happy-path — automation blocked at accounts.google.com/v3/signin/identifier. Owner: kws. Target: within 7 days. +- [x] [CLOSED BY OPERATIONAL EVIDENCE] Task 5 OAuth happy-path — 2026-05-31T12:17Z UTC, same browser-profile OAuth smoke confirmed `/login` -> Google account chooser -> ReadMates production app return. CLI smoke also confirmed Google receives `redirect_uri=https://readmates.pages.dev/login/oauth2/code/google`. - Task 3 (DB backup → Object Storage + daily timer): 2026-05-18T12:37Z UTC, partial. Object upload: automated via local OCI CLI fallback. Uploaded `mysql/readmates-pre-v1.11.0-20260518T113652Z.sql.gz` to bucket `readmates-db-exports` (namespace `ax5hfpscso8v`) with `opc-meta-sha256=4b6c36c237e94736574894065ceabaa08d7492469bc6d45f4600d67903c1c81a`, `opc-meta-tag=pre-v1.11.0`. Local unit files + runbook committed. Timer install on VM: BLOCKED (OCI CLI not installed on VM, ENV_BLOCKER per spec §S1.4.3). Artifact: .tmp/v1.11.0-followups/oci-object-head.json. -- [ ] [MANUAL REQUIRED] Task 3 daily backup timer — VM lacks OCI CLI. Bootstrap per docs/deploy/oci-mysql-heatwave.md, populate /etc/readmates/backup-mysql.env, scp deploy/oci/backup-mysql.{service,timer} → /etc/systemd/system/, daemon-reload, enable --now. Owner: kws. Target: within 7 days. +- [x] [CLOSED BY OPERATIONAL EVIDENCE] Task 3 daily backup timer — 2026-05-31T12:09Z UTC, automated with operator CLI. Installed backup scripts, backup env/defaults, OCI CLI, instance-principal Object Storage policy, `backup-mysql.service`, and `backup-mysql.timer` on the ReadMates VM. Verification: `backup-mysql.timer` is enabled/active with next run scheduled for 2026-06-01T04:19:30Z UTC, and a manual `backup-mysql.service` run uploaded both `mysql/readmates-20260531T120949Z.sql.gz` and `mysql/readmates-20260531T120949Z.sql.gz.sha256`. + +## 2026-05-31 Ops Insight & Release Trust residual policy + +For the Ops Insight & Release Trust branch, residuals are classified as: + +- **Closed by automated evidence** only when a repo command, script, test, or public-safe document proves the condition without private operator access. +- **Manual operational action remains** when Google OAuth credential entry, production host access, VM access, or provider console access is required. +- **Out of scope for this branch** when the item predates the branch and is not changed by analytics, observability, release-readiness, docs, scripts, or deploy behavior. + +The v1.11.0 production OAuth and backup timer items are closed by 2026-05-31 operational evidence. Analytics v2 and observability truth cleanup did not close those items by themselves; the closure evidence above came from browser-profile OAuth smoke, VM timer installation, and manual backup upload proof. + +## 2026-05-31 v1.12.0 release preparation note + +- Scope reviewed: `v1.11.0..HEAD`, with `origin/main..HEAD` also considered because this local `main` is ahead of the remote baseline. +- Release classification: minor release (`v1.12.0`) because the branch adds platform-admin routes/contracts, host/member reading-loop changes, observability/deploy behavior, and additive Flyway migrations V34/V35. +- Executed: `git diff --check v1.11.0..HEAD -- . ':(exclude)docs/superpowers/**'`, `./scripts/pre-push-check.sh --release --dry-run`, `pnpm --dir front lint`, `pnpm --dir front test`, `pnpm --dir front build`, `./server/gradlew -p server clean test`, `pnpm --dir front test:e2e`, `./scripts/build-public-release-candidate.sh`, and `./scripts/public-release-check.sh .tmp/public-release-candidate`. +- Deployment constraint: direct `main` admin-bypass is not release-policy eligible for this version because DB migrations and public API contracts changed. Use a release PR, then create/push the annotated `v1.12.0` tag after merge so `Deploy Front` and `Deploy Server Image` run from the merged release commit. +- Branch protection exception: PR #10 preserved the release PR artifact and CI evidence, but normal merge was blocked by `REVIEW_REQUIRED` after all checks passed because the repository required one code-owner review while no non-author collaborator was available. Use an admin merge for PR #10 only after recording this exception; do not direct-push the release commit to `main`. +- Follow-up: before the next DB/API release, either add a non-author release reviewer/code owner or adjust branch protection so a solo-admin release PR cannot require an impossible self-review. +- Residual risk: production deployment and smoke are not complete until the tag workflows succeed, OCI compose is promoted to `ghcr.io///readmates-server:v1.12.0`, and sanitized post-deploy BFF/OAuth/admin smoke checks pass. + +## 2026-05-31 Ops Insight & Release Trust verification note + +- Scope reviewed: `origin/main..HEAD` (broad because local `main` is ahead of `origin/main` in this workspace). +- Executed: frontend lint/test/build, targeted admin analytics E2E, server clean test, public release candidate build, public release safety scan, production OAuth browser-profile smoke, and backup timer/manual upload proof. +- Skipped: none. +- Residual risk: no v1.11.0 OAuth or backup timer residual remains open after the 2026-05-31 operational evidence recorded above. ## 기본 범위 diff --git a/docs/development/server-state-migration.md b/docs/development/server-state-migration.md index 3adc442f..dc6ded28 100644 --- a/docs/development/server-state-migration.md +++ b/docs/development/server-state-migration.md @@ -4,7 +4,7 @@ ## 이번 분기 진행 범위 -Engineering proof portfolio 분기에서는 아래 순서로 server state migration을 진행했고, 현재 `public` read path와 AI Ops surface까지 완료했습니다. +Engineering proof portfolio 분기에서는 아래 순서로 server state migration을 진행했고, 현재 `public` read path와 platform admin operating console surface까지 완료했습니다. 1. `host/members` — 멤버 목록과 lifecycle/profile/viewer mutation을 Query invalidation 패턴으로 정리합니다. 2. `host/notifications` — 수동 알림 options/preview/confirm/dispatch ledger를 route-owned state와 Query cache로 분리합니다. @@ -13,6 +13,10 @@ Engineering proof portfolio 분기에서는 아래 순서로 server state migrat 5. `platform-admin` — summary, club directory/detail, support grants, onboarding/domain/club mutation cache ownership을 platform admin query module로 모읍니다. 6. `archive` / `feedback` / `public` — 공개/멤버 read path를 Query loader seeding으로 이전하고 AI commit 후 scoped invalidation으로 갱신합니다. 7. `platform-admin/ai-ops` — AI job 운영 요약과 ledger/action을 platform admin query module로 분리합니다. +8. `platform-admin/notifications` — 알림 운영 snapshot, event/delivery ledgers, replay preview/confirm mutation을 platform admin query module로 분리합니다. +9. `platform-admin/club-operations` — 클럽 상세 운영 snapshot을 loader-seeded Query read model로 분리합니다. +10. `platform-admin/support` — support search, grant ledger, grant create/revoke mutation cache ownership을 support route module로 분리합니다. +11. `platform-admin/audit` — 통합 감사 ledger를 loader-seeded Query read model로 분리합니다. Filter URL state는 S8 analytics가 재사용할 date range, club scope, source slice, action category, actor role, outcome vocabulary를 따릅니다. 각 migration은 UI 컴포넌트가 API를 직접 호출하지 않는다는 route-first 경계를 유지해야 합니다. @@ -27,6 +31,10 @@ Engineering proof portfolio 분기에서는 아래 순서로 server state migrat - `feedback` — feedback document reads and AI commit invalidation are Query-owned - `public` — club/session public reads use Query loader seeding with scoped invalidation - `platform-admin/ai-ops` — AI Ops summary/job ledger reads and force-cancel invalidation are Query-owned +- `platform-admin/notifications` — admin notification snapshot, event/delivery cursor ledgers, replay preview, and replay confirm are Query-owned +- `platform-admin/club-operations` — selected club operations snapshot is loader-seeded and Query-owned +- `platform-admin/support` — support search, active grant ledger, grant create, and revoke invalidation are Query-owned +- `platform-admin/audit` — platform/club/notification replay/AI audit source를 Query-owned cursor ledger로 조회하고, route loader seeding과 safe metadata detail rendering을 적용합니다. ## 패턴 - query: `features//queries/-queries.ts` 에 `queryOptions` + `useXxxMutation` export diff --git a/docs/development/test-guide.md b/docs/development/test-guide.md index 5c438d7f..977af8d6 100644 --- a/docs/development/test-guide.md +++ b/docs/development/test-guide.md @@ -131,6 +131,12 @@ pnpm --dir front test:e2e 예정 세션 흐름을 확인하는 `front/tests/e2e/dev-login-session-flow.spec.ts`는 호스트가 `DRAFT` 세션을 만들고, `MEMBER` 공개로 바꾼 뒤, 멤버 홈의 `/api/sessions/upcoming` 표시와 `OPEN` 전환을 함께 검증합니다. `CLOSED`/`PUBLISHED` 기록 lifecycle은 현재 backend DB test와 frontend unit test에서 더 촘촘히 검증합니다. +Member/host reading-loop route smoke: + +```bash +pnpm --dir front test:e2e -- tests/e2e/dev-login-session-flow.spec.ts +``` + 세션 기록 JSON 가져오기 흐름은 frontend model unit test와 backend DB integration test가 1차 검증합니다. ```bash @@ -144,6 +150,12 @@ pnpm --dir front exec vitest run features/host/model/session-import-model.test.t pnpm --dir front test:e2e -- member-profile-permissions ``` +플랫폼 admin 첫 화면의 today operations ledger만 빠르게 확인하려면 아래 spec을 지정합니다. 이 spec은 public-safe BFF 응답을 route mock으로 고정해 OWNER가 queue/brief를 보는 흐름과 SUPPORT가 mutation CTA를 실행할 수 없는 흐름을 검증합니다. + +```bash +pnpm --dir front test:e2e -- tests/e2e/admin-today.spec.ts +``` + ## Backend Backend tests are expected to run on JDK 21. `server/build.gradle.kts` pins the Gradle `Test` JVM to the Java 21 toolchain so local shells using a newer current JVM do not change test runtime behavior. If Gradle cannot find a JDK 21 toolchain locally, install one or set `JAVA_HOME` to a JDK 21 installation before running backend tests. diff --git a/docs/development/vertical-slice-checklist.md b/docs/development/vertical-slice-checklist.md new file mode 100644 index 00000000..6f3eada7 --- /dev/null +++ b/docs/development/vertical-slice-checklist.md @@ -0,0 +1,41 @@ +# Vertical Slice Checklist + +Use this checklist when a change crosses frontend, BFF, server API, auth, persistence, or public-safety boundaries. + +## 1. Surface + +- Product surface is one of public, member, host, platform admin, auth, BFF, or operations. +- The owning feature folder is named before code changes start. +- The change does not introduce real member data, secrets, private domains, deployment state, local paths, OCIDs, or token-shaped examples. + +## 2. Server + +- Controller parses HTTP input and maps responses only. +- Application service owns authorization, lifecycle rules, orchestration, and application errors. +- Persistence, Redis, Kafka, mail, provider SDK, and HTTP client details are behind outbound ports/adapters. +- Read-side services use `@ReadOnlyApplicationService` and do not depend on mutation ports. +- Workflow-side services keep side effects behind ports and document retry or recovery behavior in tests. + +## 3. BFF / Auth + +- Browser traffic uses same-origin `/api/bff/**` when the frontend calls Spring API. +- Internal `x-readmates-*` response headers and secrets are stripped. +- Club context is derived from trusted BFF input, not browser-supplied internal headers. +- Route return values and redirects use safe relative paths unless an allowlisted absolute return flow is explicitly documented. + +## 4. Frontend + +- `api` owns BFF calls and response contracts. +- `queries` owns query keys, `queryOptions`, mutation hooks, and invalidation. +- `model` owns pure view-model calculation and imports no React, router, query, or API client. +- `route` owns loader/action behavior, auth/redirect, URL state, query seeding, and UI prop assembly. +- `ui` renders from props/callbacks and imports no API, query, route, or `shared/api` client. + +## 5. Tests + +- Server boundary change: run `./server/gradlew -p server architectureTest`. +- Server behavior change: run the focused unit or integration test for the slice. +- Frontend boundary change: run `pnpm --dir front exec vitest run tests/unit/frontend-boundaries.test.ts`. +- Frontend behavior change: run the focused Vitest file and the smallest relevant route/component test. +- API, auth, BFF, or user-flow change: run `pnpm --dir front test:e2e`. +- Public release change: run `./scripts/build-public-release-candidate.sh` and `./scripts/public-release-check.sh .tmp/public-release-candidate`. diff --git a/docs/operations/observability/alerts.md b/docs/operations/observability/alerts.md index e70e2be1..176dd375 100644 --- a/docs/operations/observability/alerts.md +++ b/docs/operations/observability/alerts.md @@ -37,7 +37,7 @@ | `AiGenProviderErrorBurst` | warn | provider별 FAILED job ratio > 10% over 10m | `#provider-error-burst` | | `AiGenSchemaFailureSpike` | warn | `SCHEMA_INVALID` validation failure ratio > 20% over 1h | `#schema-failure-spike` | | `AiGenBudgetExhaustion` | info | aggregate 30d AI generation cost > $1000 | `#budget-exhaustion` | -| `AiGenQueueLagHigh` | warn | `readmates_aigen_queue_depth > 50` for 5m | `#queue-lag-high` | +| `AiGenQueueLagHigh` | warn | Redis active AI job backlog `readmates_aigen_queue_depth > 50` for 5m | `#queue-lag-high` | | `AiGenRedisDown` | critical | `redis_up == 0` and HTTP 5xx rate elevated | `#redis-down` | Per-club cost cap은 metric label에 `club_id`를 싣지 않는 정책 때문에 Prometheus alert가 아니라 application cap guard와 `ai_generation_audit_log` SQL drill-down으로 운영합니다. diff --git a/docs/operations/observability/dashboards.md b/docs/operations/observability/dashboards.md index 21d3d37d..e9c78755 100644 --- a/docs/operations/observability/dashboards.md +++ b/docs/operations/observability/dashboards.md @@ -238,7 +238,7 @@ | Cost rate by provider/model | `readmates_aigen_cost_usd_total` | 최근 비용 증가율 확인 | | Top-N club cost | SQL drill-down 안내 | `club_id`를 metric label로 쓰지 않는 정책을 유지하면서 과금 원인 확인 | | Validation failures by reason | `readmates_aigen_validation_failures_total` | schema/author/template 실패 spike 확인 | -| Queue lag | `readmates_aigen_queue_depth` | Kafka consumer lag wiring 후 backlog 확인 | +| Active job backlog | `readmates_aigen_queue_depth` | Redis job store 기준 `PENDING` + `RUNNING` AI job 적체 확인 | | Jobs by status/provider | `readmates_aigen_jobs_completed_total` | 성공/실패/취소 비율 확인 | | Tokens by direction | `readmates_aigen_tokens_total` | input/cached_input/output token 사용량 확인 | | Cap denials by reason | `readmates_aigen_cap_denials_total` | host daily, club monthly, host per-minute cap 거절 확인 | diff --git a/docs/operations/observability/metrics-catalog.md b/docs/operations/observability/metrics-catalog.md index c460032a..93dcfeea 100644 --- a/docs/operations/observability/metrics-catalog.md +++ b/docs/operations/observability/metrics-catalog.md @@ -40,7 +40,7 @@ | `readmates.aigen.cost.usd` | counter | `provider`, `model` | USD | AI generation 누적 비용 추정치. | `server/.../aigen/application/service/AiGenerationMetrics.kt` | dashboards.md#ai-session-generation | alerts.md#aigenbudgetexhaustion | | `readmates.aigen.validation.failures` | counter | `reason` | 건수 | Validation class error로 실패한 AI output 수. | `server/.../aigen/application/service/AiGenerationMetrics.kt` | dashboards.md#ai-session-generation | alerts.md#aigenschemafailurespike | | `readmates.aigen.cap.denials` | counter | `reason` | 건수 | Provider 호출 전 cap guard가 거절한 요청 수. | `server/.../aigen/application/service/AiGenerationMetrics.kt` | dashboards.md#ai-session-generation | — | -| `readmates.aigen.queue.depth` | gauge | (없음) | 건수 | AI generation queue depth. 현재는 consumer lag wiring 전 placeholder 0 gauge입니다. | `server/.../aigen/application/service/AiGenerationMetrics.kt` | dashboards.md#ai-session-generation | alerts.md#aigenqueuelaghigh | +| `readmates.aigen.queue.depth` | gauge | (없음) | 건수 | Redis AI job store에서 `PENDING` + `RUNNING` active job 수를 scrape 시점에 읽은 backlog. `AiGenerationQueueDepthGaugeBinder`가 `AiGenerationJobStore.loadActiveJobs()`에 바인딩한다. | `server/src/main/kotlin/com/readmates/aigen/application/service/AiGenerationMetrics.kt`, `server/src/main/kotlin/com/readmates/aigen/application/service/AiGenerationQueueDepthGaugeBinder.kt` | dashboards.md#ai-session-generation | alerts.md#aigenqueuelaghigh | > **태그 정책**: enum/low-cardinality 값만 허용. `club_id`, `user_id`, `membership_id`, `email`, `delivery_id`, transcript 본문 등 고유 식별자나 민감 본문은 절대 태그로 사용하지 않는다. 행 단위 감사는 notification은 `notification_deliveries`, AI 생성은 `ai_generation_audit_log`를 사용한다. 근거: `server/.../ReadmatesOperationalMetrics.kt`, `server/.../aigen/application/service/AiGenerationMetrics.kt` KDoc 참조. @@ -96,4 +96,4 @@ - `bff_request_total` (counter, by `route`, `host`) — Cloudflare Worker analytics 의존. BFF layer에서 별도 계측 필요. - `frontend_route_load_seconds` (histogram) — RUM (Real User Monitoring) 도입 후 추가. - `readmates.redis.operation.errors` 세분화 — 현재 `feature`/`operation` 2개 태그로 충분하나, 향후 Redis Cluster 도입 시 `node` 태그 추가 검토. -- `readmates.aigen.queue.depth` 실제 Kafka consumer lag wiring — 현재 gauge는 placeholder 0입니다. +- Kafka consumer group lag을 별도 Prometheus metric으로 노출할지 검토 — 현재 `readmates.aigen.queue.depth`는 Redis active job backlog 의미로 고정합니다. diff --git a/docs/operations/runbooks/ai-session-generation.md b/docs/operations/runbooks/ai-session-generation.md index c9a12060..746bd48d 100644 --- a/docs/operations/runbooks/ai-session-generation.md +++ b/docs/operations/runbooks/ai-session-generation.md @@ -289,9 +289,9 @@ GeminiApiClient: retention policy depends on Google AI Studio project tier — c ### AiGenQueueLagHigh (warn) -- **조건**: `readmates_aigen_queue_depth > 50` 5m 지속. -- **현재 상태**: 이 gauge는 task 6.1 시점에 placeholder (항상 0)이며, Kafka consumer lag wiring이 들어올 때까지 alert가 실제 발화하지 않습니다. Pre-rollout 단계에서는 noisy alert로 간주하지 마십시오. -- **즉시 triage (wiring 후)**: Kafka consumer group lag 확인 (`kafka-consumer-groups.sh --describe`), worker pool 메모리/CPU 점검, provider latency 동시 burst 여부 확인. +- **조건**: Redis active AI job backlog `readmates_aigen_queue_depth > 50` 5m 지속. 이 gauge는 `PENDING` + `RUNNING` job 수를 `AiGenerationJobStore.loadActiveJobs()`에서 읽는다. +- **즉시 triage**: `/admin/ai-ops`에서 `PENDING`/`RUNNING` job을 확인하고, worker 로그, Redis 연결 상태, provider latency burst를 순서대로 본다. +- **Kafka 확인**: Kafka consumer group lag은 같은 증상의 원인일 수 있지만 이 metric 자체의 의미는 아니다. Kafka lag이 필요하면 별도 consumer lag metric 또는 broker 도구로 확인한다. - **에스컬레이션**: 실제 backlog면 worker 인스턴스 추가, provider 장애 동반이면 §7. Backbone 장애 의심이면 §8. - **연관 항목**: [§7 provider fallback](#7-provider-장애-임시-fallback), [§8 kill switch](#8-전체-disable-kill-switch). diff --git a/docs/operations/runbooks/deploy-attempts.md b/docs/operations/runbooks/deploy-attempts.md index 23046364..1e4c9704 100644 --- a/docs/operations/runbooks/deploy-attempts.md +++ b/docs/operations/runbooks/deploy-attempts.md @@ -94,7 +94,7 @@ jq -r 'select(.ts != null)' /var/log/readmates/deploy-attempts.jsonl v1.11.0 까지 post-deploy watch stage가 `READMATES_DEPLOY_ATTEMPT_ID`를 부모 프로세스로부터 상속받지 못해 `WATCH_STARTED`, `STAGE_STARTED` 등 일부 ledger 라인이 `"attemptId":"unknown"`으로 기록되었습니다. -v1.11.1 (fix commit: ``) 이후로는: +fix commit `b6e16f0d` 이후로는: - `05-deploy-compose-stack.sh`가 watch invocation에 `READMATES_DEPLOY_ATTEMPT_ID="$ATTEMPT_ID"`를 전달합니다. - `watch-compose-post-deploy.sh`가 `${READMATES_DEPLOY_ATTEMPT_ID:-${ATTEMPT_ID:-unknown}}` 순서로 id를 결정합니다 (부모 우선, 로컬 fallback, 최후의 `unknown`). diff --git a/docs/showcase/engineering-confidence.md b/docs/showcase/engineering-confidence.md index a2460dd5..1162e7e6 100644 --- a/docs/showcase/engineering-confidence.md +++ b/docs/showcase/engineering-confidence.md @@ -9,6 +9,7 @@ | Frontend route-first architecture | `front/tests/unit/frontend-boundaries.test.ts` | shared가 app/page/feature를 거꾸로 import하거나 feature UI가 route/API를 직접 잡는 회귀 | | Server clean architecture | `ServerArchitectureBoundaryTest` | web adapter가 persistence/JDBC를 직접 잡거나 application package가 Spring Web/adapter에 의존하는 회귀 | | CQRS read/write convention | `@ReadOnlyApplicationService` + ArchUnit rules | read-only service가 mutation port나 write transaction을 갖는 회귀 | +| Host/member reading loop | `front/shared/model/reading-loop.test.ts`, member/host/current-session route tests, `dev-login-session-flow.spec.ts` | host 운영 상태와 member 읽기 상태가 다른 의미로 갈라지거나 admin-only 신호가 새는 회귀 | | Flyway migration compatibility | `MySqlFlywayMigrationTest` | MySQL-specific migration, collation, FK compatibility 회귀 | | Query budget | `ServerQueryBudgetTest` | 주요 화면의 accidental N+1 query 회귀 | | Public release safety | `scripts/build-public-release-candidate.sh`, `scripts/public-release-check.sh` | public candidate에 private state, local path, secret-shaped data가 포함되는 회귀 | diff --git a/docs/showcase/guest-mode-walkthrough.md b/docs/showcase/guest-mode-walkthrough.md index 9db50268..a7ae1778 100644 --- a/docs/showcase/guest-mode-walkthrough.md +++ b/docs/showcase/guest-mode-walkthrough.md @@ -31,10 +31,17 @@ Guest는 클럽이 `ACTIVE`이고 `PUBLIC`인 경우 아래 표면을 볼 수 | --- | --- | --- | | 멤버 현재 세션 참여, RSVP, 질문, 서평 작성 | 정식 멤버 권한과 club membership이 필요합니다. | `docs/development/architecture.md`, frontend route guard tests | | 호스트 세션 생성/수정, 출석 확정, 기록 발행 | 클럽 host 권한이 필요합니다. | host route tests, session server tests, case studies | +| Host operation -> member reading loop | 호스트 운영 상태와 멤버 준비 상태는 club membership과 role에 묶인 private workflow입니다. | `front/shared/model/reading-loop.ts`, `front/tests/e2e/dev-login-session-flow.spec.ts`, member/host route tests | | Platform admin onboarding/domain/support access | platform admin 권한이 필요합니다. | platform admin plan/spec, server authorization tests | | In-app AI 세션 생성 | host 권한, feature flag, provider key, cost/PII guard가 필요합니다. | `docs/case-studies/04-pii-safe-ai-session-generation.md`, AI runbook, `scripts/aigen-pii-check.sh` | | 수동 알림 발송 | host 권한과 notification outbox pipeline이 필요합니다. | `docs/case-studies/02-notification-pipeline-with-outbox.md`, notification tests | +## Host -> Member Reading Loop Evidence + +호스트는 세션 생성, 공개 범위, 누락 멤버, RSVP/읽기/질문 준비 상태를 운영 관점에서 닫고, 멤버는 같은 세션을 RSVP, 읽은 분량, 질문, 회고, 아카이브로 이어 읽습니다. + +이 흐름은 guest 권한으로 직접 열지 않습니다. 대신 role-safe 파생 모델(`front/shared/model/reading-loop.ts`), host/member/current-session 단위 테스트, 그리고 dev-login E2E 흐름으로 확인합니다. 문서에는 실제 멤버 데이터나 private route 접근 권한을 추가하지 않습니다. + ## Public-Safety Notes - 이 walkthrough는 guest 권한을 넓히지 않습니다. diff --git a/docs/showcase/operational-proof.md b/docs/showcase/operational-proof.md index e706a633..c3a5486f 100644 --- a/docs/showcase/operational-proof.md +++ b/docs/showcase/operational-proof.md @@ -27,6 +27,21 @@ Change | Post-deploy watch | `docs/operations/runbooks/post-deploy-watch.md` | | Incident learning | `docs/operations/postmortems/README.md` | +## Product Loop Evidence + +Host/member reading-loop changes should close both product and evidence work: + +```text +Host operating action + -> role-safe reading-loop state + -> member reading action + -> focused unit/route/E2E checks + -> showcase and changelog update + -> public release candidate scan when public-facing docs change +``` + +The loop is private by permission. Public docs describe it through sanitized tests and source references rather than opening member or host routes to guests. + ## Operating Principle Passing tests is evidence, not proof that release risk is closed. Release readiness review also checks changelog coverage, operator-facing behavior changes, CI/deploy script risks, security-code hygiene, architecture-test baselines, and public-release safety. diff --git a/docs/superpowers/plans/2026-05-27-readmates-admin-today-operations-ledger.md b/docs/superpowers/plans/2026-05-27-readmates-admin-today-operations-ledger.md new file mode 100644 index 00000000..ec2ae4f1 --- /dev/null +++ b/docs/superpowers/plans/2026-05-27-readmates-admin-today-operations-ledger.md @@ -0,0 +1,1868 @@ +# ReadMates Admin Today Operations Ledger Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Rebuild `/admin/today` as a platform operations ledger that prioritizes today's club, domain, notification, and AI operations work. + +**Architecture:** Keep the existing route-first frontend boundary. `admin-today-route.tsx` owns TanStack Query composition and URL state, `platform-admin-workbench-model.ts` owns pure queue/brief calculation, and `ui` modules render from props and callbacks only. No new server endpoint is required for the first pass; existing admin summary, clubs, notification snapshot, and AI Ops queries are combined. + +**Tech Stack:** React, Vite, React Router 7, TanStack Query v5, Vitest, Testing Library, CSS in `front/src/styles/globals.css`. + +--- + +## Source Documents + +- Approved design: `docs/superpowers/specs/2026-05-27-readmates-admin-today-operations-ledger-design.md` +- Frontend guide: `docs/agents/front.md` +- Design guide: `docs/agents/design.md` +- Docs guide: `docs/agents/docs.md` +- Architecture source of truth: `docs/development/architecture.md` + +## Scope Check + +This plan is frontend-only. It does not add server API routes, database migrations, or BFF behavior. It changes the `/admin/today` composition and presentation using existing platform-admin data contracts. + +## File Structure + +- Modify: `front/features/platform-admin/model/platform-admin-workbench-model.ts` + - Add typed queue item kinds for club, notification, AI, and partial data failures. + - Add selected item brief calculation by queue item id. + - Add notification snapshot and AI query state inputs. +- Modify: `front/features/platform-admin/model/platform-admin-workbench-model.test.ts` + - Cover queue ordering, notification risk mapping, AI disabled/error mapping, selected item fallback, and SUPPORT affordances. +- Create: `front/features/platform-admin/ui/admin-today-ledger.tsx` + - Shell component for the two-column desktop and single-column mobile ledger. +- Create: `front/features/platform-admin/ui/admin-work-queue.tsx` + - Queue list component that renders all item kinds and emits selected item ids. +- Create: `front/features/platform-admin/ui/admin-selected-brief.tsx` + - Selected item brief component with primary action, drill links, checklist, and permission explanation. +- Create: `front/features/platform-admin/ui/admin-today-ledger.test.tsx` + - UI tests for empty, partial-failure, support role, and queue selection states. +- Modify: `front/features/platform-admin/route/admin-today-route.tsx` + - Compose summary/clubs/notification/AI queries without non-null assertions. + - Persist selected queue item in `?selected=`. + - Pass query partial-failure state to the model. +- Modify: `front/features/platform-admin/route/admin-today-route.test.tsx` + - Cover route query composition with seeded data and notification risk data. +- Modify: `front/src/styles/globals.css` + - Add today-ledger layout, queue, brief, risk badge, and mobile responsive styles. +- Modify: `CHANGELOG.md` + - Add an `Unreleased` line for the admin today ledger productization. + +## Task 1: Workbench Model Queue Contract + +**Files:** +- Modify: `front/features/platform-admin/model/platform-admin-workbench-model.test.ts` +- Modify: `front/features/platform-admin/model/platform-admin-workbench-model.ts` + +- [ ] **Step 1: Add failing model tests for mixed queue items** + +Append these tests to `front/features/platform-admin/model/platform-admin-workbench-model.test.ts`. + +```ts +const notificationSnapshot = { + generatedAt: "2026-05-27T00:00:00Z", + outboxSummary: { pending: 4, active: 1, failed: 2, dead: 1, sentOrPublishedLast24h: 8 }, + deliverySummary: { pending: 3, active: 0, failed: 1, dead: 1, sentOrPublishedLast24h: 10 }, + relaySummary: { publishing: 0, sending: 0, stalePublishing: 1, staleSending: 1 }, + failureClusters: [ + { safeErrorCode: "mailbox_unavailable", status: "DEAD", count: 2, latestAt: "2026-05-27T00:00:00Z" }, + ], + clubHealth: [ + { + clubId: "club-ready", + slug: "ready-club", + name: "Ready Club", + pending: 0, + failed: 2, + dead: 1, + lastSuccessAt: "2026-05-26T23:00:00Z", + }, + ], + recentManualDispatches: [], +}; + +describe("buildPlatformAdminWorkbench — operations ledger queue", () => { + it("adds notification risk items without dropping club readiness items", () => { + const result = buildPlatformAdminWorkbench({ + ...baseInput, + selectedItemId: "notification-club-ready", + notificationSnapshot, + }); + + expect(result.queueItems.map((item) => item.id)).toContain("notification-club-ready"); + expect(result.queueItems.map((item) => item.id)).toContain("club-club-ready"); + expect(result.selectedBrief?.item.id).toBe("notification-club-ready"); + expect(result.selectedBrief?.primaryAction.href).toBe("/admin/notifications?clubId=club-ready"); + expect(result.selectedBrief?.drillLinks).toContainEqual({ + label: "알림 운영", + href: "/admin/notifications?clubId=club-ready", + }); + }); + + it("adds a partial failure item when notification snapshot cannot be read", () => { + const result = buildPlatformAdminWorkbench({ + ...baseInput, + notificationUnavailable: true, + }); + + const item = result.queueItems.find((candidate) => candidate.id === "partial-notifications"); + expect(item).toMatchObject({ + type: "partial-error", + severity: "warn", + primaryActionLabel: "알림 확인 불가", + }); + expect(result.metrics.operationsWarningCount).toBeGreaterThanOrEqual(1); + }); + + it("treats AI disabled as an info item and failed AI jobs as critical work", () => { + const result = buildPlatformAdminWorkbench({ + ...baseInput, + aiDisabled: true, + aiJobs: [ + { + jobId: "job-failed", + clubId: "club-ready", + clubName: "Ready Club", + sessionTitle: "7회차", + status: "FAILED", + errorCode: "PROVIDER_RATE_LIMITED", + stale: false, + startedAt: "2026-05-27T00:00:00Z", + }, + ], + }); + + expect(result.queueItems.find((item) => item.id === "ai-disabled")).toMatchObject({ + type: "ai", + severity: "info", + primaryActionLabel: "AI 비활성", + }); + expect(result.queueItems.find((item) => item.id === "ai-job-failed")).toMatchObject({ + type: "ai", + severity: "critical", + primaryActionLabel: "AI 실패", + }); + }); + + it("shows support role mutation limits in the selected brief", () => { + const result = buildPlatformAdminWorkbench({ + ...baseInput, + role: "SUPPORT", + selectedItemId: "club-club-ready", + selectedClubId: "club-ready", + }); + + expect(result.selectedBrief?.permissionNote).toBe("현재 역할은 변경 작업을 실행할 수 없습니다."); + expect(result.selectedBrief?.primaryAction.disabled).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run focused model tests and verify they fail** + +Run: + +```bash +pnpm --dir front exec vitest run features/platform-admin/model/platform-admin-workbench-model.test.ts -t "operations ledger queue" +``` + +Expected: FAIL because `selectedItemId`, `notificationSnapshot`, `notificationUnavailable`, `operationsWarningCount`, `selectedBrief`, and partial-error queue items do not exist yet. + +- [ ] **Step 3: Replace the workbench queue types** + +In `front/features/platform-admin/model/platform-admin-workbench-model.ts`, add these imports and replace the queue/brief-related type definitions from `PlatformAdminWorkbenchInput` through `PlatformAdminWorkbenchView`. + +```ts +import type { AdminNotificationOperationsSnapshot } from "@/features/platform-admin/model/platform-admin-notifications-model"; + +export type WorkQueueSeverity = + | "blocked" + | "critical" + | "attention" + | "warn" + | "ready" + | "stable" + | "info"; + +export type PlatformAdminWorkbenchInput = { + role: PlatformAdminRole; + activeClubCount: number; + domainActionRequiredCount: number; + selectedClubId: string | null; + selectedItemId?: string | null; + clubs: PlatformAdminWorkbenchClub[]; + domains: PlatformAdminWorkbenchDomain[]; + notificationSnapshot?: AdminNotificationOperationsSnapshot | null; + notificationUnavailable?: boolean; + aiJobs?: ReadonlyArray; + aiDisabled?: boolean; + aiUnavailable?: boolean; +}; + +export type PlatformAdminPermissionView = { + canCreateClub: boolean; + canUpdateClub: boolean; + canManageDomains: boolean; + canCreateSupportGrant: boolean; + canRevokeSupportGrant: boolean; + canForceCancelAiJob: boolean; +}; + +export type PublishChecklistItem = { + id: "public-info" | "first-host" | "lifecycle" | "domains"; + label: string; + passed: boolean; + detail: string; +}; + +export type SelectedAdminAction = { + kind: + | "make-public" + | "make-private" + | "check-domain" + | "open-notifications" + | "open-ai-ops" + | "open-detail" + | "none"; + label: string; + href: string; + disabled: boolean; + reason: string | null; +}; + +export type WorkbenchQueueItemType = "club" | "notification" | "ai" | "partial-error"; + +export type WorkbenchQueueItem = { + id: string; + type: WorkbenchQueueItemType; + clubId: string | null; + slug: string; + name: string; + severity: WorkQueueSeverity; + reason: string; + primaryActionLabel: string; + badges: string[]; + sortRank: number; + href: string; +}; + +export type PlatformAdminSelectedBrief = { + item: WorkbenchQueueItem; + club: PlatformAdminWorkbenchClub | null; + domains: PlatformAdminWorkbenchDomain[]; + publishChecklist: PublishChecklistItem[]; + primaryAction: SelectedAdminAction; + drillLinks: Array<{ label: string; href: string }>; + permissionNote: string | null; +}; + +export type PlatformAdminWorkbenchView = { + permissions: PlatformAdminPermissionView; + metrics: { + platformRole: PlatformAdminRole; + activeClubCount: number; + needsActionCount: number; + domainActionRequiredCount: number; + publishReadyCount: number; + operationsWarningCount: number; + }; + queueItems: WorkbenchQueueItem[]; + selectedBrief: PlatformAdminSelectedBrief | null; +}; +``` + +- [ ] **Step 4: Implement the model builder and helper functions** + +Replace `buildPlatformAdminWorkbench`, `buildAiQueueItem`, `permissionsForRole`, `buildQueueItem`, and `selectClubId` with this implementation. Keep the existing `buildPublishChecklist`, `buildPrimaryAction`, `groupDomainsByClub`, and `hostStateDetail` functions, then adapt `buildPrimaryAction` in the next step. + +```ts +export function buildPlatformAdminWorkbench(input: PlatformAdminWorkbenchInput): PlatformAdminWorkbenchView { + const permissions = permissionsForRole(input.role); + const domainsByClub = groupDomainsByClub(input.domains); + const clubItems = input.clubs + .map((club) => buildClubQueueItem(club, domainsByClub.get(club.clubId) ?? [])) + .sort(compareQueueItems); + const notificationItems = buildNotificationQueueItems(input.notificationSnapshot, input.notificationUnavailable ?? false); + const aiItems = buildAiQueueItems(input.aiJobs ?? [], { + disabled: input.aiDisabled ?? false, + unavailable: input.aiUnavailable ?? false, + }); + const queueItems = [...clubItems, ...notificationItems, ...aiItems].sort(compareQueueItems); + const selectedItem = selectQueueItem(input.selectedItemId, input.selectedClubId, queueItems); + const selectedBrief = selectedItem + ? buildSelectedBrief(selectedItem, input.clubs, domainsByClub, permissions) + : null; + + return { + permissions, + metrics: { + platformRole: input.role, + activeClubCount: input.activeClubCount, + needsActionCount: queueItems.filter((item) => + item.severity === "blocked" || item.severity === "critical" || item.severity === "attention" + ).length, + domainActionRequiredCount: input.domainActionRequiredCount, + publishReadyCount: queueItems.filter((item) => item.primaryActionLabel === "공개 전환").length, + operationsWarningCount: queueItems.filter((item) => item.severity === "critical" || item.severity === "warn").length, + }, + queueItems, + selectedBrief, + }; +} + +function compareQueueItems(a: WorkbenchQueueItem, b: WorkbenchQueueItem): number { + return a.sortRank - b.sortRank || a.name.localeCompare(b.name, "ko-KR") || a.id.localeCompare(b.id); +} + +function buildClubQueueItem( + club: PlatformAdminWorkbenchClub, + domains: PlatformAdminWorkbenchDomain[], +): WorkbenchQueueItem { + const checklist = buildPublishChecklist(club, domains); + const failedDomain = domains.find((domain) => domain.status === "FAILED"); + const actionRequiredDomain = domains.find((domain) => domain.status === "ACTION_REQUIRED"); + const badges = [club.status, club.publicVisibility, `host ${club.firstHostOnboardingState}`]; + + if (failedDomain) { + return { + id: `club-${club.clubId}`, + type: "club", + clubId: club.clubId, + slug: club.slug, + name: club.name, + severity: "attention", + reason: `${failedDomain.hostname} 도메인 확인이 실패했습니다.`, + primaryActionLabel: "도메인 확인", + badges: [...badges, "domain FAILED"], + sortRank: 20, + href: `/admin/clubs/${club.clubId}`, + }; + } + + if (actionRequiredDomain) { + return { + id: `club-${club.clubId}`, + type: "club", + clubId: club.clubId, + slug: club.slug, + name: club.name, + severity: "attention", + reason: `${actionRequiredDomain.hostname} 연결 작업이 필요합니다.`, + primaryActionLabel: "도메인 확인", + badges: [...badges, "domain ACTION_REQUIRED"], + sortRank: 30, + href: `/admin/clubs/${club.clubId}`, + }; + } + + if (!checklist.every((item) => item.passed)) { + return { + id: `club-${club.clubId}`, + type: "club", + clubId: club.clubId, + slug: club.slug, + name: club.name, + severity: "blocked", + reason: checklist.find((item) => !item.passed)?.detail ?? "공개 준비 조건을 확인해야 합니다.", + primaryActionLabel: "체크리스트", + badges, + sortRank: 10, + href: `/admin/clubs/${club.clubId}`, + }; + } + + if (club.publicVisibility === "PRIVATE") { + return { + id: `club-${club.clubId}`, + type: "club", + clubId: club.clubId, + slug: club.slug, + name: club.name, + severity: "ready", + reason: "공개 전환 조건을 충족했습니다.", + primaryActionLabel: "공개 전환", + badges, + sortRank: 40, + href: `/admin/clubs/${club.clubId}`, + }; + } + + return { + id: `club-${club.clubId}`, + type: "club", + clubId: club.clubId, + slug: club.slug, + name: club.name, + severity: "stable", + reason: "현재 공개 상태입니다.", + primaryActionLabel: "검토", + badges, + sortRank: 70, + href: `/admin/clubs/${club.clubId}`, + }; +} + +function buildNotificationQueueItems( + snapshot: AdminNotificationOperationsSnapshot | null | undefined, + unavailable: boolean, +): WorkbenchQueueItem[] { + if (unavailable) { + return [{ + id: "partial-notifications", + type: "partial-error", + clubId: null, + slug: "platform", + name: "알림 운영", + severity: "warn", + reason: "알림 운영 snapshot을 확인하지 못했습니다.", + primaryActionLabel: "알림 확인 불가", + badges: ["notifications unavailable"], + sortRank: 35, + href: "/admin/notifications", + }]; + } + + if (!snapshot) return []; + + const clubItems = snapshot.clubHealth + .filter((club) => club.failed > 0 || club.dead > 0) + .map((club): WorkbenchQueueItem => ({ + id: `notification-${club.clubId}`, + type: "notification", + clubId: club.clubId, + slug: club.slug, + name: club.name, + severity: club.dead > 0 ? "critical" : "warn", + reason: `알림 실패 ${club.failed}건 · dead ${club.dead}건`, + primaryActionLabel: "알림 진단", + badges: ["notifications", club.dead > 0 ? "DEAD" : "FAILED"], + sortRank: club.dead > 0 ? 15 : 32, + href: `/admin/notifications?clubId=${encodeURIComponent(club.clubId)}`, + })); + + const platformBacklog = + snapshot.outboxSummary.dead + + snapshot.outboxSummary.failed + + snapshot.deliverySummary.dead + + snapshot.deliverySummary.failed + + snapshot.relaySummary.stalePublishing + + snapshot.relaySummary.staleSending; + + if (platformBacklog === 0) return clubItems; + + return [ + ...clubItems, + { + id: "notification-platform", + type: "notification", + clubId: null, + slug: "platform", + name: "알림 outbox", + severity: snapshot.outboxSummary.dead + snapshot.deliverySummary.dead > 0 ? "critical" : "warn", + reason: `실패/정체 신호 ${platformBacklog}건`, + primaryActionLabel: "알림 운영", + badges: ["outbox", "delivery"], + sortRank: 18, + href: "/admin/notifications?focus=outbox_backlog", + }, + ]; +} + +function buildAiQueueItems( + jobs: ReadonlyArray, + state: { disabled: boolean; unavailable: boolean }, +): WorkbenchQueueItem[] { + const items: WorkbenchQueueItem[] = []; + + if (state.disabled) { + items.push({ + id: "ai-disabled", + type: "ai", + clubId: null, + slug: "platform", + name: "AI Ops", + severity: "info", + reason: "AI generation이 비활성 상태입니다.", + primaryActionLabel: "AI 비활성", + badges: ["AI_DISABLED"], + sortRank: 90, + href: "/admin/ai-ops", + }); + } + + if (state.unavailable) { + items.push({ + id: "partial-ai", + type: "partial-error", + clubId: null, + slug: "platform", + name: "AI Ops", + severity: "warn", + reason: "AI Ops 작업 목록을 확인하지 못했습니다.", + primaryActionLabel: "AI 확인 불가", + badges: ["ai unavailable"], + sortRank: 36, + href: "/admin/ai-ops", + }); + } + + for (const job of jobs) { + const failed = job.status === "FAILED"; + const stale = job.stale; + if (!failed && !stale) continue; + items.push({ + id: `ai-${job.jobId}`, + type: "ai", + clubId: job.clubId, + slug: job.clubId, + name: job.clubName, + severity: failed ? "critical" : "warn", + reason: `${job.clubName} · ${job.sessionTitle}`, + primaryActionLabel: failed ? "AI 실패" : "AI stale", + badges: [failed ? "FAILED" : "STALE", job.errorCode ?? "no_error_code"], + sortRank: failed ? 16 : 34, + href: `/admin/ai-ops?clubId=${encodeURIComponent(job.clubId)}`, + }); + } + + return items; +} + +function permissionsForRole(role: PlatformAdminRole): PlatformAdminPermissionView { + const canOperate = role === "OWNER" || role === "OPERATOR"; + return { + canCreateClub: canOperate, + canUpdateClub: canOperate, + canManageDomains: canOperate, + canCreateSupportGrant: role === "OWNER", + canRevokeSupportGrant: role === "OWNER", + canForceCancelAiJob: canOperate, + }; +} +``` + +- [ ] **Step 5: Add selected brief helpers** + +In the same model file, replace the old `buildPrimaryAction` return type usage and add these helpers before `groupDomainsByClub`. + +```ts +function buildSelectedBrief( + item: WorkbenchQueueItem, + clubs: PlatformAdminWorkbenchClub[], + domainsByClub: Map, + permissions: PlatformAdminPermissionView, +): PlatformAdminSelectedBrief { + const club = item.clubId ? clubs.find((candidate) => candidate.clubId === item.clubId) ?? null : null; + const domains = club ? domainsByClub.get(club.clubId) ?? [] : []; + const publishChecklist = club ? buildPublishChecklist(club, domains) : []; + const primaryAction = buildSelectedAction(item, club, domains, permissions); + return { + item, + club, + domains, + publishChecklist, + primaryAction, + drillLinks: buildDrillLinks(item, club), + permissionNote: primaryAction.disabled && primaryAction.reason === "현재 역할은 변경 작업을 실행할 수 없습니다." + ? primaryAction.reason + : null, + }; +} + +function buildSelectedAction( + item: WorkbenchQueueItem, + club: PlatformAdminWorkbenchClub | null, + domains: PlatformAdminWorkbenchDomain[], + permissions: PlatformAdminPermissionView, +): SelectedAdminAction { + if (item.type === "club" && club) { + const action = buildClubVisibilityAction(club, domains); + if (!permissions.canUpdateClub && action.kind !== "none") { + return { ...action, disabled: true, reason: "현재 역할은 변경 작업을 실행할 수 없습니다." }; + } + return action; + } + + if (item.type === "notification") { + return { + kind: "open-notifications", + label: "알림 운영 열기", + href: item.href, + disabled: false, + reason: null, + }; + } + + if (item.type === "ai") { + return { + kind: "open-ai-ops", + label: "AI Ops 열기", + href: item.href, + disabled: false, + reason: null, + }; + } + + return { + kind: "open-detail", + label: "상세 화면 열기", + href: item.href, + disabled: false, + reason: null, + }; +} + +function buildClubVisibilityAction( + club: PlatformAdminWorkbenchClub, + domains: PlatformAdminWorkbenchDomain[], +): SelectedAdminAction { + if (club.status === "SUSPENDED" || club.status === "ARCHIVED") { + return { + kind: "none", + label: "전환 불가", + href: `/admin/clubs/${club.clubId}`, + disabled: true, + reason: club.status === "ARCHIVED" + ? "보관된 클럽은 공개/비공개 전환 대상이 아닙니다." + : "정지된 클럽은 공개/비공개 전환 대상이 아닙니다.", + }; + } + + const checklist = buildPublishChecklist(club, domains); + const failed = checklist.find((candidate) => !candidate.passed); + + if (club.publicVisibility === "PUBLIC") { + return { + kind: "make-private", + label: "비공개 전환", + href: `/admin/clubs/${club.clubId}`, + disabled: false, + reason: null, + }; + } + + if (failed) { + return { + kind: "make-public", + label: "공개 전환", + href: `/admin/clubs/${club.clubId}`, + disabled: true, + reason: failed.detail, + }; + } + + return { + kind: "make-public", + label: "공개 전환", + href: `/admin/clubs/${club.clubId}`, + disabled: false, + reason: null, + }; +} + +function buildDrillLinks( + item: WorkbenchQueueItem, + club: PlatformAdminWorkbenchClub | null, +): Array<{ label: string; href: string }> { + const links: Array<{ label: string; href: string }> = []; + if (club) { + links.push({ label: "클럽 상세", href: `/admin/clubs/${club.clubId}` }); + } + if (item.type === "notification") { + links.push({ label: "알림 운영", href: item.href }); + } + if (item.type === "ai") { + links.push({ label: "AI Ops", href: item.href }); + } + links.push({ label: "감사 로그", href: club ? `/admin/audit?clubId=${club.clubId}` : "/admin/audit" }); + return links; +} + +function selectQueueItem( + requestedItemId: string | null | undefined, + requestedClubId: string | null, + queueItems: WorkbenchQueueItem[], +): WorkbenchQueueItem | null { + if (requestedItemId) { + const byId = queueItems.find((item) => item.id === requestedItemId); + if (byId) return byId; + } + if (requestedClubId) { + const byClub = queueItems.find((item) => item.type === "club" && item.clubId === requestedClubId); + if (byClub) return byClub; + } + return queueItems[0] ?? null; +} +``` + +Remove the old `SelectedClubAction`, `PlatformAdminWorkQueueItem`, `WorkbenchClubQueueItem`, `WorkbenchAiQueueItem`, `PlatformAdminSelectedClubBrief`, `buildAiQueueItem`, `buildQueueItem`, `buildPrimaryAction`, and `selectClubId` definitions after the new functions compile. + +- [ ] **Step 6: Run the focused model tests and verify they pass** + +Before running, update the existing assertions in `front/features/platform-admin/model/platform-admin-workbench-model.test.ts` so they use the new queue ids and `selectedBrief` shape: + +```ts +expect(workbench.queueItems.map((item) => item.id)).toEqual([ + "club-club-host-missing", + "club-club-public", + "club-club-ready", +]); +expect(workbench.selectedBrief?.club?.clubId).toBe("club-host-missing"); + +expect(workbench.selectedBrief?.publishChecklist.every((item) => item.passed)).toBe(true); +expect(workbench.selectedBrief?.primaryAction).toEqual({ + kind: "make-public", + label: "공개 전환", + href: "/admin/clubs/club-ready", + disabled: false, + reason: null, +}); + +expect(workbench.selectedBrief?.publishChecklist).toContainEqual({ + id: "first-host", + label: "첫 호스트 지정", + passed: false, + detail: "첫 호스트가 아직 없습니다.", +}); +expect(workbench.selectedBrief?.primaryAction.disabled).toBe(true); + +expect(archived.selectedBrief?.primaryAction.kind).toBe("none"); +expect(archived.selectedBrief?.primaryAction.disabled).toBe(true); + +expect(workbench.selectedBrief?.club?.clubId).toBe("club-host-missing"); +``` + +In the AI disabled test, assert the disabled platform item and the failed job separately: + +```ts +const disabledItem = result.queueItems.find((item) => item.id === "ai-disabled"); +const failedItem = result.queueItems.find((item) => item.id === "ai-job-3"); +expect(disabledItem?.severity).toBe("info"); +expect(failedItem?.severity).toBe("critical"); +``` + +Run: + +```bash +pnpm --dir front exec vitest run features/platform-admin/model/platform-admin-workbench-model.test.ts +``` + +Expected: PASS for all workbench model tests. + +- [ ] **Step 7: Commit the model contract** + +Run: + +```bash +git add front/features/platform-admin/model/platform-admin-workbench-model.ts front/features/platform-admin/model/platform-admin-workbench-model.test.ts +git commit -m "feat(admin): model today operations ledger queue" +``` + +## Task 2: Today Ledger UI Components + +**Files:** +- Create: `front/features/platform-admin/ui/admin-today-ledger.tsx` +- Create: `front/features/platform-admin/ui/admin-work-queue.tsx` +- Create: `front/features/platform-admin/ui/admin-selected-brief.tsx` +- Create: `front/features/platform-admin/ui/admin-today-ledger.test.tsx` + +- [ ] **Step 1: Add failing UI tests** + +Create `front/features/platform-admin/ui/admin-today-ledger.test.tsx`. + +```tsx +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MemoryRouter } from "react-router-dom"; +import { describe, expect, it, vi } from "vitest"; +import { AdminTodayLedger } from "@/features/platform-admin/ui/admin-today-ledger"; +import type { PlatformAdminWorkbenchView } from "@/features/platform-admin/model/platform-admin-workbench-model"; + +const baseWorkbench: PlatformAdminWorkbenchView = { + permissions: { + canCreateClub: true, + canUpdateClub: true, + canManageDomains: true, + canCreateSupportGrant: true, + canRevokeSupportGrant: true, + canForceCancelAiJob: true, + }, + metrics: { + platformRole: "OWNER", + activeClubCount: 2, + needsActionCount: 2, + domainActionRequiredCount: 1, + publishReadyCount: 1, + operationsWarningCount: 1, + }, + queueItems: [ + { + id: "club-club-ready", + type: "club", + clubId: "club-ready", + slug: "ready-club", + name: "Ready Club", + severity: "ready", + reason: "공개 전환 조건을 충족했습니다.", + primaryActionLabel: "공개 전환", + badges: ["ACTIVE", "PRIVATE"], + sortRank: 40, + href: "/admin/clubs/club-ready", + }, + { + id: "notification-platform", + type: "notification", + clubId: null, + slug: "platform", + name: "알림 outbox", + severity: "critical", + reason: "실패/정체 신호 3건", + primaryActionLabel: "알림 운영", + badges: ["outbox", "delivery"], + sortRank: 18, + href: "/admin/notifications?focus=outbox_backlog", + }, + ], + selectedBrief: { + item: { + id: "notification-platform", + type: "notification", + clubId: null, + slug: "platform", + name: "알림 outbox", + severity: "critical", + reason: "실패/정체 신호 3건", + primaryActionLabel: "알림 운영", + badges: ["outbox", "delivery"], + sortRank: 18, + href: "/admin/notifications?focus=outbox_backlog", + }, + club: null, + domains: [], + publishChecklist: [], + primaryAction: { + kind: "open-notifications", + label: "알림 운영 열기", + href: "/admin/notifications?focus=outbox_backlog", + disabled: false, + reason: null, + }, + drillLinks: [ + { label: "알림 운영", href: "/admin/notifications?focus=outbox_backlog" }, + { label: "감사 로그", href: "/admin/audit" }, + ], + permissionNote: null, + }, +}; + +function renderLedger(workbench: PlatformAdminWorkbenchView = baseWorkbench, onSelectItem = vi.fn()) { + return { + onSelectItem, + ...render( + + + , + ), + }; +} + +describe("AdminTodayLedger", () => { + it("renders the operations ledger summary, queue, and selected brief", () => { + renderLedger(); + + expect(screen.getByRole("heading", { name: "오늘 할 일" })).toBeInTheDocument(); + expect(screen.getByText("조치 필요 2")).toBeInTheDocument(); + expect(screen.getByRole("region", { name: "운영 작업 큐" })).toBeInTheDocument(); + expect(screen.getByRole("region", { name: "선택 항목 브리프" })).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "알림 운영 열기" })).toHaveAttribute("href", "/admin/notifications?focus=outbox_backlog"); + }); + + it("emits item ids when a queue row is selected", async () => { + const user = userEvent.setup(); + const { onSelectItem } = renderLedger(); + + await user.click(screen.getByRole("button", { name: /Ready Club/ })); + + expect(onSelectItem).toHaveBeenCalledWith("club-club-ready"); + }); + + it("shows permission notes and disabled primary actions", () => { + renderLedger({ + ...baseWorkbench, + selectedBrief: { + ...baseWorkbench.selectedBrief!, + primaryAction: { + kind: "make-public", + label: "공개 전환", + href: "/admin/clubs/club-ready", + disabled: true, + reason: "현재 역할은 변경 작업을 실행할 수 없습니다.", + }, + permissionNote: "현재 역할은 변경 작업을 실행할 수 없습니다.", + }, + }); + + expect(screen.getByText("현재 역할은 변경 작업을 실행할 수 없습니다.")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "공개 전환" })).toBeDisabled(); + }); + + it("renders an honest empty state", () => { + renderLedger({ ...baseWorkbench, queueItems: [], selectedBrief: null }); + + const queue = screen.getByRole("region", { name: "운영 작업 큐" }); + expect(within(queue).getByText("오늘 처리할 플랫폼 작업이 없습니다.")).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 2: Run UI tests and verify they fail** + +Run: + +```bash +pnpm --dir front exec vitest run features/platform-admin/ui/admin-today-ledger.test.tsx +``` + +Expected: FAIL because `AdminTodayLedger`, `AdminWorkQueue`, and `AdminSelectedBrief` do not exist. + +- [ ] **Step 3: Create the queue component** + +Create `front/features/platform-admin/ui/admin-work-queue.tsx`. + +```tsx +import type { WorkbenchQueueItem } from "@/features/platform-admin/model/platform-admin-workbench-model"; + +type Props = { + items: WorkbenchQueueItem[]; + selectedItemId: string | null; + onSelectItem: (itemId: string) => void; +}; + +const severityLabel: Record = { + blocked: "막힘", + critical: "긴급", + attention: "확인", + warn: "경고", + ready: "준비", + stable: "안정", + info: "정보", +}; + +export function AdminWorkQueue({ items, selectedItemId, onSelectItem }: Props) { + return ( +
+
+

Operations ledger

+

운영 작업 큐

+
+ {items.length === 0 ? ( +

오늘 처리할 플랫폼 작업이 없습니다.

+ ) : ( +
+ {items.map((item) => ( + + ))} +
+ )} +
+ ); +} +``` + +- [ ] **Step 4: Create the selected brief component** + +Create `front/features/platform-admin/ui/admin-selected-brief.tsx`. + +```tsx +import { Link } from "react-router-dom"; +import type { PlatformAdminSelectedBrief } from "@/features/platform-admin/model/platform-admin-workbench-model"; + +type Props = { + brief: PlatformAdminSelectedBrief | null; +}; + +export function AdminSelectedBrief({ brief }: Props) { + if (!brief) { + return ( +
+

선택할 작업이 없습니다.

+
+ ); + } + + const primary = brief.primaryAction; + return ( +
+
+

Selected brief

+

{brief.item.name}

+

+ {brief.item.slug} · {brief.item.primaryActionLabel} · {brief.item.reason} +

+
+ + {brief.permissionNote ? ( +

{brief.permissionNote}

+ ) : null} + + {brief.publishChecklist.length > 0 ? ( +
+ {brief.publishChecklist.map((item) => ( +
+ {item.label} + {item.detail} +
+ ))} +
+ ) : null} + + {primary.kind === "make-public" || primary.kind === "make-private" ? ( + + ) : ( + + {primary.label} + + )} + + {primary.reason && !brief.permissionNote ? ( +

{primary.reason}

+ ) : null} + +
+ {brief.drillLinks.map((link) => ( + + {link.label} + + ))} +
+
+ ); +} +``` + +- [ ] **Step 5: Create the ledger shell component** + +Create `front/features/platform-admin/ui/admin-today-ledger.tsx`. + +```tsx +import type { PlatformAdminWorkbenchView } from "@/features/platform-admin/model/platform-admin-workbench-model"; +import { AdminSelectedBrief } from "@/features/platform-admin/ui/admin-selected-brief"; +import { AdminWorkQueue } from "@/features/platform-admin/ui/admin-work-queue"; + +type Props = { + workbench: PlatformAdminWorkbenchView; + selectedItemId: string | null; + filterLabel?: string | null; + onClearFilter?: () => void; + onSelectItem: (itemId: string) => void; +}; + +export function AdminTodayLedger({ + workbench, + selectedItemId, + filterLabel = null, + onClearFilter, + onSelectItem, +}: Props) { + return ( +
+
+
+

Platform operations

+

오늘 할 일

+

+ 공개 준비, 도메인 조치, 알림 실패, AI 작업 이상을 오늘 처리할 순서로 정리합니다. +

+
+
+ 조치 필요 {workbench.metrics.needsActionCount} + 공개 준비 {workbench.metrics.publishReadyCount} + 운영 경고 {workbench.metrics.operationsWarningCount} +
+
+ + {filterLabel ? ( +

+ 필터: {filterLabel} + {onClearFilter ? ( + + ) : null} +

+ ) : null} + +
+ + +
+
+ ); +} +``` + +- [ ] **Step 6: Run UI tests and verify they pass** + +Run: + +```bash +pnpm --dir front exec vitest run features/platform-admin/ui/admin-today-ledger.test.tsx +``` + +Expected: PASS. + +- [ ] **Step 7: Commit the UI components** + +Run: + +```bash +git add front/features/platform-admin/ui/admin-today-ledger.tsx front/features/platform-admin/ui/admin-work-queue.tsx front/features/platform-admin/ui/admin-selected-brief.tsx front/features/platform-admin/ui/admin-today-ledger.test.tsx +git commit -m "feat(admin): add today ledger UI" +``` + +## Task 3: Route Query Composition And Partial Failure Handling + +**Files:** +- Modify: `front/features/platform-admin/route/admin-today-route.test.tsx` +- Modify: `front/features/platform-admin/route/admin-today-route.tsx` + +- [ ] **Step 1: Replace the route test with seeded query coverage** + +Replace `front/features/platform-admin/route/admin-today-route.test.tsx` with this test file. + +```tsx +import { render, screen } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { MemoryRouter } from "react-router-dom"; +import { describe, expect, it } from "vitest"; +import { platformAdminNotificationSnapshotQuery } from "@/features/platform-admin/queries/platform-admin-notifications-queries"; +import { + platformAdminAiOpsJobsQuery, + platformAdminAiOpsSummaryQuery, +} from "@/features/platform-admin/queries/platform-admin-ai-ops-queries"; +import { + platformAdminClubsQuery, + platformAdminSummaryQuery, +} from "@/features/platform-admin/queries/platform-admin-queries"; +import { AdminTodayRoute } from "./admin-today-route"; + +function renderRoute(client: QueryClient, initialEntry = "/admin/today") { + return render( + + + + + , + ); +} + +function seededClient() { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + queryClient.setQueryData(platformAdminSummaryQuery().queryKey, { + platformRole: "OWNER", + activeClubCount: 1, + domainActionRequiredCount: 0, + domains: [], + domainsRequiringAction: [], + }); + queryClient.setQueryData(platformAdminClubsQuery().queryKey, { + items: [{ + clubId: "club-ready", + slug: "ready-club", + name: "Ready Club", + tagline: "함께 읽는 클럽", + about: "공개 소개가 입력되어 있습니다.", + status: "ACTIVE", + publicVisibility: "PRIVATE", + domainCount: 0, + domainActionRequiredCount: 0, + firstHostOnboardingState: "ASSIGNED", + }], + }); + queryClient.setQueryData(platformAdminNotificationSnapshotQuery().queryKey, { + generatedAt: "2026-05-27T00:00:00Z", + outboxSummary: { pending: 0, active: 0, failed: 0, dead: 0, sentOrPublishedLast24h: 1 }, + deliverySummary: { pending: 0, active: 0, failed: 0, dead: 0, sentOrPublishedLast24h: 1 }, + relaySummary: { publishing: 0, sending: 0, stalePublishing: 0, staleSending: 0 }, + failureClusters: [], + clubHealth: [], + recentManualDispatches: [], + }); + queryClient.setQueryData(platformAdminAiOpsSummaryQuery().queryKey, { + activeJobCount: 0, + failedLast24h: 0, + monthToDateCostEstimateUsd: "0.0000", + failureCodes: [], + providerCosts: [], + staleCandidateCount: 0, + }); + queryClient.setQueryData(platformAdminAiOpsJobsQuery().queryKey, { items: [], nextCursor: null }); + return queryClient; +} + +describe("AdminTodayRoute", () => { + it("renders the operations ledger from seeded admin queries", () => { + renderRoute(seededClient(), "/admin/today?selected=club-club-ready"); + + expect(screen.getByRole("heading", { name: "오늘 할 일" })).toBeInTheDocument(); + expect(screen.getByRole("region", { name: "운영 작업 큐" })).toBeInTheDocument(); + expect(screen.getByRole("region", { name: "선택 항목 브리프" })).toBeInTheDocument(); + expect(screen.getByText("Ready Club")).toBeInTheDocument(); + }); + + it("renders notification risk from the notification snapshot", () => { + const client = seededClient(); + client.setQueryData(platformAdminNotificationSnapshotQuery().queryKey, { + generatedAt: "2026-05-27T00:00:00Z", + outboxSummary: { pending: 0, active: 0, failed: 1, dead: 0, sentOrPublishedLast24h: 1 }, + deliverySummary: { pending: 0, active: 0, failed: 0, dead: 1, sentOrPublishedLast24h: 1 }, + relaySummary: { publishing: 0, sending: 0, stalePublishing: 0, staleSending: 0 }, + failureClusters: [], + clubHealth: [{ + clubId: "club-ready", + slug: "ready-club", + name: "Ready Club", + pending: 0, + failed: 1, + dead: 1, + lastSuccessAt: null, + }], + recentManualDispatches: [], + }); + + renderRoute(client); + + expect(screen.getByText("Ready Club")).toBeInTheDocument(); + expect(screen.getByText(/알림 실패 1건/)).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 2: Run route tests and verify they fail** + +Run: + +```bash +pnpm --dir front exec vitest run features/platform-admin/route/admin-today-route.test.tsx +``` + +Expected: FAIL because `AdminTodayRoute` still renders old `PlatformAdminWorkQueue` / `ClubOperationsBrief` props and does not load notification snapshot. + +- [ ] **Step 3: Replace the route implementation** + +Replace `front/features/platform-admin/route/admin-today-route.tsx` with this implementation. + +```tsx +import { useQuery } from "@tanstack/react-query"; +import { useSearchParams } from "react-router-dom"; +import { isReadmatesApiError } from "@/shared/api/errors"; +import { AdminTodayLedger } from "@/features/platform-admin/ui/admin-today-ledger"; +import { + buildPlatformAdminWorkbench, + type PlatformAdminWorkbenchInput, + type WorkbenchQueueItem, +} from "@/features/platform-admin/model/platform-admin-workbench-model"; +import { platformAdminNotificationSnapshotQuery } from "@/features/platform-admin/queries/platform-admin-notifications-queries"; +import { + platformAdminAiOpsJobsQuery, + platformAdminAiOpsSummaryQuery, +} from "@/features/platform-admin/queries/platform-admin-ai-ops-queries"; +import { + platformAdminClubsQuery, + platformAdminSummaryQuery, +} from "@/features/platform-admin/queries/platform-admin-queries"; + +export function AdminTodayRoute() { + const summaryQuery = useQuery(platformAdminSummaryQuery()); + const clubsQuery = useQuery(platformAdminClubsQuery()); + const notificationQuery = useQuery(platformAdminNotificationSnapshotQuery()); + const aiSummaryQuery = useQuery(platformAdminAiOpsSummaryQuery()); + const aiJobsQuery = useQuery(platformAdminAiOpsJobsQuery()); + const [searchParams, setSearchParams] = useSearchParams(); + const filter = searchParams.get("filter"); + const selectedItemId = searchParams.get("selected"); + + const summary = summaryQuery.data; + const clubs = clubsQuery.data; + + if (summaryQuery.isLoading || clubsQuery.isLoading) { + return

오늘 할 일을 불러오는 중입니다.

; + } + + if (summaryQuery.isError || clubsQuery.isError || !summary || !clubs) { + return ( +
+

오늘 할 일

+

+ 플랫폼 작업 큐를 불러오지 못했습니다. 잠시 뒤 다시 시도해 주세요. +

+
+ ); + } + + const input: PlatformAdminWorkbenchInput = { + role: summary.platformRole, + activeClubCount: summary.activeClubCount, + domainActionRequiredCount: summary.domainActionRequiredCount, + selectedClubId: null, + selectedItemId, + clubs: clubs.items.map((club) => ({ + clubId: club.clubId, + slug: club.slug, + name: club.name, + tagline: club.tagline, + about: club.about, + status: club.status, + publicVisibility: club.publicVisibility, + domainCount: club.domainCount, + domainActionRequiredCount: club.domainActionRequiredCount, + firstHostOnboardingState: club.firstHostOnboardingState, + })), + domains: (summary.domains ?? summary.domainsRequiringAction ?? []).map((domain) => ({ + id: domain.id, + clubId: domain.clubId, + hostname: domain.hostname, + kind: domain.kind, + status: domain.status, + desiredState: domain.desiredState, + manualAction: domain.manualAction, + errorCode: domain.errorCode, + isPrimary: domain.isPrimary, + verifiedAt: domain.verifiedAt, + lastCheckedAt: domain.lastCheckedAt, + })), + notificationSnapshot: notificationQuery.data ?? null, + notificationUnavailable: notificationQuery.isError, + aiJobs: (aiJobsQuery.data?.items ?? []).map((job) => ({ + jobId: job.jobId, + clubId: job.club.clubId, + clubName: job.club.name ?? job.club.slug ?? "클럽", + sessionTitle: job.session.bookTitle ?? "세션", + status: job.status, + errorCode: job.errorCode, + stale: job.staleCandidate, + startedAt: job.createdAt, + })), + aiDisabled: isReadmatesApiError(aiSummaryQuery.error) && aiSummaryQuery.error.status === 503, + aiUnavailable: aiSummaryQuery.isError && !(isReadmatesApiError(aiSummaryQuery.error) && aiSummaryQuery.error.status === 503), + }; + const workbench = buildPlatformAdminWorkbench(input); + const filteredItems = filterQueueItems(workbench.queueItems, filter); + const filteredWorkbench = { ...workbench, queueItems: filteredItems }; + + function handleSelectItem(itemId: string) { + const next = new URLSearchParams(searchParams); + next.set("selected", itemId); + setSearchParams(next, { replace: true }); + } + + function clearFilter() { + const next = new URLSearchParams(searchParams); + next.delete("filter"); + setSearchParams(next, { replace: true }); + } + + return ( + + ); +} + +function filterQueueItems(items: ReadonlyArray, filter: string | null): WorkbenchQueueItem[] { + if (!filter) return [...items]; + return items.filter((item) => matchesFilter(item, filter)); +} + +function matchesFilter(item: WorkbenchQueueItem, filter: string): boolean { + if (filter === "setup_required") return item.badges.some((badge) => badge === "SETUP_REQUIRED"); + if (filter === "ready_to_publish") return item.primaryActionLabel === "공개 전환"; + if (filter === "domain_action") return item.badges.some((badge) => badge.includes("FAILED") || badge.includes("ACTION_REQUIRED")); + if (filter === "operations_warning") return item.severity === "critical" || item.severity === "warn"; + return true; +} + +function filterLabel(filter: string): string { + if (filter === "setup_required") return "조치 필요"; + if (filter === "ready_to_publish") return "공개 준비"; + if (filter === "domain_action") return "도메인 조치"; + if (filter === "operations_warning") return "운영 경고"; + return filter; +} +``` + +- [ ] **Step 4: Run route tests and verify they pass** + +Run: + +```bash +pnpm --dir front exec vitest run features/platform-admin/route/admin-today-route.test.tsx +``` + +Expected: PASS. + +- [ ] **Step 5: Run model and UI tests together** + +Run: + +```bash +pnpm --dir front exec vitest run features/platform-admin/model/platform-admin-workbench-model.test.ts features/platform-admin/ui/admin-today-ledger.test.tsx features/platform-admin/route/admin-today-route.test.tsx +``` + +Expected: PASS. + +- [ ] **Step 6: Commit the route composition** + +Run: + +```bash +git add front/features/platform-admin/route/admin-today-route.tsx front/features/platform-admin/route/admin-today-route.test.tsx +git commit -m "feat(admin): compose today operations queries" +``` + +## Task 4: Styling And Responsive Polish + +**Files:** +- Modify: `front/src/styles/globals.css` +- Modify: `front/features/platform-admin/ui/admin-today-ledger.test.tsx` + +- [ ] **Step 1: Add class-level regression assertions** + +Append this test to `front/features/platform-admin/ui/admin-today-ledger.test.tsx`. + +```tsx +it("uses stable class hooks for desktop and mobile responsive styling", () => { + const { container } = renderLedger(); + + expect(container.querySelector(".admin-today-ledger__columns")).toBeInTheDocument(); + expect(container.querySelector(".admin-work-queue__row")).toBeInTheDocument(); + expect(container.querySelector(".admin-selected-brief__links")).toBeInTheDocument(); +}); +``` + +- [ ] **Step 2: Run UI tests and verify the new assertion passes before CSS** + +Run: + +```bash +pnpm --dir front exec vitest run features/platform-admin/ui/admin-today-ledger.test.tsx -t "stable class hooks" +``` + +Expected: PASS. This locks the class hooks before adding CSS. + +- [ ] **Step 3: Add CSS for the today ledger** + +Append this CSS near the existing `.admin-today` and `.platform-admin-work-queue` styles in `front/src/styles/globals.css`. + +```css +.admin-today-ledger { + display: grid; + gap: 18px; +} + +.admin-today-ledger__header { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 18px; +} + +.admin-today-ledger__lede { + max-width: 720px; + margin: 6px 0 0; + color: var(--text-3); +} + +.admin-today-ledger__metrics { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px; +} + +.admin-today-ledger__metrics span, +.admin-today-ledger__filter, +.admin-selected-brief__notice { + border: 1px solid var(--line); + border-radius: 8px; + background: var(--surface); + padding: 8px 10px; + color: var(--text-2); + font-size: 12px; + font-weight: 900; +} + +.admin-today-ledger__filter { + display: inline-flex; + align-items: center; + gap: 8px; + width: fit-content; +} + +.admin-today-ledger__filter button { + border: 0; + background: transparent; + color: var(--accent); + cursor: pointer; + font: inherit; +} + +.admin-today-ledger__columns { + display: grid; + grid-template-columns: minmax(0, 1.05fr) minmax(320px, 0.95fr); + gap: 18px; + align-items: start; +} + +.admin-work-queue, +.admin-selected-brief { + display: grid; + gap: 14px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--surface); + padding: 16px; +} + +.admin-work-queue__header, +.admin-selected-brief__header { + display: grid; + gap: 4px; +} + +.admin-work-queue__list { + display: grid; + gap: 8px; +} + +.admin-work-queue__row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 12px; + width: 100%; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--bg-raised); + padding: 12px; + color: var(--text); + text-align: left; + cursor: pointer; +} + +.admin-work-queue__row:hover, +.admin-work-queue__row:focus-visible, +.admin-work-queue__row[aria-pressed="true"] { + border-color: var(--accent); + outline: none; +} + +.admin-work-queue__row[data-severity="critical"], +.admin-work-queue__row[data-severity="blocked"] { + border-left: 4px solid var(--danger); +} + +.admin-work-queue__row[data-severity="attention"], +.admin-work-queue__row[data-severity="warn"] { + border-left: 4px solid var(--warn); +} + +.admin-work-queue__row[data-severity="ready"] { + border-left: 4px solid var(--accent); +} + +.admin-work-queue__main, +.admin-work-queue__meta, +.admin-selected-brief__check, +.admin-selected-brief__links { + display: grid; + gap: 5px; + min-width: 0; +} + +.admin-work-queue__title { + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: baseline; +} + +.admin-work-queue__title span, +.admin-work-queue__reason { + color: var(--text-3); + font-size: 12px; +} + +.admin-work-queue__meta { + justify-items: end; + align-content: start; +} + +.admin-work-queue__severity, +.admin-work-queue__action, +.admin-selected-brief__link { + border: 1px solid var(--line); + border-radius: 8px; + padding: 4px 7px; + color: var(--text-2); + font-size: 11px; + font-weight: 900; + text-decoration: none; + white-space: nowrap; +} + +.admin-selected-brief__checklist { + display: grid; + gap: 8px; +} + +.admin-selected-brief__check { + border: 1px solid var(--line); + border-radius: 8px; + background: var(--bg-sub); + padding: 10px; +} + +.admin-selected-brief__check[data-state="blocked"] { + border-color: var(--warn); +} + +.admin-selected-brief__check span { + color: var(--text-3); + font-size: 12px; +} + +.admin-selected-brief__links { + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); +} + +.admin-today-ledger__loading, +.admin-today-ledger__error, +.admin-work-queue__empty { + border: 1px solid var(--line); + border-radius: 8px; + background: var(--surface); + padding: 16px; +} + +.admin-today-ledger__error { + color: var(--danger); +} + +@media (max-width: 860px) { + .admin-today-ledger__header { + align-items: stretch; + flex-direction: column; + } + + .admin-today-ledger__metrics { + justify-content: flex-start; + } + + .admin-today-ledger__columns, + .admin-work-queue__row { + grid-template-columns: 1fr; + } + + .admin-work-queue__meta { + justify-items: start; + } +} +``` + +- [ ] **Step 4: Run UI tests after CSS** + +Run: + +```bash +pnpm --dir front exec vitest run features/platform-admin/ui/admin-today-ledger.test.tsx +``` + +Expected: PASS. + +- [ ] **Step 5: Commit styling** + +Run: + +```bash +git add front/src/styles/globals.css front/features/platform-admin/ui/admin-today-ledger.test.tsx +git commit -m "style(admin): polish today operations ledger" +``` + +## Task 5: Release Note And Full Frontend Verification + +**Files:** +- Modify: `CHANGELOG.md` + +- [ ] **Step 1: Add the Unreleased CHANGELOG line** + +Open `CHANGELOG.md`. Under the existing `Unreleased` section, add this line in the most fitting frontend/admin subsection. If the section is a flat list, add it as a bullet under `Unreleased`. + +```md +- platform-admin: redesigned `/admin/today` as an operations ledger that prioritizes club readiness, domain, notification, and AI Ops work. +``` + +- [ ] **Step 2: Run focused tests** + +Run: + +```bash +pnpm --dir front exec vitest run features/platform-admin/model/platform-admin-workbench-model.test.ts features/platform-admin/ui/admin-today-ledger.test.tsx features/platform-admin/route/admin-today-route.test.tsx +``` + +Expected: PASS. + +- [ ] **Step 3: Run frontend lint** + +Run: + +```bash +pnpm --dir front lint +``` + +Expected: PASS. + +- [ ] **Step 4: Run frontend test suite** + +Run: + +```bash +pnpm --dir front test +``` + +Expected: PASS. + +- [ ] **Step 5: Run frontend build** + +Run: + +```bash +pnpm --dir front build +``` + +Expected: PASS. + +- [ ] **Step 6: Manually inspect the admin surface** + +Start the dev server if it is not already running. + +```bash +pnpm --dir front dev +``` + +Open `/admin/today` in a browser with a platform admin session and inspect these viewport widths: + +```text +390px mobile +768px tablet +1280px desktop +``` + +Expected: + +- Text does not overlap or overflow queue rows, metric chips, or selected brief links. +- The desktop layout shows queue and brief side by side. +- The mobile layout shows queue first and selected brief below. +- `SUPPORT` role can read the ledger but sees disabled mutation CTAs with a reason. +- Optional AI/notification failures do not blank the club readiness queue. + +- [ ] **Step 7: Run doc diff check** + +Run: + +```bash +git diff --check -- CHANGELOG.md +``` + +Expected: no output. + +- [ ] **Step 8: Commit release note and verification cleanup** + +Run: + +```bash +git add CHANGELOG.md +git commit -m "docs: note admin today ledger" +``` + +If `CHANGELOG.md` already changed in a previous task commit during execution, run `git status --short` and skip this commit only when there is no remaining doc diff. + +## Task 6: Final Review Checklist + +**Files:** +- Review only unless verification discovers a defect. + +- [ ] **Step 1: Inspect final diff against the branch base** + +Run: + +```bash +git diff --stat origin/main..HEAD +git diff --check origin/main..HEAD +``` + +Expected: `git diff --check` has no output. + +- [ ] **Step 2: Confirm route-first boundaries** + +Run: + +```bash +rg -n "useQuery|fetch|readmatesFetch|platformAdmin.*Query|admin-today-route" front/features/platform-admin/ui/admin-today-ledger.tsx front/features/platform-admin/ui/admin-work-queue.tsx front/features/platform-admin/ui/admin-selected-brief.tsx front/features/platform-admin/model/platform-admin-workbench-model.ts +``` + +Expected: + +- No matches in `front/features/platform-admin/ui/admin-today-ledger.tsx`. +- No matches in `front/features/platform-admin/ui/admin-work-queue.tsx`. +- No matches in `front/features/platform-admin/ui/admin-selected-brief.tsx`. +- No React/router/fetch matches in `front/features/platform-admin/model/platform-admin-workbench-model.ts`. + +- [ ] **Step 3: Confirm no public-safety regressions in touched files** + +Run: + +```bash +rg -n "token|secret|password|@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}|/Users/|ocid1\\." front/features/platform-admin docs/superpowers/specs/2026-05-27-readmates-admin-today-operations-ledger-design.md CHANGELOG.md +``` + +Expected: + +- No raw secrets or token-shaped examples. +- Synthetic masked examples such as `m***@example.com` in tests are acceptable. +- Local paths may appear only in tool output, not in committed source files. + +- [ ] **Step 4: Capture final status** + +Run: + +```bash +git status --short +``` + +Expected: clean working tree, unless generated artifacts from local verification are explicitly untracked and ignored. diff --git a/docs/superpowers/plans/2026-05-27-readmates-admin-vnext-audit-ledger.md b/docs/superpowers/plans/2026-05-27-readmates-admin-vnext-audit-ledger.md new file mode 100644 index 00000000..93e46c65 --- /dev/null +++ b/docs/superpowers/plans/2026-05-27-readmates-admin-vnext-audit-ledger.md @@ -0,0 +1,2394 @@ +# ReadMates Admin vNext Audit Ledger Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship S7 `/admin/audit` as a read-only platform-admin audit ledger that safely unifies existing platform, club, notification replay, and AI audit records while preserving S8-compatible filter semantics. + +**Architecture:** Add a dedicated read-only `com.readmates.admin.audit` server slice with source-specific query ports and allowlist projectors, then expose `/api/admin/audit/events` as a cursor page. On the frontend, follow the existing platform-admin route-first pattern with `api`, `model`, `queries`, `route`, and `ui` modules, flipping only the audit route from coming-soon to READY. Docs and release notes are updated only after behavior ships. + +**Tech Stack:** Kotlin/Spring Boot, JdbcTemplate, MySQL/Flyway existing tables, React/Vite, React Router, TanStack Query v5, Vitest, Playwright. + +--- + +## Source Documents + +- Design spec: `docs/superpowers/specs/2026-05-27-readmates-admin-vnext-audit-ledger-design.md` +- Roadmap reset: `docs/superpowers/specs/2026-05-26-readmates-admin-vnext-operating-roadmap-reset-design.md` +- Architecture source of truth: `docs/development/architecture.md` +- Guides: `docs/agents/server.md`, `docs/agents/front.md`, `docs/agents/design.md`, `docs/agents/docs.md` + +## File Structure + +### Server + +- Create `server/src/main/kotlin/com/readmates/admin/audit/application/model/AdminAuditModels.kt` + - Shared request, filter, source row, ledger item, source state, cursor, and enum models. +- Create `server/src/main/kotlin/com/readmates/admin/audit/application/port/in/AdminAuditUseCases.kt` + - `ListAdminAuditLedgerUseCase`. +- Create `server/src/main/kotlin/com/readmates/admin/audit/application/port/out/AdminAuditLedgerReadPort.kt` + - Source-specific read methods for platform events, club events, AI audit rows, and replay previews. +- Create `server/src/main/kotlin/com/readmates/admin/audit/application/service/AdminAuditLedgerService.kt` + - Authorization, filter normalization, per-source failure isolation, merge/sort/page, cursor encoding, source projection. +- Create `server/src/main/kotlin/com/readmates/admin/audit/application/AdminAuditException.kt` + - Typed invalid filter/cursor errors. +- Create `server/src/main/kotlin/com/readmates/admin/audit/adapter/out/persistence/JdbcAdminAuditLedgerAdapter.kt` + - Bounded source queries against existing audit tables. +- Create `server/src/main/kotlin/com/readmates/admin/audit/adapter/in/web/PlatformAdminAuditController.kt` + - `/api/admin/audit/events` endpoint, query parsing, response DTO mapping. +- Create `server/src/main/kotlin/com/readmates/admin/audit/adapter/in/web/AdminAuditErrorHandler.kt` + - Safe HTTP status mapping for invalid request errors. +- Test `server/src/test/kotlin/com/readmates/admin/audit/application/service/AdminAuditLedgerServiceTest.kt` +- Test `server/src/test/kotlin/com/readmates/admin/audit/api/PlatformAdminAuditControllerTest.kt` + +### Frontend + +- Create `front/features/platform-admin/model/platform-admin-audit-model.ts` + - Response/request types, filter normalization, label helpers, URL parsing. +- Create `front/features/platform-admin/api/platform-admin-audit-api.ts` + - BFF GET call for `/api/admin/audit/events`. +- Modify `front/features/platform-admin/api/platform-admin-contracts.ts` + - Export audit types. +- Create `front/features/platform-admin/queries/platform-admin-audit-queries.ts` + - Query keys and queryOptions. +- Create `front/features/platform-admin/route/admin-audit-data.ts` + - Loader factory seeding the default filter. +- Create `front/features/platform-admin/route/admin-audit-route.tsx` + - URL state, query execution, load-more coordination, UI props. +- Create `front/features/platform-admin/ui/admin-audit-ledger.tsx` + - Filter toolbar, ledger rows, selected detail, empty/partial states. +- Modify `front/features/platform-admin/model/admin-route-catalog.ts` + - Flip audit to READY and remove `comingSoon`. +- Modify `front/src/app/routes/admin.tsx` + - Add ready child for `audit`. +- Modify `front/src/styles/globals.css` + - Add restrained `admin-audit-*` styles. +- Test `front/features/platform-admin/model/platform-admin-audit-model.test.ts` +- Test `front/features/platform-admin/route/admin-audit-route.test.tsx` +- Test `front/features/platform-admin/ui/admin-audit-ledger.test.tsx` +- Modify `front/features/platform-admin/model/admin-route-catalog.test.ts` +- Modify `front/features/platform-admin/ui/admin-layout-nav.test.tsx` +- Create `front/tests/e2e/admin-audit.spec.ts` +- Modify `front/tests/e2e/admin-shell.spec.ts` + +### Docs + +- Modify `CHANGELOG.md` +- Modify `docs/development/architecture.md` +- Modify `docs/development/server-state-migration.md` + +--- + +## Task 1: Backend Models, Projection, And Service + +**Files:** +- Create: `server/src/main/kotlin/com/readmates/admin/audit/application/model/AdminAuditModels.kt` +- Create: `server/src/main/kotlin/com/readmates/admin/audit/application/port/in/AdminAuditUseCases.kt` +- Create: `server/src/main/kotlin/com/readmates/admin/audit/application/port/out/AdminAuditLedgerReadPort.kt` +- Create: `server/src/main/kotlin/com/readmates/admin/audit/application/AdminAuditException.kt` +- Create: `server/src/main/kotlin/com/readmates/admin/audit/application/service/AdminAuditLedgerService.kt` +- Test: `server/src/test/kotlin/com/readmates/admin/audit/application/service/AdminAuditLedgerServiceTest.kt` + +- [ ] **Step 1: Write the service test for merge order, masking, and malformed metadata** + +Create `server/src/test/kotlin/com/readmates/admin/audit/application/service/AdminAuditLedgerServiceTest.kt` with these first tests. Keep the fake port local to the test file so the production interface can evolve without test fixtures leaking into app code. + +```kotlin +package com.readmates.admin.audit.application.service + +import com.readmates.admin.audit.application.model.AdminAuditFilter +import com.readmates.admin.audit.application.model.AdminAuditListQuery +import com.readmates.admin.audit.application.model.AdminAuditSourceRow +import com.readmates.admin.audit.application.model.AdminAuditSourceType +import com.readmates.admin.audit.application.port.out.AdminAuditLedgerReadPort +import com.readmates.club.domain.PlatformAdminRole +import com.readmates.shared.paging.PageRequest +import com.readmates.shared.security.CurrentPlatformAdmin +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import java.time.OffsetDateTime +import java.time.ZoneOffset +import java.util.UUID + +class AdminAuditLedgerServiceTest { + @Test + fun `merges source rows in reverse chronological order and returns opaque cursor`() { + val readPort = FakeAdminAuditLedgerReadPort( + platformRows = listOf(platformRow("platform-1", "2026-05-27T00:01:00Z")), + clubRows = listOf(clubRow("club-1", "2026-05-27T00:02:00Z")), + aiRows = listOf(aiRow("ai-1", "2026-05-27T00:00:00Z")), + replayPreviewRows = listOf(replayPreviewRow("preview-1", "2026-05-27T00:03:00Z")), + ) + val service = AdminAuditLedgerService(readPort) + + val page = service.listLedger(owner(), query(limit = 2)) + + assertThat(page.items.map { it.id }).containsExactly( + "admin_notification_replay_previews:preview-1", + "club_audit_events:club-1", + ) + assertThat(page.nextCursor).isNotBlank() + assertThat(page.summary.visibleCount).isEqualTo(2) + } + + @Test + fun `support receives masked target labels for support grant rows`() { + val readPort = FakeAdminAuditLedgerReadPort( + platformRows = listOf( + platformRow( + id = "support-create", + occurredAt = "2026-05-27T00:01:00Z", + eventType = "SUPPORT_ACCESS_GRANT_CREATED", + metadataJson = """{"grantId":"grant-1","clubId":"club-1","granteeUserId":"00000000-0000-0000-0000-000000000202","scope":"METADATA_READ","expiresAt":"2026-05-28T00:00:00Z"}""", + ), + ), + ) + val service = AdminAuditLedgerService(readPort) + + val item = service.listLedger(support(), query()).items.single() + + assertThat(item.summary).contains("support grant") + assertThat(item.target.label).isEqualTo("사용자 숨김") + assertThat(item.safeMetadata.map { it.label }).contains("scope", "expiryBucket") + assertThat(item.safeMetadata.map { it.value }).doesNotContain("00000000-0000-0000-0000-000000000202") + } + + @Test + fun `malformed metadata keeps row visible without raw json`() { + val readPort = FakeAdminAuditLedgerReadPort( + platformRows = listOf( + platformRow( + id = "broken", + occurredAt = "2026-05-27T00:01:00Z", + eventType = "ADMIN_NOTIFICATION_REPLAY_CONFIRMED", + metadataJson = """{"previewId":""", + ), + ), + ) + val service = AdminAuditLedgerService(readPort) + + val item = service.listLedger(owner(), query()).items.single() + + assertThat(item.metadataState.name).isEqualTo("UNAVAILABLE") + assertThat(item.safeMetadata).isEmpty() + assertThat(item.summary).doesNotContain("previewId") + } + + private fun query(limit: Int = 25): AdminAuditListQuery = + AdminAuditListQuery( + filter = AdminAuditFilter.defaultNow(now = NOW), + pageRequest = PageRequest.cursor(requestedLimit = limit, rawCursor = null, defaultLimit = 25, maxLimit = 50), + ) + + private fun owner(): CurrentPlatformAdmin = + CurrentPlatformAdmin(ADMIN_USER_ID, "owner@example.com", PlatformAdminRole.OWNER) + + private fun support(): CurrentPlatformAdmin = + CurrentPlatformAdmin(SUPPORT_USER_ID, "support@example.com", PlatformAdminRole.SUPPORT) +} +``` + +- [ ] **Step 2: Run the failing service test** + +Run: + +```bash +./server/gradlew -p server unitTest --tests "com.readmates.admin.audit.application.service.AdminAuditLedgerServiceTest" +``` + +Expected: fail because the `com.readmates.admin.audit` package does not exist. + +- [ ] **Step 3: Add model and port contracts** + +Create `server/src/main/kotlin/com/readmates/admin/audit/application/model/AdminAuditModels.kt` with these concrete types. + +```kotlin +package com.readmates.admin.audit.application.model + +import com.readmates.shared.paging.CursorPage +import com.readmates.shared.paging.PageRequest +import java.time.OffsetDateTime +import java.time.ZoneOffset +import java.util.UUID + +data class AdminAuditListQuery( + val filter: AdminAuditFilter, + val pageRequest: PageRequest, +) + +data class AdminAuditFilter( + val from: OffsetDateTime, + val to: OffsetDateTime, + val range: AdminAuditTimeRange?, + val clubId: UUID?, + val actorRole: AdminAuditActorRole?, + val sourceSlice: AdminAuditSourceSlice?, + val actionCategory: AdminAuditActionCategory?, + val outcome: AdminAuditOutcome?, +) { + companion object { + fun defaultNow(now: OffsetDateTime): AdminAuditFilter = + AdminAuditFilter( + from = now.minusDays(7), + to = now, + range = AdminAuditTimeRange.DAYS_7, + clubId = null, + actorRole = null, + sourceSlice = null, + actionCategory = null, + outcome = null, + ) + } +} + +enum class AdminAuditTimeRange(val wireValue: String) { + HOURS_24("24h"), + DAYS_7("7d"), + DAYS_30("30d"), + DAYS_90("90d"), +} + +enum class AdminAuditSourceSlice { S3, S4, S5, S6, PLATFORM, CLUB } +enum class AdminAuditActionCategory { NOTIFICATION, SUPPORT, CLUB_LIFECYCLE, AI_OPS, AUTH_SECURITY, PLATFORM_ADMIN } +enum class AdminAuditActorRole { OWNER, OPERATOR, SUPPORT, HOST, MEMBER, SYSTEM, UNKNOWN } +enum class AdminAuditOutcome { SUCCESS, FAILED, DENIED, PREPARED, UNKNOWN } +enum class AdminAuditMetadataState { AVAILABLE, EMPTY, UNAVAILABLE } +enum class AdminAuditSourceType(val tableName: String, val rank: Int) { + PLATFORM("platform_audit_events", 10), + CLUB("club_audit_events", 20), + AI_GENERATION("ai_generation_audit_log", 30), + NOTIFICATION_REPLAY_PREVIEW("admin_notification_replay_previews", 40), +} + +data class AdminAuditSourceRow( + val sourceType: AdminAuditSourceType, + val sourceId: String, + val occurredAt: OffsetDateTime, + val actorUserId: UUID?, + val actorRole: String?, + val clubId: UUID?, + val targetUserId: UUID?, + val actionType: String, + val outcomeHint: String?, + val metadataJson: String?, +) + +data class AdminAuditLedgerPage( + val generatedAt: OffsetDateTime, + val filters: AdminAuditFilter, + val summary: AdminAuditSummary, + val items: List, + val nextCursor: String?, +) { + fun toCursorPage(): CursorPage = CursorPage(items, nextCursor) +} + +data class AdminAuditSummary( + val visibleCount: Int, + val sourceUnavailableCount: Int, + val metadataUnavailableCount: Int, + val unavailableSources: List, +) + +data class AdminAuditLedgerItem( + val id: String, + val occurredAt: OffsetDateTime, + val sourceSlice: AdminAuditSourceSlice, + val sourceTable: String, + val actionCategory: AdminAuditActionCategory, + val actionType: String, + val outcome: AdminAuditOutcome, + val actor: AdminAuditActor, + val target: AdminAuditTarget, + val summary: String, + val safeMetadata: List, + val metadataState: AdminAuditMetadataState, +) + +data class AdminAuditActor( + val userId: UUID?, + val role: AdminAuditActorRole, + val displayLabel: String, +) + +data class AdminAuditTarget( + val clubId: UUID?, + val userId: UUID?, + val jobId: UUID?, + val eventId: String?, + val label: String, +) + +data class AdminAuditMetadata( + val label: String, + val value: String, + val kind: String, +) + +fun OffsetDateTime.utc(): OffsetDateTime = withOffsetSameInstant(ZoneOffset.UTC) +``` + +Create `server/src/main/kotlin/com/readmates/admin/audit/application/port/in/AdminAuditUseCases.kt`. + +```kotlin +package com.readmates.admin.audit.application.port.`in` + +import com.readmates.admin.audit.application.model.AdminAuditLedgerPage +import com.readmates.admin.audit.application.model.AdminAuditListQuery +import com.readmates.shared.security.CurrentPlatformAdmin + +interface ListAdminAuditLedgerUseCase { + fun listLedger( + admin: CurrentPlatformAdmin, + query: AdminAuditListQuery, + ): AdminAuditLedgerPage +} +``` + +Create `server/src/main/kotlin/com/readmates/admin/audit/application/port/out/AdminAuditLedgerReadPort.kt`. + +```kotlin +package com.readmates.admin.audit.application.port.out + +import com.readmates.admin.audit.application.model.AdminAuditFilter +import com.readmates.admin.audit.application.model.AdminAuditSourceRow +import com.readmates.shared.paging.PageRequest + +interface AdminAuditLedgerReadPort { + fun listPlatformEvents( + filter: AdminAuditFilter, + pageRequest: PageRequest, + ): List + + fun listClubEvents( + filter: AdminAuditFilter, + pageRequest: PageRequest, + ): List + + fun listAiGenerationEvents( + filter: AdminAuditFilter, + pageRequest: PageRequest, + ): List + + fun listNotificationReplayPreviews( + filter: AdminAuditFilter, + pageRequest: PageRequest, + ): List +} +``` + +Create `server/src/main/kotlin/com/readmates/admin/audit/application/AdminAuditException.kt`. + +```kotlin +package com.readmates.admin.audit.application + +enum class AdminAuditError { + INVALID_FILTER, + INVALID_CURSOR, +} + +class AdminAuditException( + val error: AdminAuditError, + message: String, +) : RuntimeException(message) +``` + +- [ ] **Step 4: Add the service with source projection and cursor handling** + +Create `server/src/main/kotlin/com/readmates/admin/audit/application/service/AdminAuditLedgerService.kt`. + +```kotlin +package com.readmates.admin.audit.application.service + +import com.readmates.admin.audit.application.AdminAuditError +import com.readmates.admin.audit.application.AdminAuditException +import com.readmates.admin.audit.application.model.AdminAuditActionCategory +import com.readmates.admin.audit.application.model.AdminAuditActor +import com.readmates.admin.audit.application.model.AdminAuditActorRole +import com.readmates.admin.audit.application.model.AdminAuditFilter +import com.readmates.admin.audit.application.model.AdminAuditLedgerItem +import com.readmates.admin.audit.application.model.AdminAuditLedgerPage +import com.readmates.admin.audit.application.model.AdminAuditListQuery +import com.readmates.admin.audit.application.model.AdminAuditMetadata +import com.readmates.admin.audit.application.model.AdminAuditMetadataState +import com.readmates.admin.audit.application.model.AdminAuditOutcome +import com.readmates.admin.audit.application.model.AdminAuditSourceRow +import com.readmates.admin.audit.application.model.AdminAuditSourceSlice +import com.readmates.admin.audit.application.model.AdminAuditSourceType +import com.readmates.admin.audit.application.model.AdminAuditSummary +import com.readmates.admin.audit.application.model.AdminAuditTarget +import com.readmates.admin.audit.application.port.`in`.ListAdminAuditLedgerUseCase +import com.readmates.admin.audit.application.port.out.AdminAuditLedgerReadPort +import com.readmates.club.domain.PlatformAdminRole +import com.readmates.shared.architecture.ReadOnlyApplicationService +import com.readmates.shared.paging.CursorCodec +import com.readmates.shared.paging.PageRequest +import com.readmates.shared.security.CurrentPlatformAdmin +import tools.jackson.databind.ObjectMapper +import java.time.OffsetDateTime +import java.time.ZoneOffset +import java.util.UUID + +@ReadOnlyApplicationService +class AdminAuditLedgerService( + private val readPort: AdminAuditLedgerReadPort, + private val objectMapper: ObjectMapper = ObjectMapper(), +) : ListAdminAuditLedgerUseCase { + override fun listLedger( + admin: CurrentPlatformAdmin, + query: AdminAuditListQuery, + ): AdminAuditLedgerPage { + validateFilter(query.filter) + val requestedLimit = query.pageRequest.limit.coerceIn(1, MAX_LIMIT) + val sourcePage = query.pageRequest.copy(limit = requestedLimit + 1) + val unavailable = mutableListOf() + val sourceRows = + buildList { + addAll(readSource(unavailable, AdminAuditSourceType.PLATFORM) { readPort.listPlatformEvents(query.filter, sourcePage) }) + addAll(readSource(unavailable, AdminAuditSourceType.CLUB) { readPort.listClubEvents(query.filter, sourcePage) }) + addAll(readSource(unavailable, AdminAuditSourceType.AI_GENERATION) { readPort.listAiGenerationEvents(query.filter, sourcePage) }) + addAll(readSource(unavailable, AdminAuditSourceType.NOTIFICATION_REPLAY_PREVIEW) { readPort.listNotificationReplayPreviews(query.filter, sourcePage) }) + } + + val projected = + sourceRows + .map { project(admin, it) } + .filter { matchesQueryFilter(query.filter, it) } + .sortedWith(compareByDescending { it.occurredAt }.thenBy { sourceRank(it.sourceTable) }.thenByDescending { it.id }) + + val visible = projected.take(requestedLimit) + val nextCursor = projected.drop(requestedLimit).firstOrNull()?.let(::encodeCursor) + return AdminAuditLedgerPage( + generatedAt = OffsetDateTime.now(ZoneOffset.UTC), + filters = query.filter, + summary = + AdminAuditSummary( + visibleCount = visible.size, + sourceUnavailableCount = unavailable.size, + metadataUnavailableCount = visible.count { it.metadataState == AdminAuditMetadataState.UNAVAILABLE }, + unavailableSources = unavailable, + ), + items = visible, + nextCursor = nextCursor, + ) + } + + private fun validateFilter(filter: AdminAuditFilter) { + if (!filter.from.isBefore(filter.to)) { + throw AdminAuditException(AdminAuditError.INVALID_FILTER, "from must be before to") + } + if (filter.from.isBefore(filter.to.minusDays(90))) { + throw AdminAuditException(AdminAuditError.INVALID_FILTER, "audit range cannot exceed 90 days") + } + } + + private fun readSource( + unavailable: MutableList, + source: AdminAuditSourceType, + read: () -> List, + ): List = + runCatching(read).getOrElse { + unavailable += source + emptyList() + } + + private fun project( + admin: CurrentPlatformAdmin, + row: AdminAuditSourceRow, + ): AdminAuditLedgerItem = + when (row.sourceType) { + AdminAuditSourceType.PLATFORM -> projectPlatform(admin, row) + AdminAuditSourceType.CLUB -> projectClub(admin, row) + AdminAuditSourceType.AI_GENERATION -> projectAi(admin, row) + AdminAuditSourceType.NOTIFICATION_REPLAY_PREVIEW -> projectReplayPreview(admin, row) + } + + private fun projectPlatform( + admin: CurrentPlatformAdmin, + row: AdminAuditSourceRow, + ): AdminAuditLedgerItem { + val metadata = parseMetadata(row.metadataJson) + val metadataUnavailable = row.metadataJson != null && metadata == null + val sourceSlice = + when (row.actionType) { + "SUPPORT_ACCESS_GRANT_CREATED", "SUPPORT_ACCESS_GRANT_REVOKED" -> AdminAuditSourceSlice.S4 + "ADMIN_NOTIFICATION_REPLAY_CONFIRMED" -> AdminAuditSourceSlice.S5 + else -> AdminAuditSourceSlice.PLATFORM + } + val category = + when (sourceSlice) { + AdminAuditSourceSlice.S4 -> AdminAuditActionCategory.SUPPORT + AdminAuditSourceSlice.S5 -> AdminAuditActionCategory.NOTIFICATION + else -> AdminAuditActionCategory.PLATFORM_ADMIN + } + val targetUser = row.targetUserId.takeUnless { admin.role == PlatformAdminRole.SUPPORT } + val safeMetadata = + if (metadata == null) { + emptyList() + } else { + platformMetadata(row.actionType, metadata, admin.role) + } + return AdminAuditLedgerItem( + id = "${row.sourceType.tableName}:${row.sourceId}", + occurredAt = row.occurredAt, + sourceSlice = sourceSlice, + sourceTable = row.sourceType.tableName, + actionCategory = category, + actionType = row.actionType, + outcome = AdminAuditOutcome.SUCCESS, + actor = actor(row.actorUserId, row.actorRole), + target = + AdminAuditTarget( + clubId = row.clubId ?: metadata?.uuid("clubId"), + userId = targetUser, + jobId = null, + eventId = metadata?.string("eventId"), + label = if (admin.role == PlatformAdminRole.SUPPORT && row.targetUserId != null) "사용자 숨김" else row.targetUserId?.toString() ?: "대상 없음", + ), + summary = platformSummary(row.actionType), + safeMetadata = safeMetadata, + metadataState = metadataState(metadata, metadataUnavailable), + ) + } + + private fun projectClub( + admin: CurrentPlatformAdmin, + row: AdminAuditSourceRow, + ): AdminAuditLedgerItem = + AdminAuditLedgerItem( + id = "${row.sourceType.tableName}:${row.sourceId}", + occurredAt = row.occurredAt, + sourceSlice = AdminAuditSourceSlice.S3, + sourceTable = row.sourceType.tableName, + actionCategory = AdminAuditActionCategory.CLUB_LIFECYCLE, + actionType = row.actionType, + outcome = AdminAuditOutcome.SUCCESS, + actor = actor(row.actorUserId, row.actorRole), + target = AdminAuditTarget(row.clubId, null, null, null, row.clubId?.toString() ?: "클럽"), + summary = "클럽 운영 상태가 변경되었습니다.", + safeMetadata = listOf(AdminAuditMetadata("eventType", row.actionType, "code")), + metadataState = AdminAuditMetadataState.AVAILABLE, + ) + + private fun projectAi( + admin: CurrentPlatformAdmin, + row: AdminAuditSourceRow, + ): AdminAuditLedgerItem { + val metadata = parseMetadata(row.metadataJson) + val status = metadata?.string("status") ?: row.outcomeHint ?: "UNKNOWN" + return AdminAuditLedgerItem( + id = "${row.sourceType.tableName}:${row.sourceId}", + occurredAt = row.occurredAt, + sourceSlice = AdminAuditSourceSlice.S6, + sourceTable = row.sourceType.tableName, + actionCategory = AdminAuditActionCategory.AI_OPS, + actionType = row.actionType, + outcome = if (status == "FAILED") AdminAuditOutcome.FAILED else AdminAuditOutcome.SUCCESS, + actor = actor(row.actorUserId, row.actorRole ?: "HOST"), + target = AdminAuditTarget(row.clubId, null, metadata?.uuid("jobId"), null, metadata?.string("jobId") ?: "AI job"), + summary = "AI 작업 감사 이벤트가 기록되었습니다.", + safeMetadata = aiMetadata(metadata), + metadataState = metadataState(metadata, metadata == null && row.metadataJson != null), + ) + } + + private fun projectReplayPreview( + admin: CurrentPlatformAdmin, + row: AdminAuditSourceRow, + ): AdminAuditLedgerItem { + val metadata = parseMetadata(row.metadataJson) + return AdminAuditLedgerItem( + id = "${row.sourceType.tableName}:${row.sourceId}", + occurredAt = row.occurredAt, + sourceSlice = AdminAuditSourceSlice.S5, + sourceTable = row.sourceType.tableName, + actionCategory = AdminAuditActionCategory.NOTIFICATION, + actionType = "ADMIN_NOTIFICATION_REPLAY_PREVIEW", + outcome = AdminAuditOutcome.PREPARED, + actor = actor(row.actorUserId, row.actorRole), + target = AdminAuditTarget(row.clubId, null, null, row.sourceId, "Replay preview"), + summary = "알림 재처리 대상이 미리 확인되었습니다.", + safeMetadata = replayPreviewMetadata(metadata), + metadataState = metadataState(metadata, metadata == null && row.metadataJson != null), + ) + } + + private fun actor( + actorUserId: UUID?, + actorRole: String?, + ): AdminAuditActor { + val role = actorRole.toActorRole() + return AdminAuditActor( + userId = actorUserId, + role = role, + displayLabel = role.name, + ) + } + + private fun platformMetadata( + actionType: String, + metadata: Map, + role: PlatformAdminRole, + ): List = + when (actionType) { + "SUPPORT_ACCESS_GRANT_CREATED" -> + listOfNotNull( + metadata.string("grantId")?.let { AdminAuditMetadata("grantId", it, "id") }, + metadata.string("scope")?.let { AdminAuditMetadata("scope", it, "code") }, + metadata.string("expiresAt")?.let { AdminAuditMetadata("expiryBucket", expiryBucket(it), "time") }, + ) + "SUPPORT_ACCESS_GRANT_REVOKED" -> + listOfNotNull(metadata.string("grantId")?.let { AdminAuditMetadata("grantId", it, "id") }) + "ADMIN_NOTIFICATION_REPLAY_CONFIRMED" -> + listOfNotNull( + metadata.string("previewId")?.let { AdminAuditMetadata("previewId", it, "id") }, + metadata.string("selectionHash")?.take(8)?.let { AdminAuditMetadata("selectionHashPrefix", it, "fingerprint") }, + metadata.number("replayedCount")?.let { AdminAuditMetadata("replayedCount", it, "count") }, + metadata.number("skippedCount")?.let { AdminAuditMetadata("skippedCount", it, "count") }, + AdminAuditMetadata("reasonPresent", metadata.string("reason")?.isNotBlank().toString(), "boolean"), + ) + else -> listOf(AdminAuditMetadata("eventType", actionType, "code")) + } + + private fun aiMetadata(metadata: Map?): List = + if (metadata == null) { + emptyList() + } else { + listOfNotNull( + metadata.string("provider")?.let { AdminAuditMetadata("provider", it, "code") }, + metadata.string("model")?.let { AdminAuditMetadata("model", it, "code") }, + metadata.string("status")?.let { AdminAuditMetadata("status", it, "code") }, + metadata.string("errorCode")?.let { AdminAuditMetadata("errorCode", it, "code") }, + metadata.number("costEstimateUsd")?.let { AdminAuditMetadata("costEstimateUsd", it, "money") }, + metadata.number("latencyMs")?.let { AdminAuditMetadata("latencyMs", it, "duration") }, + ) + } + + private fun replayPreviewMetadata(metadata: Map?): List = + if (metadata == null) { + emptyList() + } else { + listOfNotNull( + metadata.number("matchedCount")?.let { AdminAuditMetadata("matchedCount", it, "count") }, + metadata.string("selectionHash")?.take(8)?.let { AdminAuditMetadata("selectionHashPrefix", it, "fingerprint") }, + metadata.string("expiresAt")?.let { AdminAuditMetadata("expiresAt", it, "time") }, + metadata.string("consumedAt")?.let { AdminAuditMetadata("consumedAt", it, "time") }, + ) + } + + private fun metadataState( + metadata: Map?, + unavailable: Boolean, + ): AdminAuditMetadataState = + when { + unavailable -> AdminAuditMetadataState.UNAVAILABLE + metadata == null || metadata.isEmpty() -> AdminAuditMetadataState.EMPTY + else -> AdminAuditMetadataState.AVAILABLE + } + + private fun parseMetadata(metadataJson: String?): Map? = + metadataJson?.let { + runCatching { + @Suppress("UNCHECKED_CAST") + objectMapper.readValue(it, Map::class.java) as Map + }.getOrNull() + } + + private fun matchesQueryFilter( + filter: AdminAuditFilter, + item: AdminAuditLedgerItem, + ): Boolean = + (filter.sourceSlice == null || item.sourceSlice == filter.sourceSlice) && + (filter.actionCategory == null || item.actionCategory == filter.actionCategory) && + (filter.outcome == null || item.outcome == filter.outcome) && + (filter.actorRole == null || item.actor.role == filter.actorRole) && + (filter.clubId == null || item.target.clubId == filter.clubId) + + private fun encodeCursor(item: AdminAuditLedgerItem): String? = + CursorCodec.encode( + mapOf( + "occurredAt" to item.occurredAt.toString(), + "sourceRank" to sourceRank(item.sourceTable).toString(), + "sourceId" to item.id.substringAfter(":"), + ), + ) + + private fun sourceRank(tableName: String): Int = + AdminAuditSourceType.entries.firstOrNull { it.tableName == tableName }?.rank ?: 99 +} + +private fun String?.toActorRole(): AdminAuditActorRole = + when (this) { + "OWNER" -> AdminAuditActorRole.OWNER + "OPERATOR" -> AdminAuditActorRole.OPERATOR + "SUPPORT" -> AdminAuditActorRole.SUPPORT + "HOST" -> AdminAuditActorRole.HOST + "MEMBER" -> AdminAuditActorRole.MEMBER + "SYSTEM" -> AdminAuditActorRole.SYSTEM + else -> AdminAuditActorRole.UNKNOWN + } + +private fun platformSummary(actionType: String): String = + when (actionType) { + "SUPPORT_ACCESS_GRANT_CREATED" -> "support grant가 생성되었습니다." + "SUPPORT_ACCESS_GRANT_REVOKED" -> "support grant가 회수되었습니다." + "ADMIN_NOTIFICATION_REPLAY_CONFIRMED" -> "알림 재처리가 확정되었습니다." + else -> "platform admin 이벤트가 기록되었습니다." + } + +private fun Map.string(key: String): String? = this[key]?.toString()?.takeIf { it.isNotBlank() } +private fun Map.number(key: String): String? = this[key]?.toString()?.takeIf { it.isNotBlank() } +private fun Map.uuid(key: String): UUID? = string(key)?.let { runCatching { UUID.fromString(it) }.getOrNull() } +private fun expiryBucket(value: String): String = if (value.contains("T")) "configured" else "unknown" + +private const val MAX_LIMIT = 50 +``` + +When implementing, keep the service code focused. If the projector functions make the file difficult to read, split them into `AdminAuditSourceProjectors.kt` in the same package during this task and update imports in the test. + +- [ ] **Step 5: Complete test helpers and run the service test** + +Add the fake port and row helpers to the bottom of `AdminAuditLedgerServiceTest.kt`. + +```kotlin +private class FakeAdminAuditLedgerReadPort( + private val platformRows: List = emptyList(), + private val clubRows: List = emptyList(), + private val aiRows: List = emptyList(), + private val replayPreviewRows: List = emptyList(), +) : AdminAuditLedgerReadPort { + override fun listPlatformEvents(filter: AdminAuditFilter, pageRequest: PageRequest): List = platformRows + override fun listClubEvents(filter: AdminAuditFilter, pageRequest: PageRequest): List = clubRows + override fun listAiGenerationEvents(filter: AdminAuditFilter, pageRequest: PageRequest): List = aiRows + override fun listNotificationReplayPreviews(filter: AdminAuditFilter, pageRequest: PageRequest): List = replayPreviewRows +} + +private fun platformRow( + id: String, + occurredAt: String, + eventType: String = "ADMIN_NOTIFICATION_REPLAY_CONFIRMED", + metadataJson: String = """{"previewId":"preview-1","selectionHash":"aaaaaaaa","reason":"provider recovered","replayedCount":2,"skippedCount":0}""", +): AdminAuditSourceRow = + AdminAuditSourceRow( + sourceType = AdminAuditSourceType.PLATFORM, + sourceId = id, + occurredAt = OffsetDateTime.parse(occurredAt), + actorUserId = ADMIN_USER_ID, + actorRole = "OWNER", + clubId = CLUB_ID, + targetUserId = MEMBER_USER_ID, + actionType = eventType, + outcomeHint = null, + metadataJson = metadataJson, + ) + +private fun clubRow(id: String, occurredAt: String): AdminAuditSourceRow = + AdminAuditSourceRow(AdminAuditSourceType.CLUB, id, OffsetDateTime.parse(occurredAt), ADMIN_USER_ID, "OPERATOR", CLUB_ID, null, "CLUB_STATUS_CHANGED", null, "{}") + +private fun aiRow(id: String, occurredAt: String): AdminAuditSourceRow = + AdminAuditSourceRow(AdminAuditSourceType.AI_GENERATION, id, OffsetDateTime.parse(occurredAt), HOST_USER_ID, "HOST", CLUB_ID, null, "AI_GENERATION_AUDIT", "SUCCEEDED", """{"jobId":"$AI_JOB_ID","provider":"openai","model":"gpt-safe","status":"SUCCEEDED","costEstimateUsd":"0.0100","latencyMs":1200}""") + +private fun replayPreviewRow(id: String, occurredAt: String): AdminAuditSourceRow = + AdminAuditSourceRow(AdminAuditSourceType.NOTIFICATION_REPLAY_PREVIEW, id, OffsetDateTime.parse(occurredAt), ADMIN_USER_ID, "OWNER", CLUB_ID, null, "ADMIN_NOTIFICATION_REPLAY_PREVIEW", "PREPARED", """{"matchedCount":2,"selectionHash":"aaaaaaaa","expiresAt":"2026-05-27T00:10:00Z"}""") + +private val NOW: OffsetDateTime = OffsetDateTime.parse("2026-05-27T00:05:00Z") +private val ADMIN_USER_ID: UUID = UUID.fromString("00000000-0000-0000-0000-000000000901") +private val SUPPORT_USER_ID: UUID = UUID.fromString("00000000-0000-0000-0000-000000000903") +private val MEMBER_USER_ID: UUID = UUID.fromString("00000000-0000-0000-0000-000000000202") +private val HOST_USER_ID: UUID = UUID.fromString("00000000-0000-0000-0000-000000000201") +private val CLUB_ID: UUID = UUID.fromString("00000000-0000-0000-0000-000000000001") +private val AI_JOB_ID: UUID = UUID.fromString("00000000-0000-0000-0000-00000000a111") +``` + +Run: + +```bash +./server/gradlew -p server unitTest --tests "com.readmates.admin.audit.application.service.AdminAuditLedgerServiceTest" +``` + +Expected: pass after imports and ktlint formatting are fixed. + +- [ ] **Step 6: Commit Task 1** + +```bash +git add server/src/main/kotlin/com/readmates/admin/audit/application server/src/test/kotlin/com/readmates/admin/audit/application/service/AdminAuditLedgerServiceTest.kt +git commit -m "feat: add admin audit ledger projection service" +``` + +--- + +## Task 2: Backend Persistence, Controller, And Integration Tests + +**Files:** +- Create: `server/src/main/kotlin/com/readmates/admin/audit/adapter/out/persistence/JdbcAdminAuditLedgerAdapter.kt` +- Create: `server/src/main/kotlin/com/readmates/admin/audit/adapter/in/web/PlatformAdminAuditController.kt` +- Create: `server/src/main/kotlin/com/readmates/admin/audit/adapter/in/web/AdminAuditErrorHandler.kt` +- Test: `server/src/test/kotlin/com/readmates/admin/audit/api/PlatformAdminAuditControllerTest.kt` +- Modify if needed: `server/src/test/kotlin/com/readmates/architecture/ServerArchitectureBoundaryTest.kt` + +- [ ] **Step 1: Write the controller integration test** + +Create `server/src/test/kotlin/com/readmates/admin/audit/api/PlatformAdminAuditControllerTest.kt`. + +```kotlin +package com.readmates.admin.audit.api + +import com.readmates.auth.application.service.AuthSessionService +import com.readmates.support.ReadmatesMySqlIntegrationTestSupport +import jakarta.servlet.http.Cookie +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Tag +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc +import org.springframework.http.MediaType +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.get +import java.util.UUID + +@SpringBootTest(properties = ["spring.flyway.locations=classpath:db/mysql/migration,classpath:db/mysql/dev"]) +@AutoConfigureMockMvc +@Tag("integration") +class PlatformAdminAuditControllerTest( + @param:Autowired private val mockMvc: MockMvc, + @param:Autowired private val authSessionService: AuthSessionService, + @param:Autowired private val jdbcTemplate: JdbcTemplate, +) : ReadmatesMySqlIntegrationTestSupport() { + private val createdSessionTokenHashes = linkedSetOf() + + @AfterEach + fun cleanup() { + jdbcTemplate.update("delete from platform_audit_events where id in (?, ?)", PLATFORM_EVENT_ID, SUPPORT_EVENT_ID) + jdbcTemplate.update("delete from club_audit_events where id = ?", CLUB_EVENT_ID) + jdbcTemplate.update("delete from ai_generation_audit_log where job_id = ?", AI_JOB_ID) + jdbcTemplate.update("delete from admin_notification_replay_previews where id = ?", PREVIEW_ID) + if (createdSessionTokenHashes.isNotEmpty()) { + val bindMarks = createdSessionTokenHashes.joinToString(",") { "?" } + jdbcTemplate.update("delete from auth_sessions where session_token_hash in ($bindMarks)", *createdSessionTokenHashes.toTypedArray()) + } + createdSessionTokenHashes.clear() + } + + @Test + fun `owner reads unified audit ledger without raw metadata leakage`() { + seedAuditRows() + + val body = + mockMvc + .get("/api/admin/audit/events?range=7d&limit=10") { + cookie(sessionCookieForUser(OWNER_USER_ID)) + }.andExpect { + status { isOk() } + content { contentTypeCompatibleWith(MediaType.APPLICATION_JSON) } + jsonPath("$.generatedAt") { exists() } + jsonPath("$.items[0].sourceTable") { exists() } + jsonPath("$.items[?(@.actionType == 'ADMIN_NOTIFICATION_REPLAY_CONFIRMED')]") { exists() } + jsonPath("$.items[?(@.actionType == 'SUPPORT_ACCESS_GRANT_CREATED')]") { exists() } + jsonPath("$.items[?(@.actionType == 'AI_GENERATION_AUDIT')]") { exists() } + }.andReturn() + .response + .contentAsString + + assertThat(body).contains("selectionHashPrefix") + assertThat(body).doesNotContain("member1@example.com") + assertThat(body).doesNotContain("SMTP 550") + assertThat(body).doesNotContain("transcript body") + assertThat(body).doesNotContain("\"metadataJson\"") + } + + @Test + fun `support can read ledger but target user id is masked`() { + seedAuditRows() + + val body = + mockMvc + .get("/api/admin/audit/events?sourceSlice=S4") { + cookie(sessionCookieForUser(SUPPORT_USER_ID)) + }.andExpect { + status { isOk() } + jsonPath("$.items[0].target.label") { value("사용자 숨김") } + }.andReturn() + .response + .contentAsString + + assertThat(body).doesNotContain(MEMBER_USER_ID) + } + + @Test + fun `member cannot read admin audit ledger`() { + mockMvc + .get("/api/admin/audit/events") { + cookie(sessionCookieForUser(MEMBER_USER_ID)) + }.andExpect { + status { isForbidden() } + } + } +} +``` + +- [ ] **Step 2: Run the failing integration test** + +Run: + +```bash +./server/gradlew -p server integrationTest --tests "com.readmates.admin.audit.api.PlatformAdminAuditControllerTest" +``` + +Expected: fail because `/api/admin/audit/events` is not mapped. + +- [ ] **Step 3: Add the JDBC adapter** + +Create `server/src/main/kotlin/com/readmates/admin/audit/adapter/out/persistence/JdbcAdminAuditLedgerAdapter.kt`. + +```kotlin +package com.readmates.admin.audit.adapter.out.persistence + +import com.readmates.admin.audit.application.model.AdminAuditFilter +import com.readmates.admin.audit.application.model.AdminAuditSourceRow +import com.readmates.admin.audit.application.model.AdminAuditSourceType +import com.readmates.admin.audit.application.port.out.AdminAuditLedgerReadPort +import com.readmates.shared.db.dbString +import com.readmates.shared.paging.PageRequest +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.stereotype.Repository +import java.sql.ResultSet +import java.time.OffsetDateTime +import java.time.ZoneOffset +import java.util.UUID + +@Repository +class JdbcAdminAuditLedgerAdapter( + private val jdbcTemplate: JdbcTemplate, +) : AdminAuditLedgerReadPort { + override fun listPlatformEvents(filter: AdminAuditFilter, pageRequest: PageRequest): List = + jdbcTemplate.query( + """ + select id, actor_user_id, actor_platform_role, target_user_id, event_type, + cast(metadata_json as char) as metadata_json, created_at + from platform_audit_events + where created_at >= ? and created_at < ? + and (? is null or json_unquote(json_extract(metadata_json, '$.clubId')) = ?) + order by created_at desc, id desc + limit ? + """.trimIndent(), + { rs, _ -> rs.toPlatformRow() }, + filter.from.toSqlTimestamp(), + filter.to.toSqlTimestamp(), + filter.clubId?.dbString(), + filter.clubId?.dbString(), + pageRequest.limit, + ) + + override fun listClubEvents(filter: AdminAuditFilter, pageRequest: PageRequest): List = + jdbcTemplate.query( + """ + select id, actor_user_id, actor_platform_role, club_id, event_type, + cast(metadata_json as char) as metadata_json, created_at + from club_audit_events + where created_at >= ? and created_at < ? + and (? is null or club_id = ?) + order by created_at desc, id desc + limit ? + """.trimIndent(), + { rs, _ -> rs.toClubRow() }, + filter.from.toSqlTimestamp(), + filter.to.toSqlTimestamp(), + filter.clubId?.dbString(), + filter.clubId?.dbString(), + pageRequest.limit, + ) + + override fun listAiGenerationEvents(filter: AdminAuditFilter, pageRequest: PageRequest): List = + jdbcTemplate.query( + """ + select id, job_id, club_id, host_user_id, kind, provider, model, status, error_code, + input_tokens, cached_input_tokens, output_tokens, cost_estimate_usd, latency_ms, created_at + from ai_generation_audit_log + where created_at >= ? and created_at < ? + and (? is null or club_id = ?) + order by created_at desc, id desc + limit ? + """.trimIndent(), + { rs, _ -> rs.toAiRow() }, + filter.from.toSqlTimestamp(), + filter.to.toSqlTimestamp(), + filter.clubId?.dbString(), + filter.clubId?.dbString(), + pageRequest.limit, + ) + + override fun listNotificationReplayPreviews(filter: AdminAuditFilter, pageRequest: PageRequest): List = + jdbcTemplate.query( + """ + select id, actor_user_id, cast(filter_json as char) as filter_json, selection_hash, + matched_count, expires_at, consumed_at, created_at + from admin_notification_replay_previews + where created_at >= ? and created_at < ? + order by created_at desc, id desc + limit ? + """.trimIndent(), + { rs, _ -> rs.toReplayPreviewRow() }, + filter.from.toSqlTimestamp(), + filter.to.toSqlTimestamp(), + pageRequest.limit, + ) + + private fun ResultSet.toPlatformRow(): AdminAuditSourceRow = + AdminAuditSourceRow( + sourceType = AdminAuditSourceType.PLATFORM, + sourceId = getString("id"), + occurredAt = getTimestamp("created_at").toInstant().atOffset(ZoneOffset.UTC), + actorUserId = uuidOrNull("actor_user_id"), + actorRole = getString("actor_platform_role"), + clubId = null, + targetUserId = uuidOrNull("target_user_id"), + actionType = getString("event_type"), + outcomeHint = null, + metadataJson = getString("metadata_json"), + ) + + private fun ResultSet.toClubRow(): AdminAuditSourceRow = + AdminAuditSourceRow( + sourceType = AdminAuditSourceType.CLUB, + sourceId = getString("id"), + occurredAt = getTimestamp("created_at").toInstant().atOffset(ZoneOffset.UTC), + actorUserId = uuidOrNull("actor_user_id"), + actorRole = getString("actor_platform_role"), + clubId = uuidOrNull("club_id"), + targetUserId = null, + actionType = getString("event_type"), + outcomeHint = null, + metadataJson = getString("metadata_json"), + ) +} +``` + +Complete `toAiRow`, `toReplayPreviewRow`, `uuidOrNull`, and `toSqlTimestamp` in the same file: + +```kotlin +private fun ResultSet.toAiRow(): AdminAuditSourceRow { + val jobId = getString("job_id") + val metadataJson = + """ + {"jobId":"$jobId","provider":"${getString("provider")}","model":"${getString("model")}","status":"${getString("status")}","errorCode":${getString("error_code")?.quoteJson()},"inputTokens":${getInt("input_tokens")},"cachedInputTokens":${getInt("cached_input_tokens")},"outputTokens":${getInt("output_tokens")},"costEstimateUsd":"${getBigDecimal("cost_estimate_usd")}","latencyMs":${getInt("latency_ms")}} + """.trimIndent() + return AdminAuditSourceRow( + sourceType = AdminAuditSourceType.AI_GENERATION, + sourceId = getLong("id").toString(), + occurredAt = getTimestamp("created_at").toInstant().atOffset(ZoneOffset.UTC), + actorUserId = uuidOrNull("host_user_id"), + actorRole = "HOST", + clubId = uuidOrNull("club_id"), + targetUserId = null, + actionType = "AI_GENERATION_AUDIT", + outcomeHint = getString("status"), + metadataJson = metadataJson, + ) +} + +private fun ResultSet.toReplayPreviewRow(): AdminAuditSourceRow { + val selectionHash = getString("selection_hash") + val metadataJson = + """ + {"matchedCount":${getInt("matched_count")},"selectionHash":"$selectionHash","expiresAt":"${getTimestamp("expires_at").toInstant().atOffset(ZoneOffset.UTC)}","consumedAt":${getTimestamp("consumed_at")?.toInstant()?.atOffset(ZoneOffset.UTC)?.toString()?.quoteJson()},"filter":${getString("filter_json")}} + """.trimIndent() + return AdminAuditSourceRow( + sourceType = AdminAuditSourceType.NOTIFICATION_REPLAY_PREVIEW, + sourceId = getString("id"), + occurredAt = getTimestamp("created_at").toInstant().atOffset(ZoneOffset.UTC), + actorUserId = uuidOrNull("actor_user_id"), + actorRole = "OWNER", + clubId = null, + targetUserId = null, + actionType = "ADMIN_NOTIFICATION_REPLAY_PREVIEW", + outcomeHint = "PREPARED", + metadataJson = metadataJson, + ) +} + +private fun ResultSet.uuidOrNull(column: String): UUID? = getString(column)?.let { UUID.fromString(it) } +private fun OffsetDateTime.toSqlTimestamp(): java.sql.Timestamp = java.sql.Timestamp.from(toInstant()) +private fun String.quoteJson(): String = "\"${replace("\\", "\\\\").replace("\"", "\\\"")}\"" +``` + +If ktlint rejects the string formatting, replace the inline JSON construction with a small `ObjectMapper` dependency in the adapter. Keep the output keys exactly the same. + +- [ ] **Step 4: Add the controller and error handler** + +Create `server/src/main/kotlin/com/readmates/admin/audit/adapter/in/web/PlatformAdminAuditController.kt`. + +```kotlin +@file:Suppress("ktlint:standard:package-name") + +package com.readmates.admin.audit.adapter.`in`.web + +import com.readmates.admin.audit.application.model.AdminAuditActionCategory +import com.readmates.admin.audit.application.model.AdminAuditActorRole +import com.readmates.admin.audit.application.model.AdminAuditFilter +import com.readmates.admin.audit.application.model.AdminAuditLedgerItem +import com.readmates.admin.audit.application.model.AdminAuditLedgerPage +import com.readmates.admin.audit.application.model.AdminAuditListQuery +import com.readmates.admin.audit.application.model.AdminAuditOutcome +import com.readmates.admin.audit.application.model.AdminAuditSourceSlice +import com.readmates.admin.audit.application.model.AdminAuditTimeRange +import com.readmates.admin.audit.application.port.`in`.ListAdminAuditLedgerUseCase +import com.readmates.shared.paging.PageRequest +import com.readmates.shared.security.CurrentPlatformAdmin +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController +import java.time.OffsetDateTime +import java.time.ZoneOffset +import java.util.UUID + +@RestController +@RequestMapping("/api/admin/audit") +class PlatformAdminAuditController( + private val useCase: ListAdminAuditLedgerUseCase, +) { + @GetMapping("/events") + fun events( + admin: CurrentPlatformAdmin, + @RequestParam(required = false) from: OffsetDateTime?, + @RequestParam(required = false) to: OffsetDateTime?, + @RequestParam(required = false) range: String?, + @RequestParam(required = false) clubId: UUID?, + @RequestParam(required = false) actorRole: AdminAuditActorRole?, + @RequestParam(required = false) sourceSlice: AdminAuditSourceSlice?, + @RequestParam(required = false) actionCategory: AdminAuditActionCategory?, + @RequestParam(required = false) outcome: AdminAuditOutcome?, + @RequestParam(required = false) limit: Int?, + @RequestParam(required = false) cursor: String?, + ): AdminAuditLedgerPageResponse { + val now = OffsetDateTime.now(ZoneOffset.UTC) + val normalizedRange = range?.toTimeRange() + val resolvedTo = (to ?: now).withOffsetSameInstant(ZoneOffset.UTC) + val resolvedFrom = + (from ?: normalizedRange?.from(resolvedTo) ?: resolvedTo.minusDays(7)) + .withOffsetSameInstant(ZoneOffset.UTC) + return useCase + .listLedger( + admin, + AdminAuditListQuery( + filter = + AdminAuditFilter( + from = resolvedFrom, + to = resolvedTo, + range = normalizedRange, + clubId = clubId, + actorRole = actorRole, + sourceSlice = sourceSlice, + actionCategory = actionCategory, + outcome = outcome, + ), + pageRequest = PageRequest.cursor(limit, cursor, defaultLimit = 25, maxLimit = 50), + ), + ).toResponse() + } +} +``` + +Add response DTOs below the controller in the same file. Keep wire names camelCase. + +```kotlin +data class AdminAuditLedgerPageResponse( + val generatedAt: OffsetDateTime, + val filters: Any, + val summary: Any, + val items: List, + val nextCursor: String?, +) + +private fun AdminAuditLedgerPage.toResponse(): AdminAuditLedgerPageResponse = + AdminAuditLedgerPageResponse( + generatedAt = generatedAt, + filters = filters, + summary = summary, + items = items, + nextCursor = nextCursor, + ) + +private fun String.toTimeRange(): AdminAuditTimeRange = + AdminAuditTimeRange.entries.firstOrNull { it.wireValue == this } ?: AdminAuditTimeRange.DAYS_7 + +private fun AdminAuditTimeRange.from(to: OffsetDateTime): OffsetDateTime = + when (this) { + AdminAuditTimeRange.HOURS_24 -> to.minusHours(24) + AdminAuditTimeRange.DAYS_7 -> to.minusDays(7) + AdminAuditTimeRange.DAYS_30 -> to.minusDays(30) + AdminAuditTimeRange.DAYS_90 -> to.minusDays(90) + } +``` + +Create `server/src/main/kotlin/com/readmates/admin/audit/adapter/in/web/AdminAuditErrorHandler.kt`. + +```kotlin +@file:Suppress("ktlint:standard:package-name") + +package com.readmates.admin.audit.adapter.`in`.web + +import com.readmates.admin.audit.application.AdminAuditError +import com.readmates.admin.audit.application.AdminAuditException +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.bind.annotation.RestControllerAdvice + +@RestControllerAdvice(assignableTypes = [PlatformAdminAuditController::class]) +class AdminAuditErrorHandler { + @ExceptionHandler(AdminAuditException::class) + fun handleAdminAuditException(exception: AdminAuditException): ResponseEntity = + ResponseEntity.status(exception.error.toHttpStatus()).build() + + private fun AdminAuditError.toHttpStatus(): HttpStatus = + when (this) { + AdminAuditError.INVALID_FILTER -> HttpStatus.BAD_REQUEST + AdminAuditError.INVALID_CURSOR -> HttpStatus.BAD_REQUEST + } +} +``` + +- [ ] **Step 5: Complete integration test seed helpers** + +Add these helpers to `PlatformAdminAuditControllerTest.kt`. Use existing dev seed UUIDs already present in admin tests. + +```kotlin +private fun seedAuditRows() { + jdbcTemplate.update( + """ + insert into platform_audit_events (id, actor_user_id, actor_platform_role, target_user_id, event_type, metadata_json, created_at) + values (?, ?, 'OWNER', ?, 'ADMIN_NOTIFICATION_REPLAY_CONFIRMED', + json_object('previewId', ?, 'selectionHash', ?, 'reason', 'provider recovered', 'replayedCount', 2, 'skippedCount', 0), + utc_timestamp(6)) + """.trimIndent(), + PLATFORM_EVENT_ID, + OWNER_USER_ID, + MEMBER_USER_ID, + PREVIEW_ID, + "a".repeat(64), + ) + jdbcTemplate.update( + """ + insert into platform_audit_events (id, actor_user_id, actor_platform_role, target_user_id, event_type, metadata_json, created_at) + values (?, ?, 'OWNER', ?, 'SUPPORT_ACCESS_GRANT_CREATED', + json_object('grantId', 'grant-1', 'clubId', ?, 'granteeUserId', ?, 'scope', 'METADATA_READ', 'expiresAt', '2026-05-28T00:00:00Z'), + utc_timestamp(6)) + """.trimIndent(), + SUPPORT_EVENT_ID, + OWNER_USER_ID, + MEMBER_USER_ID, + CLUB_ID, + MEMBER_USER_ID, + ) + jdbcTemplate.update( + """ + insert into club_audit_events (id, actor_user_id, actor_platform_role, club_id, event_type, metadata_json, created_at) + values (?, ?, 'OPERATOR', ?, 'CLUB_STATUS_CHANGED', json_object('reason', 'manual review'), utc_timestamp(6)) + """.trimIndent(), + CLUB_EVENT_ID, + OWNER_USER_ID, + CLUB_ID, + ) + jdbcTemplate.update( + """ + insert into ai_generation_audit_log ( + job_id, session_id, club_id, host_user_id, kind, provider, model, status, error_code, + input_tokens, cached_input_tokens, output_tokens, cost_estimate_usd, latency_ms, created_at + ) + values (?, ?, ?, ?, 'GENERATE', 'openai', 'gpt-safe', 'FAILED', 'PROVIDER_UNAVAILABLE', + 10, 0, 3, 0.0100, 1200, utc_timestamp(6)) + """.trimIndent(), + AI_JOB_ID, + SESSION_ID, + CLUB_ID, + MEMBER_USER_ID, + ) + jdbcTemplate.update( + """ + insert into admin_notification_replay_previews (id, actor_user_id, filter_json, selection_hash, matched_count, expires_at, consumed_at, created_at) + values (?, ?, json_object('deliveryStatus', 'DEAD'), ?, 2, timestampadd(MINUTE, 10, utc_timestamp(6)), null, utc_timestamp(6)) + """.trimIndent(), + PREVIEW_ID, + OWNER_USER_ID, + "a".repeat(64), + ) +} + +private fun sessionCookieForUser(userId: String): Cookie { + val issuedSession = + authSessionService.issueSession( + userId = UUID.fromString(userId).toString(), + userAgent = "PlatformAdminAuditControllerTest", + ipAddress = "127.0.0.1", + ) + createdSessionTokenHashes += issuedSession.storedTokenHash + return Cookie(AuthSessionService.COOKIE_NAME, issuedSession.rawToken) +} + +private const val OWNER_USER_ID = "00000000-0000-0000-0000-000000000901" +private const val SUPPORT_USER_ID = "00000000-0000-0000-0000-000000000903" +private const val MEMBER_USER_ID = "00000000-0000-0000-0000-000000000202" +private const val CLUB_ID = "00000000-0000-0000-0000-000000000001" +private const val SESSION_ID = "00000000-0000-0000-0000-000000000301" +private const val PLATFORM_EVENT_ID = "00000000-0000-0000-0000-000000008101" +private const val SUPPORT_EVENT_ID = "00000000-0000-0000-0000-000000008102" +private const val CLUB_EVENT_ID = "00000000-0000-0000-0000-000000008201" +private const val PREVIEW_ID = "00000000-0000-0000-0000-000000008301" +private const val AI_JOB_ID = "00000000-0000-0000-0000-000000008401" +``` + +- [ ] **Step 6: Run backend checks for Tasks 1 and 2** + +Run: + +```bash +./server/gradlew -p server unitTest --tests "com.readmates.admin.audit.application.service.AdminAuditLedgerServiceTest" +./server/gradlew -p server integrationTest --tests "com.readmates.admin.audit.api.PlatformAdminAuditControllerTest" +./server/gradlew -p server architectureTest +``` + +Expected: all pass. If `architectureTest` flags the new read-side package, add the package to the read-only package allowlist in `ServerArchitectureBoundaryTest` and keep the service annotated with `@ReadOnlyApplicationService`. + +- [ ] **Step 7: Commit Task 2** + +```bash +git add server/src/main/kotlin/com/readmates/admin/audit server/src/test/kotlin/com/readmates/admin/audit server/src/test/kotlin/com/readmates/architecture/ServerArchitectureBoundaryTest.kt +git commit -m "feat: expose admin audit ledger API" +``` + +--- + +## Task 3: Frontend API, Model, Query, And Loader + +**Files:** +- Create: `front/features/platform-admin/model/platform-admin-audit-model.ts` +- Create: `front/features/platform-admin/model/platform-admin-audit-model.test.ts` +- Create: `front/features/platform-admin/api/platform-admin-audit-api.ts` +- Modify: `front/features/platform-admin/api/platform-admin-contracts.ts` +- Create: `front/features/platform-admin/queries/platform-admin-audit-queries.ts` +- Create: `front/features/platform-admin/route/admin-audit-data.ts` + +- [ ] **Step 1: Write model tests for URL/filter normalization and safe labels** + +Create `front/features/platform-admin/model/platform-admin-audit-model.test.ts`. + +```ts +import { describe, expect, it } from "vitest"; +import { + adminAuditFiltersFromSearchParams, + adminAuditSearchFromFilters, + labelAdminAuditOutcome, + shouldShowAdminAuditDetailValue, +} from "./platform-admin-audit-model"; + +describe("platform-admin-audit-model", () => { + it("defaults to 7d range and drops unknown enum values", () => { + const filters = adminAuditFiltersFromSearchParams(new URLSearchParams("range=invalid&sourceSlice=S5&outcome=FAILED")); + + expect(filters).toEqual({ + range: "7d", + sourceSlice: "S5", + outcome: "FAILED", + }); + }); + + it("serializes only meaningful filter values", () => { + const search = adminAuditSearchFromFilters({ range: "30d", clubId: "club-1", actorRole: null, sourceSlice: "S4" }); + + expect(search.toString()).toBe("range=30d&clubId=club-1&sourceSlice=S4"); + }); + + it("labels outcomes for ledger chips", () => { + expect(labelAdminAuditOutcome("SUCCESS")).toBe("성공"); + expect(labelAdminAuditOutcome("PREPARED")).toBe("준비됨"); + }); + + it("suppresses unsafe metadata values in defensive UI helpers", () => { + expect(shouldShowAdminAuditDetailValue("rawJson", "{\"secret\":\"value\"}")).toBe(false); + expect(shouldShowAdminAuditDetailValue("scope", "METADATA_READ")).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run the failing model test** + +Run: + +```bash +pnpm --dir front exec vitest run features/platform-admin/model/platform-admin-audit-model.test.ts +``` + +Expected: fail because the model module does not exist. + +- [ ] **Step 3: Add audit model types and helpers** + +Create `front/features/platform-admin/model/platform-admin-audit-model.ts`. + +```ts +export type AdminAuditRange = "24h" | "7d" | "30d" | "90d"; +export type AdminAuditSourceSlice = "S3" | "S4" | "S5" | "S6" | "PLATFORM" | "CLUB"; +export type AdminAuditActionCategory = "NOTIFICATION" | "SUPPORT" | "CLUB_LIFECYCLE" | "AI_OPS" | "AUTH_SECURITY" | "PLATFORM_ADMIN"; +export type AdminAuditActorRole = "OWNER" | "OPERATOR" | "SUPPORT" | "HOST" | "MEMBER" | "SYSTEM" | "UNKNOWN"; +export type AdminAuditOutcome = "SUCCESS" | "FAILED" | "DENIED" | "PREPARED" | "UNKNOWN"; +export type AdminAuditMetadataState = "AVAILABLE" | "EMPTY" | "UNAVAILABLE"; + +export type AdminAuditFilters = { + range?: AdminAuditRange; + from?: string | null; + to?: string | null; + clubId?: string | null; + actorRole?: AdminAuditActorRole | null; + sourceSlice?: AdminAuditSourceSlice | null; + actionCategory?: AdminAuditActionCategory | null; + outcome?: AdminAuditOutcome | null; + cursor?: string | null; +}; + +export type AdminAuditLedgerPage = { + generatedAt: string; + filters: Record; + summary: { + visibleCount: number; + sourceUnavailableCount: number; + metadataUnavailableCount: number; + unavailableSources: string[]; + }; + items: AdminAuditLedgerItem[]; + nextCursor: string | null; +}; + +export type AdminAuditLedgerItem = { + id: string; + occurredAt: string; + sourceSlice: AdminAuditSourceSlice; + sourceTable: string; + actionCategory: AdminAuditActionCategory; + actionType: string; + outcome: AdminAuditOutcome; + actor: { userId: string | null; role: AdminAuditActorRole; displayLabel: string }; + target: { clubId: string | null; userId: string | null; jobId: string | null; eventId: string | null; label: string }; + summary: string; + safeMetadata: Array<{ label: string; value: string; kind: string }>; + metadataState: AdminAuditMetadataState; +}; + +const RANGES: AdminAuditRange[] = ["24h", "7d", "30d", "90d"]; +const SOURCE_SLICES: AdminAuditSourceSlice[] = ["S3", "S4", "S5", "S6", "PLATFORM", "CLUB"]; +const ACTOR_ROLES: AdminAuditActorRole[] = ["OWNER", "OPERATOR", "SUPPORT", "HOST", "MEMBER", "SYSTEM", "UNKNOWN"]; +const ACTION_CATEGORIES: AdminAuditActionCategory[] = ["NOTIFICATION", "SUPPORT", "CLUB_LIFECYCLE", "AI_OPS", "AUTH_SECURITY", "PLATFORM_ADMIN"]; +const OUTCOMES: AdminAuditOutcome[] = ["SUCCESS", "FAILED", "DENIED", "PREPARED", "UNKNOWN"]; + +export function adminAuditFiltersFromSearchParams(params: URLSearchParams): AdminAuditFilters { + return { + range: enumParam(params.get("range"), RANGES) ?? "7d", + from: params.get("from"), + to: params.get("to"), + clubId: params.get("clubId"), + actorRole: enumParam(params.get("actorRole"), ACTOR_ROLES), + sourceSlice: enumParam(params.get("sourceSlice"), SOURCE_SLICES), + actionCategory: enumParam(params.get("actionCategory"), ACTION_CATEGORIES), + outcome: enumParam(params.get("outcome"), OUTCOMES), + cursor: params.get("cursor"), + }; +} + +export function adminAuditSearchFromFilters(filters: AdminAuditFilters): URLSearchParams { + const params = new URLSearchParams(); + setParam(params, "range", filters.range); + setParam(params, "from", filters.from); + setParam(params, "to", filters.to); + setParam(params, "clubId", filters.clubId); + setParam(params, "actorRole", filters.actorRole); + setParam(params, "sourceSlice", filters.sourceSlice); + setParam(params, "actionCategory", filters.actionCategory); + setParam(params, "outcome", filters.outcome); + setParam(params, "cursor", filters.cursor); + return params; +} + +export function labelAdminAuditOutcome(outcome: AdminAuditOutcome): string { + return { + SUCCESS: "성공", + FAILED: "실패", + DENIED: "거부", + PREPARED: "준비됨", + UNKNOWN: "알 수 없음", + }[outcome]; +} + +export function shouldShowAdminAuditDetailValue(label: string, value: string): boolean { + if (label.toLowerCase().includes("raw")) return false; + if (value.includes("{") || value.includes("}")) return false; + return true; +} + +function enumParam(value: string | null, allowed: readonly T[]): T | null { + return value && allowed.includes(value as T) ? (value as T) : null; +} + +function setParam(params: URLSearchParams, key: string, value: string | null | undefined) { + if (value) params.set(key, value); +} +``` + +- [ ] **Step 4: Add API and Query modules** + +Create `front/features/platform-admin/api/platform-admin-audit-api.ts`. + +```ts +import { readmatesFetch } from "@/shared/api/client"; +import type { AdminAuditFilters, AdminAuditLedgerPage } from "@/features/platform-admin/model/platform-admin-audit-model"; + +export function fetchAdminAuditLedger(filters: AdminAuditFilters = {}) { + return readmatesFetch( + `/api/admin/audit/events${adminAuditSearch(filters)}`, + undefined, + { clubSlug: undefined }, + ); +} + +function adminAuditSearch(filters: AdminAuditFilters): string { + const params = new URLSearchParams(); + if (filters.range) params.set("range", filters.range); + if (filters.from) params.set("from", filters.from); + if (filters.to) params.set("to", filters.to); + if (filters.clubId) params.set("clubId", filters.clubId); + if (filters.actorRole) params.set("actorRole", filters.actorRole); + if (filters.sourceSlice) params.set("sourceSlice", filters.sourceSlice); + if (filters.actionCategory) params.set("actionCategory", filters.actionCategory); + if (filters.outcome) params.set("outcome", filters.outcome); + if (filters.cursor) params.set("cursor", filters.cursor); + const search = params.toString(); + return search ? `?${search}` : ""; +} +``` + +Create `front/features/platform-admin/queries/platform-admin-audit-queries.ts`. + +```ts +import { queryOptions } from "@tanstack/react-query"; +import { fetchAdminAuditLedger } from "@/features/platform-admin/api/platform-admin-audit-api"; +import type { AdminAuditFilters } from "@/features/platform-admin/model/platform-admin-audit-model"; + +function normalizeFilters(filters: AdminAuditFilters = {}) { + return { + range: filters.range ?? "7d", + from: filters.from ?? null, + to: filters.to ?? null, + clubId: filters.clubId ?? null, + actorRole: filters.actorRole ?? null, + sourceSlice: filters.sourceSlice ?? null, + actionCategory: filters.actionCategory ?? null, + outcome: filters.outcome ?? null, + cursor: filters.cursor ?? null, + }; +} + +export const platformAdminAuditKeys = { + all: ["platform-admin", "audit"] as const, + ledger: (filters?: AdminAuditFilters) => [...platformAdminAuditKeys.all, "ledger", normalizeFilters(filters)] as const, +} as const; + +export function platformAdminAuditLedgerQuery(filters?: AdminAuditFilters) { + return queryOptions({ + queryKey: platformAdminAuditKeys.ledger(filters), + queryFn: () => fetchAdminAuditLedger(filters), + }); +} +``` + +Modify `front/features/platform-admin/api/platform-admin-contracts.ts`: + +```ts +export type { + AdminAuditFilters, + AdminAuditLedgerItem, + AdminAuditLedgerPage, +} from "@/features/platform-admin/model/platform-admin-audit-model"; +``` + +- [ ] **Step 5: Add loader factory and run frontend model/query tests** + +Create `front/features/platform-admin/route/admin-audit-data.ts`. + +```ts +import type { QueryClient } from "@tanstack/react-query"; +import { platformAdminAuditLedgerQuery } from "@/features/platform-admin/queries/platform-admin-audit-queries"; + +export function adminAuditLoaderFactory(queryClient: QueryClient) { + return async function loadAdminAudit() { + await queryClient.fetchQuery(platformAdminAuditLedgerQuery({ range: "7d" })); + return null; + }; +} +``` + +Run: + +```bash +pnpm --dir front exec vitest run features/platform-admin/model/platform-admin-audit-model.test.ts +``` + +Expected: pass. + +- [ ] **Step 6: Commit Task 3** + +```bash +git add front/features/platform-admin/model/platform-admin-audit-model.ts front/features/platform-admin/model/platform-admin-audit-model.test.ts front/features/platform-admin/api/platform-admin-audit-api.ts front/features/platform-admin/api/platform-admin-contracts.ts front/features/platform-admin/queries/platform-admin-audit-queries.ts front/features/platform-admin/route/admin-audit-data.ts +git commit -m "feat: add admin audit frontend data layer" +``` + +--- + +## Task 4: Frontend Route, UI, Styling, And E2E + +**Files:** +- Create: `front/features/platform-admin/route/admin-audit-route.tsx` +- Create: `front/features/platform-admin/route/admin-audit-route.test.tsx` +- Create: `front/features/platform-admin/ui/admin-audit-ledger.tsx` +- Create: `front/features/platform-admin/ui/admin-audit-ledger.test.tsx` +- Modify: `front/features/platform-admin/model/admin-route-catalog.ts` +- Modify: `front/features/platform-admin/model/admin-route-catalog.test.ts` +- Modify: `front/features/platform-admin/ui/admin-layout-nav.test.tsx` +- Modify: `front/src/app/routes/admin.tsx` +- Modify: `front/src/styles/globals.css` +- Create: `front/tests/e2e/admin-audit.spec.ts` +- Modify: `front/tests/e2e/admin-shell.spec.ts` + +- [ ] **Step 1: Write UI and route tests** + +Create `front/features/platform-admin/ui/admin-audit-ledger.test.tsx`. + +```tsx +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { AdminAuditLedger } from "./admin-audit-ledger"; +import type { AdminAuditLedgerPage } from "@/features/platform-admin/model/platform-admin-audit-model"; + +const page: AdminAuditLedgerPage = { + generatedAt: "2026-05-27T00:00:00Z", + filters: {}, + summary: { visibleCount: 2, sourceUnavailableCount: 0, metadataUnavailableCount: 0, unavailableSources: [] }, + nextCursor: "cursor-1", + items: [ + { + id: "platform_audit_events:event-1", + occurredAt: "2026-05-27T00:01:00Z", + sourceSlice: "S5", + sourceTable: "platform_audit_events", + actionCategory: "NOTIFICATION", + actionType: "ADMIN_NOTIFICATION_REPLAY_CONFIRMED", + outcome: "SUCCESS", + actor: { userId: "admin-1", role: "OWNER", displayLabel: "OWNER" }, + target: { clubId: "club-1", userId: null, jobId: null, eventId: "preview-1", label: "Replay preview" }, + summary: "알림 재처리가 확정되었습니다.", + safeMetadata: [{ label: "selectionHashPrefix", value: "aaaaaaaa", kind: "fingerprint" }], + metadataState: "AVAILABLE", + }, + { + id: "platform_audit_events:event-2", + occurredAt: "2026-05-27T00:00:00Z", + sourceSlice: "S4", + sourceTable: "platform_audit_events", + actionCategory: "SUPPORT", + actionType: "SUPPORT_ACCESS_GRANT_CREATED", + outcome: "SUCCESS", + actor: { userId: "admin-1", role: "OWNER", displayLabel: "OWNER" }, + target: { clubId: "club-1", userId: null, jobId: null, eventId: null, label: "사용자 숨김" }, + summary: "support grant가 생성되었습니다.", + safeMetadata: [{ label: "scope", value: "METADATA_READ", kind: "code" }], + metadataState: "AVAILABLE", + }, + ], +}; + +describe("AdminAuditLedger", () => { + it("renders ledger rows and safe metadata detail", async () => { + const user = userEvent.setup(); + render(); + + expect(screen.getByRole("heading", { name: "감사" })).toBeInTheDocument(); + expect(screen.getByText("알림 재처리가 확정되었습니다.")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: /알림 재처리가 확정되었습니다/ })); + + const detail = screen.getByRole("region", { name: "감사 이벤트 상세" }); + expect(within(detail).getByText("selectionHashPrefix")).toBeInTheDocument(); + expect(detail.textContent).not.toContain("{"); + }); + + it("shows partial source unavailable state", () => { + render( + , + ); + + expect(screen.getByRole("status")).toHaveTextContent("일부 감사 source를 불러오지 못했습니다."); + }); +}); +``` + +Create `front/features/platform-admin/route/admin-audit-route.test.tsx`. + +```tsx +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { describe, expect, it } from "vitest"; +import { platformAdminAuditLedgerQuery } from "@/features/platform-admin/queries/platform-admin-audit-queries"; +import { AdminAuditRoute } from "./admin-audit-route"; + +function renderRoute(initialEntry = "/admin/audit?sourceSlice=S5") { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }); + queryClient.setQueryData(platformAdminAuditLedgerQuery({ range: "7d", sourceSlice: "S5" }).queryKey, { + generatedAt: "2026-05-27T00:00:00Z", + filters: {}, + summary: { visibleCount: 1, sourceUnavailableCount: 0, metadataUnavailableCount: 0, unavailableSources: [] }, + nextCursor: null, + items: [ + { + id: "platform_audit_events:event-1", + occurredAt: "2026-05-27T00:01:00Z", + sourceSlice: "S5", + sourceTable: "platform_audit_events", + actionCategory: "NOTIFICATION", + actionType: "ADMIN_NOTIFICATION_REPLAY_CONFIRMED", + outcome: "SUCCESS", + actor: { userId: "admin-1", role: "OWNER", displayLabel: "OWNER" }, + target: { clubId: "club-1", userId: null, jobId: null, eventId: "preview-1", label: "Replay preview" }, + summary: "알림 재처리가 확정되었습니다.", + safeMetadata: [{ label: "selectionHashPrefix", value: "aaaaaaaa", kind: "fingerprint" }], + metadataState: "AVAILABLE", + }, + ], + }); + + return render( + + + + + , + ); +} + +describe("AdminAuditRoute", () => { + it("renders cached audit ledger rows from URL filters", () => { + renderRoute(); + + expect(screen.getByRole("heading", { name: "감사" })).toBeInTheDocument(); + expect(screen.getByText("알림 재처리가 확정되었습니다.")).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 2: Run failing route/UI tests** + +Run: + +```bash +pnpm --dir front exec vitest run features/platform-admin/route/admin-audit-route.test.tsx features/platform-admin/ui/admin-audit-ledger.test.tsx +``` + +Expected: fail because the route and UI modules do not exist. + +- [ ] **Step 3: Add UI component** + +Create `front/features/platform-admin/ui/admin-audit-ledger.tsx`. + +```tsx +import { useState } from "react"; +import type { AdminAuditFilters, AdminAuditLedgerItem, AdminAuditLedgerPage } from "@/features/platform-admin/model/platform-admin-audit-model"; +import { labelAdminAuditOutcome, shouldShowAdminAuditDetailValue } from "@/features/platform-admin/model/platform-admin-audit-model"; + +export type AdminAuditLedgerProps = { + page: AdminAuditLedgerPage | null; + filters: AdminAuditFilters; + loading: boolean; + error: string | null; + onFilterChange: (filters: AdminAuditFilters) => void; + onLoadMore: () => void; +}; + +export function AdminAuditLedger({ page, filters, loading, error, onFilterChange, onLoadMore }: AdminAuditLedgerProps) { + const [selectedId, setSelectedId] = useState(page?.items[0]?.id ?? null); + const selected = page?.items.find((item) => item.id === selectedId) ?? page?.items[0] ?? null; + + return ( +
+
+
+

S7 Review

+

감사

+
+

범위 {filters.range ?? "7d"} · {page?.summary.visibleCount ?? 0}건

+
+ +
+ {(["24h", "7d", "30d", "90d"] as const).map((range) => ( + + ))} +
+ + {error ?

{error}

: null} + {page && page.summary.sourceUnavailableCount > 0 ? ( +

일부 감사 source를 불러오지 못했습니다. {page.summary.unavailableSources.join(", ")}

+ ) : null} + {loading ?

감사 ledger를 불러오는 중입니다.

: null} + +
+
+ {page && page.items.length > 0 ? ( + page.items.map((item) => ( + + )) + ) : ( +

선택한 조건에 해당하는 감사 이벤트가 없습니다.

+ )} + {page?.nextCursor ? ( + + ) : null} +
+ +
+
+ ); +} + +function AuditDetail({ item }: { item: AdminAuditLedgerItem | null }) { + if (!item) { + return ; + } + return ( + + ); +} + +function formatTimestamp(value: string) { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return date.toLocaleString("ko-KR", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" }); +} +``` + +- [ ] **Step 4: Add route module and route wiring** + +Create `front/features/platform-admin/route/admin-audit-route.tsx`. + +```tsx +import { useMemo, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useSearchParams } from "react-router-dom"; +import { + adminAuditFiltersFromSearchParams, + adminAuditSearchFromFilters, + type AdminAuditFilters, +} from "@/features/platform-admin/model/platform-admin-audit-model"; +import { platformAdminAuditLedgerQuery } from "@/features/platform-admin/queries/platform-admin-audit-queries"; +import { AdminAuditLedger } from "@/features/platform-admin/ui/admin-audit-ledger"; + +const GENERIC_ERROR = "감사 ledger를 처리하지 못했습니다. 다시 시도해 주세요."; + +export function AdminAuditRoute() { + const [searchParams, setSearchParams] = useSearchParams(); + const filters = useMemo(() => adminAuditFiltersFromSearchParams(searchParams), [searchParams]); + const [cursor, setCursor] = useState(filters.cursor ?? null); + const query = useQuery(platformAdminAuditLedgerQuery({ ...filters, cursor })); + + function changeFilters(next: AdminAuditFilters) { + setCursor(null); + setSearchParams(adminAuditSearchFromFilters({ ...next, cursor: null })); + } + + function loadMore() { + if (query.data?.nextCursor) setCursor(query.data.nextCursor); + } + + return ( + + ); +} +``` + +Modify `front/src/app/routes/admin.tsx` ready switch: + +```tsx + case "audit": + return { + path: "audit", + hydrateFallbackElement: adminChildHydrateFallback, + lazy: async () => { + const [{ AdminAuditRoute }, { adminAuditLoaderFactory }] = await Promise.all([ + import("@/features/platform-admin/route/admin-audit-route"), + import("@/features/platform-admin/route/admin-audit-data"), + ]); + return { Component: AdminAuditRoute, loader: adminAuditLoaderFactory(queryClient) }; + }, + }; +``` + +Modify `front/features/platform-admin/model/admin-route-catalog.ts` so `audit` is ready: + +```ts + { + path: "audit", + label: "감사", + group: "review", + groupLabel: "감사/분석", + slice: "S7", + status: "ready", + requiredCapability: "view_audit", + }, +``` + +Update `admin-route-catalog.test.ts` ready-route expectation to include `"audit"` and keep `"analytics"` as the only coming-soon review route. + +- [ ] **Step 5: Add CSS** + +Append focused styles to `front/src/styles/globals.css`. + +```css +.admin-audit { + display: grid; + gap: 1rem; +} + +.admin-audit__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; +} + +.admin-audit__timestamp, +.admin-audit__slice { + color: var(--color-muted); + font-size: 0.875rem; +} + +.admin-audit__filters { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.admin-audit__partial, +.admin-audit__error, +.admin-audit__loading { + margin: 0; +} + +.admin-audit__partial { + color: var(--color-ink); +} + +.admin-audit__error { + color: var(--color-danger, #9b1c1c); +} + +.admin-audit__body { + display: grid; + grid-template-columns: minmax(0, 1.4fr) minmax(18rem, 0.8fr); + gap: 1rem; + align-items: start; +} + +.admin-audit__rows, +.admin-audit__detail { + border: 1px solid var(--color-line); + background: var(--color-surface); +} + +.admin-audit__rows { + display: grid; +} + +.admin-audit__row { + display: grid; + grid-template-columns: 7rem minmax(0, 1fr) auto auto; + gap: 0.75rem; + align-items: center; + width: 100%; + padding: 0.875rem 1rem; + border: 0; + border-bottom: 1px solid var(--color-line); + background: transparent; + color: inherit; + text-align: left; +} + +.admin-audit__row:hover, +.admin-audit__row:focus-visible { + background: var(--color-paper); +} + +.admin-audit__row-main { + min-width: 0; +} + +.admin-audit__detail { + padding: 1rem; + position: sticky; + top: 1rem; +} + +.admin-audit__metadata { + display: grid; + gap: 0.625rem; +} + +.admin-audit__metadata div { + display: grid; + grid-template-columns: minmax(7rem, 0.4fr) minmax(0, 1fr); + gap: 0.75rem; +} + +.admin-audit__metadata dt { + color: var(--color-muted); +} + +.admin-audit__metadata dd { + margin: 0; + overflow-wrap: anywhere; +} + +@media (max-width: 760px) { + .admin-audit__header, + .admin-audit__body { + grid-template-columns: 1fr; + } + + .admin-audit__header { + display: grid; + } + + .admin-audit__row { + grid-template-columns: 1fr; + } + + .admin-audit__detail { + position: static; + } +} +``` + +If exact CSS variables differ, use existing nearby admin variables from `globals.css`; keep the class names and responsive structure. + +- [ ] **Step 6: Add Playwright E2E and update shell E2E** + +Create `front/tests/e2e/admin-audit.spec.ts`. + +```ts +import { expect, test, type Page, type Route } from "@playwright/test"; +import type { AuthMeResponse } from "@/shared/auth/auth-contracts"; +import type { PlatformAdminRole } from "@/features/platform-admin/api/platform-admin-contracts"; + +function platformAdminAuth(role: PlatformAdminRole): AuthMeResponse { + const email = `${role.toLowerCase()}@example.com`; + return { + authenticated: true, + userId: `platform-${role.toLowerCase()}-user`, + membershipId: null, + clubId: null, + email, + displayName: `${role} admin`, + accountName: `${role} admin`, + role: null, + membershipStatus: null, + approvalState: "INACTIVE", + currentMembership: null, + joinedClubs: [], + platformAdmin: { userId: `platform-${role.toLowerCase()}-user`, email, role }, + recommendedAppEntryUrl: "/admin", + }; +} + +async function json(route: Route, status: number, body: unknown): Promise { + await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) }); +} + +async function routePlatformAdminShell(page: Page, role: PlatformAdminRole): Promise { + await page.route("**/api/bff/api/auth/me**", async (route) => { + await json(route, 200, platformAdminAuth(role)); + }); + await page.route("**/api/bff/api/admin/summary", async (route) => { + await json(route, 200, { + platformRole: role, + activeClubCount: 1, + domainActionRequiredCount: 0, + domains: [], + domainsRequiringAction: [], + }); + }); + await page.route("**/api/bff/api/admin/clubs", async (route) => { + await json(route, 200, { items: [] }); + }); +} + +async function routeAudit(page: Page): Promise { + await page.route("**/api/bff/api/admin/audit/events**", async (route) => { + await json(route, 200, { + generatedAt: "2026-05-27T00:00:00Z", + filters: { range: "7d" }, + summary: { visibleCount: 2, sourceUnavailableCount: 0, metadataUnavailableCount: 0, unavailableSources: [] }, + nextCursor: null, + items: [ + { + id: "platform_audit_events:event-1", + occurredAt: "2026-05-27T00:01:00Z", + sourceSlice: "S5", + sourceTable: "platform_audit_events", + actionCategory: "NOTIFICATION", + actionType: "ADMIN_NOTIFICATION_REPLAY_CONFIRMED", + outcome: "SUCCESS", + actor: { userId: "platform-owner-user", role: "OWNER", displayLabel: "OWNER" }, + target: { clubId: "club-1", userId: null, jobId: null, eventId: "preview-1", label: "Replay preview" }, + summary: "알림 재처리가 확정되었습니다.", + safeMetadata: [{ label: "selectionHashPrefix", value: "aaaaaaaa", kind: "fingerprint" }], + metadataState: "AVAILABLE", + }, + { + id: "platform_audit_events:event-2", + occurredAt: "2026-05-27T00:00:00Z", + sourceSlice: "S4", + sourceTable: "platform_audit_events", + actionCategory: "SUPPORT", + actionType: "SUPPORT_ACCESS_GRANT_CREATED", + outcome: "SUCCESS", + actor: { userId: "platform-owner-user", role: "OWNER", displayLabel: "OWNER" }, + target: { clubId: "club-1", userId: null, jobId: null, eventId: null, label: "사용자 숨김" }, + summary: "support grant가 생성되었습니다.", + safeMetadata: [{ label: "scope", value: "METADATA_READ", kind: "code" }], + metadataState: "AVAILABLE", + }, + ], + }); + }); +} + +test("owner reviews admin audit ledger without raw private fields", async ({ page }) => { + await routePlatformAdminShell(page, "OWNER"); + await routeAudit(page); + + await page.goto("/admin/audit"); + + await expect(page.getByRole("heading", { name: "감사" })).toBeVisible(); + await expect(page.getByText("알림 재처리가 확정되었습니다.")).toBeVisible(); + await expect(page.getByText("support grant가 생성되었습니다.")).toBeVisible(); + await expect(page.getByText("member1@example.com")).toHaveCount(0); + await expect(page.getByText("{\"")).toHaveCount(0); +}); +``` + +Modify `front/tests/e2e/admin-shell.spec.ts` by replacing the coming-soon audit test with an analytics coming-soon test: + +```ts +test("analytics coming-soon route renders the slice descriptor", async ({ page }) => { + await loginWithDevShortcut(page, "플랫폼 관리자 · OWNER"); + await page.goto("/admin/analytics"); + await expect(page.getByLabel("분석/리포팅 lite").getByText(/준비 중 · S8/)).toBeVisible(); + await expect(page.getByRole("heading", { name: "분석/리포팅 lite" })).toBeVisible(); +}); +``` + +- [ ] **Step 7: Run frontend targeted checks** + +Run: + +```bash +pnpm --dir front exec vitest run features/platform-admin/model/platform-admin-audit-model.test.ts features/platform-admin/model/admin-route-catalog.test.ts features/platform-admin/ui/admin-layout-nav.test.tsx features/platform-admin/route/admin-audit-route.test.tsx features/platform-admin/ui/admin-audit-ledger.test.tsx +pnpm --dir front exec playwright test tests/e2e/admin-audit.spec.ts tests/e2e/admin-shell.spec.ts --project=chromium +``` + +Expected: pass. + +- [ ] **Step 8: Commit Task 4** + +```bash +git add front/features/platform-admin/model front/features/platform-admin/api front/features/platform-admin/queries front/features/platform-admin/route front/features/platform-admin/ui front/src/app/routes/admin.tsx front/src/styles/globals.css front/tests/e2e/admin-audit.spec.ts front/tests/e2e/admin-shell.spec.ts +git commit -m "feat: ship admin audit ledger route" +``` + +--- + +## Task 5: Metadata Hardening, Docs, And Verification Closeout + +**Files:** +- Modify as needed: `server/src/main/kotlin/com/readmates/notification/application/service/AdminNotificationOperationsService.kt` +- Modify as needed: `server/src/main/kotlin/com/readmates/club/application/service/SupportAccessGrantService.kt` +- Modify: `CHANGELOG.md` +- Modify: `docs/development/architecture.md` +- Modify: `docs/development/server-state-migration.md` +- Modify tests touched by metadata changes. + +- [ ] **Step 1: Add metadata regression tests only if Task 2 reveals weak source rows** + +Inspect the JSON produced by `/api/admin/audit/events` in `PlatformAdminAuditControllerTest`. If S5/S4 rows already project useful `safeMetadata`, skip production metadata write changes and keep this task docs-only. If projection lacks required fields, add regression assertions first. + +For S5 replay, extend `AdminNotificationOperationsServiceTest` with: + +```kotlin +assertThat(event.metadataJson).contains("\"previewId\":\"$PREVIEW_ID\"") +assertThat(event.metadataJson).contains("\"selectionHash\":\"$SELECTION_HASH\"") +assertThat(event.metadataJson).contains("\"reason\":\"Retry failed deliveries\"") +assertThat(event.metadataJson).contains("\"replayedCount\":2") +assertThat(event.metadataJson).contains("\"skippedCount\":1") +``` + +For S4 support grant, extend `SupportAccessGrantServiceTest` with: + +```kotlin +assertThat(audit.metadataJson).contains("\"grantId\"") +assertThat(audit.metadataJson).contains("\"clubId\"") +assertThat(audit.metadataJson).contains("\"granteeUserId\"") +assertThat(audit.metadataJson).contains("\"scope\":\"METADATA_READ\"") +assertThat(audit.metadataJson).contains("\"expiresAt\"") +``` + +Run the targeted tests: + +```bash +./server/gradlew -p server unitTest --tests "com.readmates.notification.application.service.AdminNotificationOperationsServiceTest" --tests "com.readmates.club.application.service.SupportAccessGrantServiceTest" +``` + +Expected: pass before and after any additive metadata tweaks. + +- [ ] **Step 2: Update architecture docs after behavior ships** + +Modify `docs/development/architecture.md` platform-admin row to include `/admin/audit` and add a short paragraph near the existing platform-admin notification section: + +```markdown +Platform admin 감사 ledger는 `/api/admin/audit/events`와 `/admin/audit`에서 기존 `platform_audit_events`, `club_audit_events`, `ai_generation_audit_log`, `admin_notification_replay_previews`를 읽기 전용 cursor ledger로 통합합니다. Source별 allowlist projection만 응답하며 raw metadata JSON, provider raw error, email body, transcript, generated result JSON은 노출하지 않습니다. S8 analytics와 호환되도록 date range, club scope, source slice, action category, actor role, outcome 필터 이름을 고정합니다. +``` + +Modify `docs/development/server-state-migration.md` by adding: + +```markdown +11. `platform-admin/audit` — 통합 감사 ledger를 loader-seeded Query read model로 분리합니다. Filter URL state는 S8 analytics가 재사용할 date range, club scope, source slice, action category, actor role, outcome vocabulary를 따릅니다. +``` + +Move it to the completed list: + +```markdown +- `platform-admin/audit` — platform/club/notification replay/AI audit source를 Query-owned cursor ledger로 조회하고, route loader seeding과 safe metadata detail rendering을 적용합니다. +``` + +- [ ] **Step 3: Update CHANGELOG** + +Under `## Unreleased`, add one Engineering bullet near existing platform-admin bullets: + +```markdown +- **platform-admin:** ship `/admin/audit` as a read-only operating ledger over platform, club, notification replay, and AI audit sources. The route uses safe metadata projection, role-aware masking, cursor pagination, and S8-compatible filter vocabulary without exposing raw provider errors, email bodies, transcripts, or generated result JSON. +``` + +- [ ] **Step 4: Run integrated verification** + +Run the smallest full surface that can regress: + +```bash +pnpm --dir front lint +pnpm --dir front test +pnpm --dir front build +./server/gradlew -p server unitTest --tests "com.readmates.admin.audit.application.service.AdminAuditLedgerServiceTest" --tests "com.readmates.notification.application.service.AdminNotificationOperationsServiceTest" --tests "com.readmates.club.application.service.SupportAccessGrantServiceTest" +./server/gradlew -p server integrationTest --tests "com.readmates.admin.audit.api.PlatformAdminAuditControllerTest" +./server/gradlew -p server architectureTest +git diff --check +``` + +Run E2E because this changes a routed admin user flow: + +```bash +pnpm --dir front exec playwright test tests/e2e/admin-audit.spec.ts tests/e2e/admin-shell.spec.ts --project=chromium +``` + +For public safety, run a targeted scan over changed docs, frontend fixtures, and backend tests: + +```bash +rg -n 'member1@example.com|SMTP 550|transcript body|generated result|sk-[A-Za-z0-9]|ocid1\\.|/Users/' CHANGELOG.md docs/development/architecture.md docs/development/server-state-migration.md front/tests/e2e/admin-audit.spec.ts server/src/test/kotlin/com/readmates/admin/audit +``` + +Expected: no matches except intentional negative assertions in tests. If negative assertions match, confirm they appear only inside `doesNotContain`, `toHaveCount(0)`, or seeded unsafe strings used to prove redaction. + +- [ ] **Step 5: Refresh Graphify after code changes** + +Run: + +```bash +graphify update . +``` + +Expected: graph refresh completes. Do not commit `graphify-out/` raw output if it remains ignored. + +- [ ] **Step 6: Commit Task 5** + +```bash +git add CHANGELOG.md docs/development/architecture.md docs/development/server-state-migration.md server/src/main/kotlin/com/readmates/notification/application/service/AdminNotificationOperationsService.kt server/src/main/kotlin/com/readmates/club/application/service/SupportAccessGrantService.kt server/src/test/kotlin/com/readmates/notification/application/service/AdminNotificationOperationsServiceTest.kt server/src/test/kotlin/com/readmates/club/application/service/SupportAccessGrantServiceTest.kt +git commit -m "docs: document admin audit ledger release" +``` + +If no production metadata hardening files changed, stage only the docs and any verification-related test updates: + +```bash +git add CHANGELOG.md docs/development/architecture.md docs/development/server-state-migration.md +git commit -m "docs: document admin audit ledger release" +``` + +--- + +## Final Verification Checklist + +Before handing back: + +- [ ] `git status --short --branch` shows only expected generated or ignored files. +- [ ] `git diff --check` passes. +- [ ] Frontend lint/test/build pass or skipped commands are named with reason. +- [ ] Backend targeted unit/integration/architecture tests pass or skipped commands are named with reason. +- [ ] Playwright admin audit route test passes or skipped command is named with reason. +- [ ] Public-safety scan finds no new unsafe fixture/doc leaks. +- [ ] `CHANGELOG.md`, `docs/development/architecture.md`, and `docs/development/server-state-migration.md` match shipped behavior. +- [ ] No raw `metadata_json`, email body, raw provider error, transcript, generated result JSON, token, private domain, deployment identifier, or local path is exposed in UI/API fixtures. + +## Execution Options + +Plan complete and saved to `docs/superpowers/plans/2026-05-27-readmates-admin-vnext-audit-ledger.md`. Two execution options: + +1. **Subagent-Driven (recommended)** - Dispatch a fresh subagent per task, review between tasks, fast iteration. +2. **Inline Execution** - Execute tasks in this session using executing-plans, batch execution with checkpoints. diff --git a/docs/superpowers/plans/2026-05-27-readmates-admin-vnext-operating-console-expansion.md b/docs/superpowers/plans/2026-05-27-readmates-admin-vnext-operating-console-expansion.md new file mode 100644 index 00000000..6664c6f3 --- /dev/null +++ b/docs/superpowers/plans/2026-05-27-readmates-admin-vnext-operating-console-expansion.md @@ -0,0 +1,1968 @@ +# ReadMates Admin vNext Operating Console Expansion Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +```yaml waygent-task +id: phase_1_s5_backend_read_models +title: Phase 1A — Add admin notification/outbox read contracts, ports, persistence adapter, and service. Do not create git commits from the task worktree. +dependencies: [] +file_claims: + - path: server/src/main/kotlin/com/readmates/notification/application/model/AdminNotificationOperationsModels.kt + mode: owned + - path: server/src/main/kotlin/com/readmates/notification/application/port/in/NotificationUseCases.kt + mode: modify + - path: server/src/main/kotlin/com/readmates/notification/application/port/out/AdminNotificationOperationsPorts.kt + mode: owned + - path: server/src/main/kotlin/com/readmates/notification/application/service/AdminNotificationOperationsService.kt + mode: owned + - path: server/src/main/kotlin/com/readmates/notification/adapter/out/persistence/JdbcAdminNotificationOperationsAdapter.kt + mode: owned + - path: server/src/test/kotlin/com/readmates/notification/application/service/AdminNotificationOperationsServiceTest.kt + mode: owned + - path: server/src/test/kotlin/com/readmates/notification/adapter/out/persistence/JdbcAdminNotificationOperationsAdapterTest.kt + mode: owned +risk: high +verify_isolation: medium +verify: + - ./server/gradlew -p server unitTest --tests "com.readmates.notification.application.service.AdminNotificationOperationsServiceTest" + - ./server/gradlew -p server integrationTest --tests "com.readmates.notification.adapter.out.persistence.JdbcAdminNotificationOperationsAdapterTest" +instructions: + - Implement Tasks 1 and 2 from the plan body. + - Keep response models masked and public-safe. + - Do not execute git add or git commit from the task worktree. +``` + +```yaml waygent-task +id: phase_1_s5_backend_replay_controller +title: Phase 1B — Add two-step admin replay, audit-ready records, controller DTOs, and controller tests. Do not create git commits from the task worktree. +dependencies: [phase_1_s5_backend_read_models] +file_claims: + - path: server/src/main/resources/db/mysql/migration/V35__admin_notification_replay_previews.sql + mode: owned + - path: server/src/main/kotlin/com/readmates/notification/application/model/AdminNotificationOperationsModels.kt + mode: modify + - path: server/src/main/kotlin/com/readmates/notification/application/port/out/AdminNotificationOperationsPorts.kt + mode: modify + - path: server/src/main/kotlin/com/readmates/notification/application/service/AdminNotificationOperationsService.kt + mode: modify + - path: server/src/main/kotlin/com/readmates/notification/adapter/out/persistence/JdbcAdminNotificationOperationsAdapter.kt + mode: modify + - path: server/src/main/kotlin/com/readmates/notification/adapter/in/web/PlatformAdminNotificationController.kt + mode: owned + - path: server/src/main/kotlin/com/readmates/notification/adapter/in/web/PlatformAdminNotificationWebDtos.kt + mode: owned + - path: server/src/test/kotlin/com/readmates/notification/api/PlatformAdminNotificationControllerTest.kt + mode: owned + - path: server/src/test/kotlin/com/readmates/notification/application/service/AdminNotificationOperationsServiceTest.kt + mode: modify +risk: high +verify_isolation: medium +verify: + - ./server/gradlew -p server unitTest --tests "com.readmates.notification.application.service.AdminNotificationOperationsServiceTest" + - ./server/gradlew -p server integrationTest --tests "com.readmates.notification.api.PlatformAdminNotificationControllerTest" +instructions: + - Implement Tasks 3 and 4 from the plan body. + - Replay must remain preview-then-confirm. + - Do not execute git add or git commit from the task worktree. +``` + +```yaml waygent-task +id: phase_1_s5_frontend_route +title: Phase 1C — Flip /admin/notifications to READY with route, API, Query, UI, styles, and E2E drill-down. Do not create git commits from the task worktree. +dependencies: [phase_1_s5_backend_replay_controller] +file_claims: + - path: front/features/platform-admin/model/platform-admin-notifications-model.ts + mode: owned + - path: front/features/platform-admin/api/platform-admin-notifications-api.ts + mode: owned + - path: front/features/platform-admin/api/platform-admin-contracts.ts + mode: modify + - path: front/features/platform-admin/queries/platform-admin-notifications-queries.ts + mode: owned + - path: front/features/platform-admin/route/admin-notifications-data.ts + mode: owned + - path: front/features/platform-admin/route/admin-notifications-route.tsx + mode: owned + - path: front/features/platform-admin/route/admin-notifications-route.test.tsx + mode: owned + - path: front/features/platform-admin/ui/admin-notifications-page.tsx + mode: owned + - path: front/features/platform-admin/ui/admin-notifications-page.test.tsx + mode: owned + - path: front/features/platform-admin/model/admin-route-catalog.ts + mode: modify + - path: front/features/platform-admin/model/admin-route-catalog.test.ts + mode: modify + - path: front/features/platform-admin/ui/admin-layout-nav.test.tsx + mode: modify + - path: front/src/app/routes/admin.tsx + mode: modify + - path: front/src/styles/globals.css + mode: modify + - path: front/tests/e2e/admin-health.spec.ts + mode: modify + - path: front/tests/e2e/admin-notifications.spec.ts + mode: owned +risk: high +verify_isolation: fast +verify: + - pnpm --dir front exec vitest run features/platform-admin/model/admin-route-catalog.test.ts features/platform-admin/ui/admin-layout-nav.test.tsx features/platform-admin/route/admin-notifications-route.test.tsx features/platform-admin/ui/admin-notifications-page.test.tsx + - pnpm --dir front exec playwright test tests/e2e/admin-health.spec.ts tests/e2e/admin-notifications.spec.ts --project=chromium +instructions: + - Implement Tasks 5, 6, and 7 from the plan body. + - Keep all fixture identities public-safe. + - Do not execute git add or git commit from the task worktree. +``` + +```yaml waygent-task +id: phase_2_s3_backend_club_operations +title: Phase 2A — Add AdminClubOperationsSnapshot backend contract, service, persistence, and controller. Do not create git commits from the task worktree. +dependencies: [phase_1_s5_backend_read_models] +file_claims: + - path: server/src/main/kotlin/com/readmates/club/application/model/AdminClubOperationsModels.kt + mode: owned + - path: server/src/main/kotlin/com/readmates/club/application/port/in/PlatformAdminUseCases.kt + mode: modify + - path: server/src/main/kotlin/com/readmates/club/application/port/out/AdminClubOperationsPorts.kt + mode: owned + - path: server/src/main/kotlin/com/readmates/club/application/service/AdminClubOperationsService.kt + mode: owned + - path: server/src/main/kotlin/com/readmates/club/adapter/out/persistence/JdbcAdminClubOperationsAdapter.kt + mode: owned + - path: server/src/main/kotlin/com/readmates/club/adapter/in/web/PlatformAdminClubOperationsController.kt + mode: owned + - path: server/src/test/kotlin/com/readmates/club/application/service/AdminClubOperationsServiceTest.kt + mode: owned + - path: server/src/test/kotlin/com/readmates/club/api/PlatformAdminClubOperationsControllerTest.kt + mode: owned +risk: high +verify_isolation: medium +verify: + - ./server/gradlew -p server unitTest --tests "com.readmates.club.application.service.AdminClubOperationsServiceTest" + - ./server/gradlew -p server integrationTest --tests "com.readmates.club.api.PlatformAdminClubOperationsControllerTest" +instructions: + - Implement Tasks 8 and 9 from the plan body. + - Keep the snapshot aggregate-only by default. + - Do not execute git add or git commit from the task worktree. +``` + +```yaml waygent-task +id: phase_2_s3_frontend_club_operations +title: Phase 2B — Render /admin/clubs/:id operations snapshot with route tests and E2E. Do not create git commits from the task worktree. +dependencies: [phase_2_s3_backend_club_operations, phase_1_s5_frontend_route] +file_claims: + - path: front/features/platform-admin/model/platform-admin-club-operations-model.ts + mode: owned + - path: front/features/platform-admin/api/platform-admin-club-operations-api.ts + mode: owned + - path: front/features/platform-admin/api/platform-admin-contracts.ts + mode: modify + - path: front/features/platform-admin/queries/platform-admin-club-operations-queries.ts + mode: owned + - path: front/features/platform-admin/route/admin-club-detail-data.ts + mode: modify + - path: front/features/platform-admin/route/admin-club-detail-route.tsx + mode: modify + - path: front/features/platform-admin/route/admin-club-detail-route.test.tsx + mode: modify + - path: front/features/platform-admin/ui/admin-club-operations-page.tsx + mode: owned + - path: front/features/platform-admin/ui/admin-club-operations-page.test.tsx + mode: owned + - path: front/src/styles/globals.css + mode: modify + - path: front/tests/e2e/admin-club-operations.spec.ts + mode: owned +risk: medium +verify_isolation: fast +verify: + - pnpm --dir front exec vitest run features/platform-admin/route/admin-club-detail-route.test.tsx features/platform-admin/ui/admin-club-operations-page.test.tsx + - pnpm --dir front exec playwright test tests/e2e/admin-club-operations.spec.ts --project=chromium +instructions: + - Implement Tasks 10 and 11 from the plan body. + - Do not duplicate host-owned commands in admin UI. + - Do not execute git add or git commit from the task worktree. +``` + +```yaml waygent-task +id: phase_3_s4_backend_support_workbench +title: Phase 3A — Add admin support search, strengthened grant validation, and support workbench controller. Do not create git commits from the task worktree. +dependencies: [] +file_claims: + - path: server/src/main/kotlin/com/readmates/club/application/PlatformAdminException.kt + mode: modify + - path: server/src/main/kotlin/com/readmates/club/application/model/AdminSupportModels.kt + mode: owned + - path: server/src/main/kotlin/com/readmates/club/application/port/in/SupportAccessGrantUseCases.kt + mode: modify + - path: server/src/main/kotlin/com/readmates/club/application/port/out/AdminSupportPorts.kt + mode: owned + - path: server/src/main/kotlin/com/readmates/club/application/service/AdminSupportWorkbenchService.kt + mode: owned + - path: server/src/main/kotlin/com/readmates/club/application/service/SupportAccessGrantService.kt + mode: modify + - path: server/src/main/kotlin/com/readmates/club/adapter/out/persistence/JdbcAdminSupportSearchAdapter.kt + mode: owned + - path: server/src/main/kotlin/com/readmates/club/adapter/out/persistence/JdbcSupportAccessGrantAdapter.kt + mode: modify + - path: server/src/main/kotlin/com/readmates/club/adapter/in/web/PlatformAdminSupportWorkbenchController.kt + mode: owned + - path: server/src/test/kotlin/com/readmates/club/application/service/AdminSupportWorkbenchServiceTest.kt + mode: owned + - path: server/src/test/kotlin/com/readmates/club/application/service/SupportAccessGrantServiceTest.kt + mode: owned + - path: server/src/test/kotlin/com/readmates/club/api/PlatformAdminSupportWorkbenchControllerTest.kt + mode: owned +risk: high +verify_isolation: medium +verify: + - ./server/gradlew -p server unitTest --tests "com.readmates.club.application.service.AdminSupportWorkbenchServiceTest" --tests "com.readmates.club.application.service.SupportAccessGrantServiceTest" + - ./server/gradlew -p server integrationTest --tests "com.readmates.club.api.PlatformAdminSupportWorkbenchControllerTest" --tests "com.readmates.club.api.SupportAccessGrantControllerTest" +instructions: + - Implement Tasks 12 and 13 from the plan body. + - Keep existing /api/admin/support-access-grants compatible but strengthen its validation. + - Do not execute git add or git commit from the task worktree. +``` + +```yaml waygent-task +id: phase_3_s4_frontend_support_workbench +title: Phase 3B — Replace /admin/support shell with search-based support workbench and E2E. Do not create git commits from the task worktree. +dependencies: [phase_3_s4_backend_support_workbench, phase_2_s3_frontend_club_operations] +file_claims: + - path: front/features/platform-admin/model/platform-admin-support-model.ts + mode: owned + - path: front/features/platform-admin/api/platform-admin-support-api.ts + mode: owned + - path: front/features/platform-admin/api/platform-admin-contracts.ts + mode: modify + - path: front/features/platform-admin/queries/platform-admin-support-queries.ts + mode: owned + - path: front/features/platform-admin/route/admin-support-data.ts + mode: owned + - path: front/features/platform-admin/route/admin-support-route.tsx + mode: modify + - path: front/features/platform-admin/route/admin-support-route.test.tsx + mode: modify + - path: front/features/platform-admin/ui/admin-support-workbench.tsx + mode: owned + - path: front/features/platform-admin/ui/admin-support-workbench.test.tsx + mode: owned + - path: front/src/app/routes/admin.tsx + mode: modify + - path: front/src/styles/globals.css + mode: modify + - path: front/tests/e2e/admin-support.spec.ts + mode: owned +risk: medium +verify_isolation: fast +verify: + - pnpm --dir front exec vitest run features/platform-admin/route/admin-support-route.test.tsx features/platform-admin/ui/admin-support-workbench.test.tsx + - pnpm --dir front exec playwright test tests/e2e/admin-support.spec.ts --project=chromium +instructions: + - Implement Tasks 14 and 15 from the plan body. + - Grant creation must be unreachable until a search result is selected. + - Do not execute git add or git commit from the task worktree. +``` + +```yaml waygent-task +id: phase_4_release_docs_verification +title: Phase 4 — Update release notes, run integrated checks, and refresh Graphify if code changed. Do not create git commits from the task worktree. +dependencies: [phase_1_s5_frontend_route, phase_2_s3_frontend_club_operations, phase_3_s4_frontend_support_workbench] +file_claims: + - path: CHANGELOG.md + mode: owned + - path: docs/development/architecture.md + mode: modify + - path: docs/development/server-state-migration.md + mode: modify +risk: medium +verify_isolation: full +verify: + - pnpm --dir front lint + - pnpm --dir front test + - pnpm --dir front build + - pnpm --dir front test:e2e + - ./server/gradlew -p server clean test + - ./server/gradlew -p server architectureTest + - ./scripts/build-public-release-candidate.sh + - ./scripts/public-release-check.sh .tmp/public-release-candidate + - graphify update . + - git diff --check +instructions: + - Implement Task 16 from the plan body. + - Update docs only for behavior that actually shipped. + - Do not execute git add or git commit from the task worktree. +``` + +**Goal:** Build the next ReadMates admin operating-console expansion in one phased implementation: actionable `/admin/notifications`, aggregate `/admin/clubs/:id` operations snapshots, and searchable `/admin/support` grants. + +**Architecture:** Keep the existing frontend route-first structure and server feature-local clean architecture. Notification/outbox admin operations stay in the `notification` feature; club operations and support workbench stay in the `club` feature; frontend route modules own Query seeding and UI prop assembly while UI components stay prop/callback driven. + +**Tech Stack:** React 19, React Router 7, TanStack Query v5, Vitest, Playwright, Vite, Kotlin/Spring Boot, MockMvc, JUnit 5, AssertJ, MySQL/Flyway. + +**Spec:** [`docs/superpowers/specs/2026-05-27-readmates-admin-vnext-operating-console-expansion-design.md`](../specs/2026-05-27-readmates-admin-vnext-operating-console-expansion-design.md) + +--- + +## Current Source State + +- `/admin/health` is READY and health cards drill to `/admin/notifications`. +- `/admin/notifications`, `/admin/audit`, and `/admin/analytics` are still coming-soon routes in `front/features/platform-admin/model/admin-route-catalog.ts`. +- `/admin/support` is READY but renders only a light shell that points to club detail support grants. +- `/admin/clubs/:id` renders club metadata only and already fetches selected-club support grants. +- Host notification APIs already expose club-scoped event, delivery, manual dispatch, retry, restore, and test-mail flows under `/api/host/notifications`. +- Notification persistence already has `notification_event_outbox`, `notification_deliveries`, `member_notifications`, `notification_manual_dispatch_previews`, and `notification_manual_dispatches`. +- Support grants already write `platform_audit_events`, but current validation only covers OWNER permission and non-blank reason. + +## File Structure + +### Phase 1: S5 Notification / Outbox + +Create server files: + +- `server/src/main/kotlin/com/readmates/notification/application/model/AdminNotificationOperationsModels.kt` +- `server/src/main/kotlin/com/readmates/notification/application/port/out/AdminNotificationOperationsPorts.kt` +- `server/src/main/kotlin/com/readmates/notification/application/service/AdminNotificationOperationsService.kt` +- `server/src/main/kotlin/com/readmates/notification/adapter/out/persistence/JdbcAdminNotificationOperationsAdapter.kt` +- `server/src/main/kotlin/com/readmates/notification/adapter/in/web/PlatformAdminNotificationController.kt` +- `server/src/main/kotlin/com/readmates/notification/adapter/in/web/PlatformAdminNotificationWebDtos.kt` +- `server/src/main/resources/db/mysql/migration/V35__admin_notification_replay_previews.sql` + +Modify server files: + +- `server/src/main/kotlin/com/readmates/notification/application/port/in/NotificationUseCases.kt` + +Create frontend files: + +- `front/features/platform-admin/model/platform-admin-notifications-model.ts` +- `front/features/platform-admin/api/platform-admin-notifications-api.ts` +- `front/features/platform-admin/queries/platform-admin-notifications-queries.ts` +- `front/features/platform-admin/route/admin-notifications-data.ts` +- `front/features/platform-admin/route/admin-notifications-route.tsx` +- `front/features/platform-admin/ui/admin-notifications-page.tsx` +- `front/tests/e2e/admin-notifications.spec.ts` + +Modify frontend files: + +- `front/features/platform-admin/api/platform-admin-contracts.ts` +- `front/features/platform-admin/model/admin-route-catalog.ts` +- `front/src/app/routes/admin.tsx` +- `front/src/styles/globals.css` +- `front/tests/e2e/admin-health.spec.ts` + +### Phase 2: S3 Club Operations + +Create server files: + +- `server/src/main/kotlin/com/readmates/club/application/model/AdminClubOperationsModels.kt` +- `server/src/main/kotlin/com/readmates/club/application/port/out/AdminClubOperationsPorts.kt` +- `server/src/main/kotlin/com/readmates/club/application/service/AdminClubOperationsService.kt` +- `server/src/main/kotlin/com/readmates/club/adapter/out/persistence/JdbcAdminClubOperationsAdapter.kt` +- `server/src/main/kotlin/com/readmates/club/adapter/in/web/PlatformAdminClubOperationsController.kt` + +Modify server files: + +- `server/src/main/kotlin/com/readmates/club/application/port/in/PlatformAdminUseCases.kt` + +Create frontend files: + +- `front/features/platform-admin/model/platform-admin-club-operations-model.ts` +- `front/features/platform-admin/api/platform-admin-club-operations-api.ts` +- `front/features/platform-admin/queries/platform-admin-club-operations-queries.ts` +- `front/features/platform-admin/ui/admin-club-operations-page.tsx` +- `front/tests/e2e/admin-club-operations.spec.ts` + +Modify frontend files: + +- `front/features/platform-admin/api/platform-admin-contracts.ts` +- `front/features/platform-admin/route/admin-club-detail-data.ts` +- `front/features/platform-admin/route/admin-club-detail-route.tsx` +- `front/src/styles/globals.css` + +### Phase 3: S4 Support Workbench + +Create server files: + +- `server/src/main/kotlin/com/readmates/club/application/model/AdminSupportModels.kt` +- `server/src/main/kotlin/com/readmates/club/application/port/out/AdminSupportPorts.kt` +- `server/src/main/kotlin/com/readmates/club/application/service/AdminSupportWorkbenchService.kt` +- `server/src/main/kotlin/com/readmates/club/adapter/out/persistence/JdbcAdminSupportSearchAdapter.kt` +- `server/src/main/kotlin/com/readmates/club/adapter/in/web/PlatformAdminSupportWorkbenchController.kt` + +Modify server files: + +- `server/src/main/kotlin/com/readmates/club/application/PlatformAdminException.kt` +- `server/src/main/kotlin/com/readmates/club/application/port/in/SupportAccessGrantUseCases.kt` +- `server/src/main/kotlin/com/readmates/club/application/service/SupportAccessGrantService.kt` +- `server/src/main/kotlin/com/readmates/club/adapter/out/persistence/JdbcSupportAccessGrantAdapter.kt` + +Create frontend files: + +- `front/features/platform-admin/model/platform-admin-support-model.ts` +- `front/features/platform-admin/api/platform-admin-support-api.ts` +- `front/features/platform-admin/queries/platform-admin-support-queries.ts` +- `front/features/platform-admin/route/admin-support-data.ts` +- `front/features/platform-admin/ui/admin-support-workbench.tsx` +- `front/tests/e2e/admin-support.spec.ts` + +Modify frontend files: + +- `front/features/platform-admin/api/platform-admin-contracts.ts` +- `front/features/platform-admin/route/admin-support-route.tsx` +- `front/src/app/routes/admin.tsx` +- `front/src/styles/globals.css` + +## Task 1 — S5 Server Models, Use Case, And Ports + +**Files:** + +- Create: `server/src/main/kotlin/com/readmates/notification/application/model/AdminNotificationOperationsModels.kt` +- Create: `server/src/main/kotlin/com/readmates/notification/application/port/out/AdminNotificationOperationsPorts.kt` +- Modify: `server/src/main/kotlin/com/readmates/notification/application/port/in/NotificationUseCases.kt` +- Test: `server/src/test/kotlin/com/readmates/notification/application/service/AdminNotificationOperationsServiceTest.kt` + +- [ ] Add admin notification application models. + +`AdminNotificationOperationsModels.kt` should define these public application models: + +```kotlin +package com.readmates.notification.application.model + +import com.readmates.notification.domain.NotificationChannel +import com.readmates.notification.domain.NotificationDeliveryStatus +import com.readmates.notification.domain.NotificationEventOutboxStatus +import com.readmates.notification.domain.NotificationEventType +import java.time.OffsetDateTime +import java.util.UUID + +data class AdminNotificationOperationsSnapshot( + val generatedAt: OffsetDateTime, + val outboxSummary: AdminNotificationStatusSummary, + val deliverySummary: AdminNotificationStatusSummary, + val relaySummary: AdminNotificationRelaySummary, + val failureClusters: List, + val clubHealth: List, + val recentManualDispatches: List, +) + +data class AdminNotificationStatusSummary( + val pending: Int, + val active: Int, + val failed: Int, + val dead: Int, + val sentOrPublishedLast24h: Int, +) + +data class AdminNotificationRelaySummary( + val publishing: Int, + val sending: Int, + val stalePublishing: Int, + val staleSending: Int, +) + +data class AdminNotificationFailureCluster( + val safeErrorCode: String, + val status: String, + val count: Int, + val latestAt: OffsetDateTime?, +) + +data class AdminNotificationClubHealth( + val clubId: UUID, + val slug: String, + val name: String, + val pending: Int, + val failed: Int, + val dead: Int, + val lastSuccessAt: OffsetDateTime?, +) + +data class AdminNotificationManualDispatchSummary( + val manualDispatchId: UUID, + val eventId: UUID, + val clubId: UUID, + val clubName: String, + val eventType: NotificationEventType, + val eventStatus: NotificationEventOutboxStatus, + val targetCount: Int, + val createdAt: OffsetDateTime, +) + +data class AdminNotificationOutboxEvent( + val eventId: UUID, + val club: AdminNotificationClubRef, + val eventType: NotificationEventType, + val source: NotificationDispatchSource, + val status: NotificationEventOutboxStatus, + val attemptCount: Int, + val nextAttemptAt: OffsetDateTime?, + val createdAt: OffsetDateTime, + val updatedAt: OffsetDateTime, + val safeErrorCode: String?, + val manualDispatch: AdminNotificationManualDispatchMetadata?, +) + +data class AdminNotificationDelivery( + val deliveryId: UUID, + val eventId: UUID, + val club: AdminNotificationClubRef, + val channel: NotificationChannel, + val status: NotificationDeliveryStatus, + val maskedRecipient: String?, + val attemptCount: Int, + val createdAt: OffsetDateTime, + val updatedAt: OffsetDateTime, + val safeErrorCode: String?, +) + +data class AdminNotificationClubRef( + val clubId: UUID, + val slug: String, + val name: String, +) + +data class AdminNotificationManualDispatchMetadata( + val manualDispatchId: UUID, + val requestedBy: String, + val targetCount: Int, +) + +data class AdminNotificationFilter( + val clubId: UUID? = null, + val eventStatus: NotificationEventOutboxStatus? = null, + val deliveryStatus: NotificationDeliveryStatus? = null, + val channel: NotificationChannel? = null, +) +``` + +- [ ] Add admin notification ports. + +`AdminNotificationOperationsPorts.kt` should contain: + +```kotlin +package com.readmates.notification.application.port.out + +import com.readmates.notification.application.model.AdminNotificationDelivery +import com.readmates.notification.application.model.AdminNotificationFilter +import com.readmates.notification.application.model.AdminNotificationOperationsSnapshot +import com.readmates.notification.application.model.AdminNotificationOutboxEvent +import com.readmates.shared.paging.CursorPage +import com.readmates.shared.paging.PageRequest + +interface AdminNotificationOperationsReadPort { + fun snapshot(): AdminNotificationOperationsSnapshot + + fun listEvents( + filter: AdminNotificationFilter, + pageRequest: PageRequest, + ): CursorPage + + fun listDeliveries( + filter: AdminNotificationFilter, + pageRequest: PageRequest, + ): CursorPage +} +``` + +- [ ] Extend inbound use cases in `NotificationUseCases.kt`. + +Add this interface without changing existing host notification contracts: + +```kotlin +interface ManageAdminNotificationOperationsUseCase { + fun snapshot(admin: CurrentPlatformAdmin): AdminNotificationOperationsSnapshot + + fun listEvents( + admin: CurrentPlatformAdmin, + filter: AdminNotificationFilter, + pageRequest: PageRequest, + ): CursorPage + + fun listDeliveries( + admin: CurrentPlatformAdmin, + filter: AdminNotificationFilter, + pageRequest: PageRequest, + ): CursorPage +} +``` + +Add imports for `CurrentPlatformAdmin`, `CursorPage`, and the new admin models. + +- [ ] Write the failing service tests. + +`AdminNotificationOperationsServiceTest.kt` should include: + +```kotlin +@Test +fun `support can read snapshot but cannot replay`() { + val service = serviceWith(readPort = fakeReadPort()) + val snapshot = service.snapshot(platformAdmin("SUPPORT")) + assertThat(snapshot.outboxSummary.pending).isEqualTo(2) +} + +@Test +fun `memberless admin filters are passed to read port`() { + val readPort = RecordingAdminNotificationReadPort() + serviceWith(readPort = readPort).listEvents( + admin = platformAdmin("OWNER"), + filter = AdminNotificationFilter(clubId = CLUB_ID, eventStatus = NotificationEventOutboxStatus.FAILED), + pageRequest = PageRequest.cursor(limit = 20, cursor = null, defaultLimit = 50, maxLimit = 100), + ) + assertThat(readPort.lastFilter?.clubId).isEqualTo(CLUB_ID) + assertThat(readPort.lastFilter?.eventStatus).isEqualTo(NotificationEventOutboxStatus.FAILED) +} +``` + +Use local fake ports and deterministic UUID constants in the test file. + +- [ ] Run the tests to verify they fail before implementation: + +```bash +./server/gradlew -p server unitTest --tests "com.readmates.notification.application.service.AdminNotificationOperationsServiceTest" +``` + +Expected before implementation: compile failure for missing model/service classes. + +## Task 2 — S5 Server Read Adapter And Service + +**Files:** + +- Create: `server/src/main/kotlin/com/readmates/notification/application/service/AdminNotificationOperationsService.kt` +- Create: `server/src/main/kotlin/com/readmates/notification/adapter/out/persistence/JdbcAdminNotificationOperationsAdapter.kt` +- Test: `server/src/test/kotlin/com/readmates/notification/adapter/out/persistence/JdbcAdminNotificationOperationsAdapterTest.kt` + +- [ ] Implement `AdminNotificationOperationsService`. + +Service behavior: + +```kotlin +@Service +class AdminNotificationOperationsService( + private val readPort: AdminNotificationOperationsReadPort, +) : ManageAdminNotificationOperationsUseCase { + override fun snapshot(admin: CurrentPlatformAdmin): AdminNotificationOperationsSnapshot = + readPort.snapshot() + + override fun listEvents( + admin: CurrentPlatformAdmin, + filter: AdminNotificationFilter, + pageRequest: PageRequest, + ): CursorPage = + readPort.listEvents(filter, pageRequest.copy(limit = pageRequest.limit.coerceIn(1, MAX_ADMIN_NOTIFICATION_LIMIT))) + + override fun listDeliveries( + admin: CurrentPlatformAdmin, + filter: AdminNotificationFilter, + pageRequest: PageRequest, + ): CursorPage = + readPort.listDeliveries(filter, pageRequest.copy(limit = pageRequest.limit.coerceIn(1, MAX_ADMIN_NOTIFICATION_LIMIT))) +} + +private const val MAX_ADMIN_NOTIFICATION_LIMIT = 100 +``` + +- [ ] Implement `JdbcAdminNotificationOperationsAdapter`. + +The adapter should: + +- use `utc_timestamp(6)` windows in SQL; +- group failure clusters by `coalesce(nullif(last_error, ''), 'unknown')` after sanitizing to a low-cardinality safe code in Kotlin; +- return masked recipients by reusing the existing row-mapper masking helper if available, or by adding a local `maskEmailForAdmin` helper with the same visible behavior as host responses; +- join `clubs` for `clubId`, `slug`, and `name`; +- never select email body columns or notification payload bodies into response models. + +Required query methods: + +```kotlin +override fun snapshot(): AdminNotificationOperationsSnapshot +override fun listEvents(filter: AdminNotificationFilter, pageRequest: PageRequest): CursorPage +override fun listDeliveries(filter: AdminNotificationFilter, pageRequest: PageRequest): CursorPage +``` + +Cursor ordering must be `updated_at desc, created_at desc, id desc`, matching existing host ledgers. + +- [ ] Write integration tests for the adapter. + +`JdbcAdminNotificationOperationsAdapterTest.kt` should seed: + +- one failed event in the baseline club; +- one dead delivery in the baseline club; +- one row in a second club; +- one manual dispatch row linked to an event. + +Assertions: + +- snapshot contains both clubs; +- masked recipient is present and raw recipient is absent; +- event and delivery pages return `nextCursor` when `limit=1`; +- failure cluster safe code does not include an email, token, SQL detail, or raw SMTP text. + +- [ ] Run the adapter tests: + +```bash +./server/gradlew -p server integrationTest --tests "com.readmates.notification.adapter.out.persistence.JdbcAdminNotificationOperationsAdapterTest" +``` + +Expected after implementation: pass. + +## Task 3 — S5 Replay Preview, Confirm, And Audit + +**Files:** + +- Add: `server/src/main/resources/db/mysql/migration/V35__admin_notification_replay_previews.sql` +- Modify: `server/src/main/kotlin/com/readmates/notification/application/model/AdminNotificationOperationsModels.kt` +- Modify: `server/src/main/kotlin/com/readmates/notification/application/port/out/AdminNotificationOperationsPorts.kt` +- Modify: `server/src/main/kotlin/com/readmates/notification/application/service/AdminNotificationOperationsService.kt` +- Modify: `server/src/main/kotlin/com/readmates/notification/adapter/out/persistence/JdbcAdminNotificationOperationsAdapter.kt` +- Test: `server/src/test/kotlin/com/readmates/notification/application/service/AdminNotificationOperationsServiceTest.kt` + +- [ ] Add the replay preview migration. + +`V35__admin_notification_replay_previews.sql`: + +```sql +create table admin_notification_replay_previews ( + id char(36) not null, + actor_user_id char(36) not null, + filter_json json not null, + selection_hash char(64) not null, + matched_count int not null, + expires_at datetime(6) not null, + consumed_at datetime(6), + created_at datetime(6) not null default (utc_timestamp(6)), + primary key (id), + key admin_notification_replay_previews_actor_created_idx (actor_user_id, created_at), + key admin_notification_replay_previews_expires_idx (expires_at), + constraint admin_notification_replay_previews_actor_fk foreign key (actor_user_id) references users(id), + constraint admin_notification_replay_previews_count_check check (matched_count >= 0), + constraint admin_notification_replay_previews_hash_check check (length(selection_hash) = 64) +) default character set utf8mb4 collate utf8mb4_0900_ai_ci; +``` + +- [ ] Add replay models. + +Append to `AdminNotificationOperationsModels.kt`: + +```kotlin +data class AdminNotificationReplayPreviewRequest( + val filter: AdminNotificationFilter, +) + +data class AdminNotificationReplayPreview( + val previewId: UUID, + val selectionHash: String, + val matchedCount: Int, + val excludedCount: Int, + val estimatedByStatus: Map, + val warnings: List, + val expiresAt: OffsetDateTime, +) + +data class AdminNotificationReplayConfirmCommand( + val previewId: UUID, + val selectionHash: String, + val reason: String, +) + +data class AdminNotificationReplayConfirmResult( + val replayedCount: Int, + val skippedCount: Int, + val selectionHash: String, +) +``` + +- [ ] Extend the use case with preview and confirm methods. + +```kotlin +fun previewReplay( + admin: CurrentPlatformAdmin, + request: AdminNotificationReplayPreviewRequest, +): AdminNotificationReplayPreview + +fun confirmReplay( + admin: CurrentPlatformAdmin, + command: AdminNotificationReplayConfirmCommand, +): AdminNotificationReplayConfirmResult +``` + +- [ ] Extend ports for preview persistence, replayable selection, replay mutation, and admin audit. + +Add to `AdminNotificationOperationsPorts.kt`: + +```kotlin +interface AdminNotificationReplayPort { + fun createPreview( + actorUserId: UUID, + filterJson: String, + selectionHash: String, + matchedCount: Int, + expiresAt: OffsetDateTime, + ): UUID + + fun loadOpenPreview(previewId: UUID): AdminNotificationReplayPreviewRecord? + + fun markPreviewConsumed(previewId: UUID): Boolean + + fun replayDeadOrFailedDeliveries(filter: AdminNotificationFilter): Int +} + +interface AdminNotificationAuditPort { + fun writeReplayConfirmed( + actorUserId: UUID, + actorPlatformRole: String, + metadataJson: String, + ) +} + +data class AdminNotificationReplayPreviewRecord( + val previewId: UUID, + val actorUserId: UUID, + val filterJson: String, + val selectionHash: String, + val matchedCount: Int, + val expiresAt: OffsetDateTime, +) +``` + +- [ ] Implement service rules. + +Rules: + +- OWNER and OPERATOR can preview and confirm replay. +- SUPPORT cannot confirm replay. +- Preview selects only `FAILED` and `DEAD` email deliveries. +- Confirm rejects blank reason. +- Confirm rejects expired previews. +- Confirm rejects mismatched actor. +- Confirm rejects mismatched selection hash. +- Confirm writes event type `ADMIN_NOTIFICATION_REPLAY_CONFIRMED`. + +Use metadata JSON keys: + +```json +{ + "previewId": "uuid", + "selectionHash": "hex", + "reason": "operator-entered reason", + "replayedCount": 3, + "skippedCount": 0 +} +``` + +- [ ] Extend unit tests for permission, blank reason, expired preview, selection hash mismatch, and audit metadata. + +- [ ] Run: + +```bash +./server/gradlew -p server unitTest --tests "com.readmates.notification.application.service.AdminNotificationOperationsServiceTest" +``` + +Expected after implementation: pass. + +## Task 4 — S5 Controller DTOs And Integration Tests + +**Files:** + +- Create: `server/src/main/kotlin/com/readmates/notification/adapter/in/web/PlatformAdminNotificationController.kt` +- Create: `server/src/main/kotlin/com/readmates/notification/adapter/in/web/PlatformAdminNotificationWebDtos.kt` +- Test: `server/src/test/kotlin/com/readmates/notification/api/PlatformAdminNotificationControllerTest.kt` + +- [ ] Create controller routes. + +`PlatformAdminNotificationController` should expose: + +```kotlin +@RestController +@RequestMapping("/api/admin/notifications") +class PlatformAdminNotificationController( + private val useCase: ManageAdminNotificationOperationsUseCase, +) { + @GetMapping("/snapshot") + fun snapshot(admin: CurrentPlatformAdmin): AdminNotificationOperationsSnapshotResponse + + @GetMapping("/events") + fun events( + admin: CurrentPlatformAdmin, + @RequestParam(required = false) clubId: UUID?, + @RequestParam(required = false) status: NotificationEventOutboxStatus?, + @RequestParam(required = false) limit: Int?, + @RequestParam(required = false) cursor: String?, + ): CursorPageResponse + + @GetMapping("/deliveries") + fun deliveries( + admin: CurrentPlatformAdmin, + @RequestParam(required = false) clubId: UUID?, + @RequestParam(required = false) status: NotificationDeliveryStatus?, + @RequestParam(required = false) channel: NotificationChannel?, + @RequestParam(required = false) limit: Int?, + @RequestParam(required = false) cursor: String?, + ): CursorPageResponse + + @PostMapping("/replay-preview") + fun preview(admin: CurrentPlatformAdmin, @RequestBody request: AdminNotificationReplayPreviewRequestBody): AdminNotificationReplayPreviewResponse + + @PostMapping("/replay-confirm") + fun confirm(admin: CurrentPlatformAdmin, @RequestBody request: AdminNotificationReplayConfirmRequestBody): AdminNotificationReplayConfirmResponse +} +``` + +Use existing `CurrentPlatformAdmin` resolution; do not accept club-scoped `CurrentMember`. + +- [ ] Create DTOs in `PlatformAdminNotificationWebDtos.kt`. + +DTOs should mirror frontend camelCase: + +- `generatedAt` +- `outboxSummary` +- `deliverySummary` +- `relaySummary` +- `failureClusters` +- `clubHealth` +- `recentManualDispatches` +- `maskedRecipient` +- `safeErrorCode` +- `previewId` +- `selectionHash` +- `matchedCount` +- `estimatedByStatus` + +- [ ] Write controller integration tests. + +Test cases: + +- OWNER gets snapshot. +- SUPPORT gets snapshot with masked recipients. +- guest/member is denied by platform admin resolver. +- replay preview returns `previewId` and `selectionHash`. +- replay confirm returns replay count for OWNER. +- replay confirm returns forbidden for SUPPORT. +- response body never contains raw recipient email or raw SMTP text. + +Use session cookies as in `SupportAccessGrantControllerTest`, not a host `CurrentMember`. + +- [ ] Run: + +```bash +./server/gradlew -p server integrationTest --tests "com.readmates.notification.api.PlatformAdminNotificationControllerTest" +``` + +Expected after implementation: pass. + +## Task 5 — S5 Frontend Contracts, API, Query, And Route Wiring + +**Files:** + +- Create: `front/features/platform-admin/model/platform-admin-notifications-model.ts` +- Create: `front/features/platform-admin/api/platform-admin-notifications-api.ts` +- Create: `front/features/platform-admin/queries/platform-admin-notifications-queries.ts` +- Create: `front/features/platform-admin/route/admin-notifications-data.ts` +- Create: `front/features/platform-admin/route/admin-notifications-route.tsx` +- Modify: `front/features/platform-admin/api/platform-admin-contracts.ts` +- Modify: `front/features/platform-admin/model/admin-route-catalog.ts` +- Modify: `front/src/app/routes/admin.tsx` +- Test: `front/features/platform-admin/model/admin-route-catalog.test.ts` +- Test: `front/features/platform-admin/ui/admin-layout-nav.test.tsx` + +- [ ] Add TypeScript contracts. + +`platform-admin-notifications-model.ts` should define: + +```ts +export type AdminNotificationStatusSummary = { + pending: number; + active: number; + failed: number; + dead: number; + sentOrPublishedLast24h: number; +}; + +export type AdminNotificationOperationsSnapshot = { + generatedAt: string; + outboxSummary: AdminNotificationStatusSummary; + deliverySummary: AdminNotificationStatusSummary; + relaySummary: { + publishing: number; + sending: number; + stalePublishing: number; + staleSending: number; + }; + failureClusters: Array<{ safeErrorCode: string; status: string; count: number; latestAt: string | null }>; + clubHealth: Array<{ clubId: string; slug: string; name: string; pending: number; failed: number; dead: number; lastSuccessAt: string | null }>; + recentManualDispatches: Array<{ manualDispatchId: string; eventId: string; clubId: string; clubName: string; eventType: string; eventStatus: string; targetCount: number; createdAt: string }>; +}; + +export type AdminNotificationOutboxEvent = { + eventId: string; + club: { clubId: string; slug: string; name: string }; + eventType: string; + source: "AUTOMATIC" | "MANUAL"; + status: string; + attemptCount: number; + nextAttemptAt: string | null; + createdAt: string; + updatedAt: string; + safeErrorCode: string | null; + manualDispatch: null | { manualDispatchId: string; requestedBy: string; targetCount: number }; +}; + +export type AdminNotificationDelivery = { + deliveryId: string; + eventId: string; + club: { clubId: string; slug: string; name: string }; + channel: "EMAIL" | "IN_APP"; + status: string; + maskedRecipient: string | null; + attemptCount: number; + createdAt: string; + updatedAt: string; + safeErrorCode: string | null; +}; + +export type AdminNotificationReplayPreview = { + previewId: string; + selectionHash: string; + matchedCount: number; + excludedCount: number; + estimatedByStatus: Record; + warnings: string[]; + expiresAt: string; +}; + +export type AdminNotificationFilters = { + clubId?: string; + eventStatus?: string; + deliveryStatus?: string; + channel?: "EMAIL" | "IN_APP"; + cursor?: string; +}; + +export type AdminNotificationReplayFilter = { + clubId?: string; + deliveryStatus?: string; + channel?: "EMAIL" | "IN_APP"; +}; + +export type AdminNotificationReplayConfirmRequest = { + previewId: string; + selectionHash: string; + reason: string; +}; +``` + +Export these types from `platform-admin-contracts.ts`. + +- [ ] Add API functions. + +`platform-admin-notifications-api.ts`: + +```ts +import { readmatesFetch } from "@/shared/api/client"; +import type { + AdminNotificationDelivery, + AdminNotificationFilters, + AdminNotificationOperationsSnapshot, + AdminNotificationOutboxEvent, + AdminNotificationReplayConfirmRequest, + AdminNotificationReplayFilter, + AdminNotificationReplayPreview, +} from "@/features/platform-admin/model/platform-admin-notifications-model"; +import type { PagedResponse } from "@/shared/model/paging"; + +function notificationSearch(filters: AdminNotificationFilters): string { + const params = new URLSearchParams(); + if (filters.clubId) params.set("clubId", filters.clubId); + if (filters.eventStatus) params.set("status", filters.eventStatus); + if (filters.deliveryStatus) params.set("status", filters.deliveryStatus); + if (filters.channel) params.set("channel", filters.channel); + if (filters.cursor) params.set("cursor", filters.cursor); + const search = params.toString(); + return search ? `?${search}` : ""; +} + +export function fetchAdminNotificationSnapshot() { + return readmatesFetch( + "/api/admin/notifications/snapshot", + undefined, + { clubSlug: undefined }, + ); +} + +export function fetchAdminNotificationEvents(filters: AdminNotificationFilters = {}) { + return readmatesFetch>( + `/api/admin/notifications/events${notificationSearch(filters)}`, + undefined, + { clubSlug: undefined }, + ); +} + +export function fetchAdminNotificationDeliveries(filters: AdminNotificationFilters = {}) { + return readmatesFetch>( + `/api/admin/notifications/deliveries${notificationSearch(filters)}`, + undefined, + { clubSlug: undefined }, + ); +} + +export function previewAdminNotificationReplay(filter: AdminNotificationReplayFilter) { + return readmatesFetch( + "/api/admin/notifications/replay-preview", + { method: "POST", body: JSON.stringify({ filter }) }, + { clubSlug: undefined }, + ); +} + +export function confirmAdminNotificationReplay(request: AdminNotificationReplayConfirmRequest) { + return readmatesFetch<{ replayedCount: number; skippedCount: number; selectionHash: string }>( + "/api/admin/notifications/replay-confirm", + { method: "POST", body: JSON.stringify(request) }, + { clubSlug: undefined }, + ); +} +``` + +All calls must pass `{ clubSlug: undefined }`. + +- [ ] Add Query helpers. + +Use query keys under `["platform-admin", "notifications"]`. Mutations invalidate the notifications root on confirm success. + +- [ ] Add route loader. + +`adminNotificationsLoaderFactory(queryClient)` should seed snapshot, first events page, and first deliveries page. + +- [ ] Flip route catalog and route wiring. + +In `admin-route-catalog.ts`, make `notifications` `status: "ready"` and remove its `comingSoon` block. + +In `front/src/app/routes/admin.tsx`, add a `case "notifications"` ready child that imports: + +```ts +import("@/features/platform-admin/route/admin-notifications-route") +import("@/features/platform-admin/route/admin-notifications-data") +``` + +- [ ] Update nav/catalog tests. + +Expected ready routes after S5: + +```ts +["ai-ops", "clubs", "health", "notifications", "support", "today"] +``` + +`admin-layout-nav.test.tsx` should assert the notifications item does not include `준비 중`. + +- [ ] Run: + +```bash +pnpm --dir front exec vitest run features/platform-admin/model/admin-route-catalog.test.ts features/platform-admin/ui/admin-layout-nav.test.tsx +``` + +Expected after implementation: pass. + +## Task 6 — S5 Frontend UI And Route Tests + +**Files:** + +- Create: `front/features/platform-admin/ui/admin-notifications-page.tsx` +- Create: `front/features/platform-admin/ui/admin-notifications-page.test.tsx` +- Create: `front/features/platform-admin/route/admin-notifications-route.test.tsx` +- Modify: `front/features/platform-admin/route/admin-notifications-route.tsx` +- Modify: `front/src/styles/globals.css` + +- [ ] Build `AdminNotificationsPage`. + +Props: + +```ts +type AdminNotificationsPageProps = { + snapshot: AdminNotificationOperationsSnapshot; + events: AdminNotificationOutboxEvent[]; + deliveries: AdminNotificationDelivery[]; + focus: string | null; + replayPreview: AdminNotificationReplayPreview | null; + replayReason: string; + canReplay: boolean; + busy: boolean; + error: string | null; + onPreviewReplay: () => Promise; + onConfirmReplay: () => Promise; + onReplayReasonChange: (value: string) => void; +}; +``` + +UI sections: + +- summary strip; +- focus banner when `focus` is `outbox_backlog` or `notification_dispatch_success`; +- failure clusters; +- events ledger; +- deliveries ledger; +- replay preview/confirm panel. + +Replay confirm button is disabled unless `replayPreview` exists, `replayReason.trim()` is non-empty, `canReplay` is true, and `busy` is false. + +- [ ] Route module owns Query and mutation state. + +`AdminNotificationsRoute` reads seeded Query data, reads `focus` from `useSearchParams`, and passes props to `AdminNotificationsPage`. + +Use safe Korean copy: + +- preview loading: `재처리 대상을 확인하는 중입니다.` +- confirm loading: `재처리를 기록하는 중입니다.` +- permission: `현재 역할은 재처리를 실행할 수 없습니다.` +- generic error: `알림 운영 정보를 처리하지 못했습니다. 다시 시도해 주세요.` + +- [ ] Add CSS classes in `globals.css`. + +Use `admin-notifications-*` class names. Keep layout dense, calm, and ledger-like: + +- constrained width; +- summary grid; +- table/list rows with stable spacing; +- mobile rows stack without text overlap; +- no decorative gradients or glow. + +- [ ] Add UI tests. + +Test cases: + +- renders summary and failure clusters; +- renders masked recipient and never raw email fixture; +- focus banner appears from focus prop; +- confirm disabled until preview and reason exist; +- support role message appears when `canReplay=false`. + +- [ ] Add route tests. + +Seed Query data and assert route passes focus from URL: + +```tsx + +``` + +- [ ] Run: + +```bash +pnpm --dir front exec vitest run features/platform-admin/route/admin-notifications-route.test.tsx features/platform-admin/ui/admin-notifications-page.test.tsx +``` + +Expected after implementation: pass. + +## Task 7 — S5 E2E Drill-Down + +**Files:** + +- Modify: `front/tests/e2e/admin-health.spec.ts` +- Create: `front/tests/e2e/admin-notifications.spec.ts` + +- [ ] Update `admin-health.spec.ts`. + +When clicking the Outbox backlog card link, assert navigation to `/admin/notifications` and visible focus state. + +Use a route fulfill for: + +- `**/api/bff/api/admin/notifications/snapshot` +- `**/api/bff/api/admin/notifications/events**` +- `**/api/bff/api/admin/notifications/deliveries**` + +- [ ] Add `admin-notifications.spec.ts`. + +Cover: + +- page renders summary, events, deliveries; +- raw recipient email does not appear; +- preview returns `matchedCount`; +- confirm requires a reason; +- confirm success updates visible copy. + +- [ ] Run: + +```bash +pnpm --dir front exec playwright test tests/e2e/admin-health.spec.ts tests/e2e/admin-notifications.spec.ts --project=chromium +``` + +Expected after implementation: pass. + +## Task 8 — S3 Backend Snapshot Contract And Service + +**Files:** + +- Create: `server/src/main/kotlin/com/readmates/club/application/model/AdminClubOperationsModels.kt` +- Create: `server/src/main/kotlin/com/readmates/club/application/port/out/AdminClubOperationsPorts.kt` +- Create: `server/src/main/kotlin/com/readmates/club/application/service/AdminClubOperationsService.kt` +- Modify: `server/src/main/kotlin/com/readmates/club/application/port/in/PlatformAdminUseCases.kt` +- Test: `server/src/test/kotlin/com/readmates/club/application/service/AdminClubOperationsServiceTest.kt` + +- [ ] Add `AdminClubOperationsSnapshot` models. + +Use this shape: + +```kotlin +data class AdminClubOperationsSnapshot( + val schema: String = "admin.club_operations_snapshot.v1", + val generatedAt: OffsetDateTime, + val club: AdminClubOperationsClub, + val readiness: AdminClubReadinessSummary, + val memberActivity: AdminClubMemberActivity, + val sessionProgress: AdminClubSessionProgress, + val notificationHealth: AdminClubNotificationHealth, + val aiUsage: AdminClubAiUsage, + val safeLinks: List, +) +``` + +Include nested data classes for every field named in the spec. Counts are integers. Cost estimate is a string to match existing AI Ops cost contracts. + +- [ ] Add inbound use case. + +`PlatformAdminUseCases.kt`: + +```kotlin +interface GetAdminClubOperationsUseCase { + fun operationsSnapshot( + admin: CurrentPlatformAdmin, + clubId: UUID, + ): AdminClubOperationsSnapshot +} +``` + +- [ ] Add outbound port. + +`AdminClubOperationsPorts.kt`: + +```kotlin +interface AdminClubOperationsSnapshotPort { + fun loadSnapshot(clubId: UUID): AdminClubOperationsSnapshot? +} +``` + +- [ ] Implement service. + +Rules: + +- OWNER/OPERATOR/SUPPORT can read. +- If port returns null, throw `PlatformAdminException(PlatformAdminError.CLUB_NOT_FOUND, "Club not found")`. +- Service does not perform host-owned mutations. + +- [ ] Add service tests for read permission, not found, and schema value. + +- [ ] Run: + +```bash +./server/gradlew -p server unitTest --tests "com.readmates.club.application.service.AdminClubOperationsServiceTest" +``` + +Expected after implementation: pass. + +## Task 9 — S3 Backend Persistence And Controller + +**Files:** + +- Create: `server/src/main/kotlin/com/readmates/club/adapter/out/persistence/JdbcAdminClubOperationsAdapter.kt` +- Create: `server/src/main/kotlin/com/readmates/club/adapter/in/web/PlatformAdminClubOperationsController.kt` +- Test: `server/src/test/kotlin/com/readmates/club/api/PlatformAdminClubOperationsControllerTest.kt` + +- [ ] Implement snapshot SQL aggregation. + +Adapter output: + +- club identity from `clubs`; +- readiness from current club fields and domain status; +- member activity from `memberships`; +- session progress from `sessions`; +- notification health from `notification_deliveries`; +- AI usage from existing AI audit/job tables only when tables and query helpers already exist in current code; +- safe links for host app and admin notifications. + +If an AI aggregate query is unavailable in current code, return zero/insufficient state from the adapter and document the missing data in the snapshot's `aiUsage.state` field. Do not invent mock data. + +- [ ] Controller route. + +Expose: + +```kotlin +@GetMapping("/api/admin/clubs/{clubId}/operations") +fun operations(admin: CurrentPlatformAdmin, @PathVariable clubId: UUID): AdminClubOperationsSnapshotResponse +``` + +Response DTO uses camelCase names and maps the schema string unchanged. + +- [ ] Integration tests. + +`PlatformAdminClubOperationsControllerTest` should assert: + +- OWNER can read baseline club; +- SUPPORT can read aggregate snapshot; +- missing club returns 404; +- response contains `admin.club_operations_snapshot.v1`; +- response does not contain raw member emails or note/review body text. + +- [ ] Run: + +```bash +./server/gradlew -p server integrationTest --tests "com.readmates.club.api.PlatformAdminClubOperationsControllerTest" +``` + +Expected after implementation: pass. + +## Task 10 — S3 Frontend Contracts, Query, And Loader + +**Files:** + +- Create: `front/features/platform-admin/model/platform-admin-club-operations-model.ts` +- Create: `front/features/platform-admin/api/platform-admin-club-operations-api.ts` +- Create: `front/features/platform-admin/queries/platform-admin-club-operations-queries.ts` +- Modify: `front/features/platform-admin/api/platform-admin-contracts.ts` +- Modify: `front/features/platform-admin/route/admin-club-detail-data.ts` + +- [ ] Add TypeScript model matching server response. + +Use: + +```ts +export type AdminClubOperationsSnapshot = { + schema: "admin.club_operations_snapshot.v1"; + generatedAt: string; + club: { clubId: string; slug: string; name: string; status: string; publicVisibility: string }; + readiness: { state: string; blockingReasons: string[]; nextAction: string | null }; + memberActivity: { activeCount: number; dormantCount: number; pendingViewerCount: number; hostCount: number }; + sessionProgress: { upcomingCount: number; currentOpenCount: number; closedCount: number; publishedRecordCount: number; incompleteRecordCount: number }; + notificationHealth: { pending: number; failed: number; dead: number; lastSuccessAt: string | null; failureClusters: Array<{ safeErrorCode: string; count: number }> }; + aiUsage: { activeJobs: number; failedRecentJobs: number; staleCandidates: number; costEstimateUsd: string; state: string }; + safeLinks: Array<{ label: string; href: string; kind: "ADMIN_ROUTE" | "HOST_ROUTE" }>; +}; +``` + +- [ ] Add API and Query helper. + +`fetchAdminClubOperationsSnapshot(clubId)` calls `/api/admin/clubs/${clubId}/operations`. + +Query key: + +```ts +["platform-admin", "club-operations", clubId] +``` + +- [ ] Update loader. + +`adminClubDetailLoaderFactory` should fetch: + +- `platformAdminClubsQuery()`; +- `platformAdminSupportGrantsQuery(clubId)`; +- `platformAdminClubOperationsQuery(clubId)`. + +- [ ] Run: + +```bash +pnpm --dir front exec vitest run features/platform-admin/route/admin-club-detail-route.test.tsx +``` + +Expected before route UI changes: tests may fail because route has not consumed the new query yet. + +## Task 11 — S3 Frontend UI And E2E + +**Files:** + +- Create: `front/features/platform-admin/ui/admin-club-operations-page.tsx` +- Create: `front/features/platform-admin/ui/admin-club-operations-page.test.tsx` +- Modify: `front/features/platform-admin/route/admin-club-detail-route.tsx` +- Modify: `front/features/platform-admin/route/admin-club-detail-route.test.tsx` +- Modify: `front/src/styles/globals.css` +- Create: `front/tests/e2e/admin-club-operations.spec.ts` + +- [ ] Build `AdminClubOperationsPage`. + +Props: + +```ts +type AdminClubOperationsPageProps = { + snapshot: AdminClubOperationsSnapshot; + supportGrantCount: number; +}; +``` + +Sections: + +- club identity and readiness; +- member activity aggregate; +- session progress aggregate; +- notification health with link to `/admin/notifications?clubId=${clubId}`; +- AI usage with link to `/admin/ai-ops?clubId=${clubId}`; +- safe links. + +Do not render host-owned command buttons. + +- [ ] Update route. + +`AdminClubDetailRoute` should render `AdminClubOperationsPage` when snapshot data exists. Keep the not-found state for unknown `clubId`. + +- [ ] Add UI tests. + +Assert: + +- snapshot heading renders; +- notification link includes selected club id; +- support grant count is shown as a summary; +- no button text for RSVP, attendance, session edit, or publication body exists. + +- [ ] Add E2E fixture. + +`admin-club-operations.spec.ts` should mock: + +- auth/me as platform OWNER; +- admin summary; +- admin clubs with one club; +- support grants empty list; +- club operations snapshot. + +Assert the operations route renders and raw email text is absent. + +- [ ] Run: + +```bash +pnpm --dir front exec vitest run features/platform-admin/route/admin-club-detail-route.test.tsx features/platform-admin/ui/admin-club-operations-page.test.tsx +pnpm --dir front exec playwright test tests/e2e/admin-club-operations.spec.ts --project=chromium +``` + +Expected after implementation: pass. + +## Task 12 — S4 Backend Search And Grant Validation + +**Files:** + +- Modify: `server/src/main/kotlin/com/readmates/club/application/PlatformAdminException.kt` +- Create: `server/src/main/kotlin/com/readmates/club/application/model/AdminSupportModels.kt` +- Create: `server/src/main/kotlin/com/readmates/club/application/port/out/AdminSupportPorts.kt` +- Create: `server/src/main/kotlin/com/readmates/club/application/service/AdminSupportWorkbenchService.kt` +- Modify: `server/src/main/kotlin/com/readmates/club/application/port/in/SupportAccessGrantUseCases.kt` +- Modify: `server/src/main/kotlin/com/readmates/club/application/service/SupportAccessGrantService.kt` +- Modify: `server/src/main/kotlin/com/readmates/club/adapter/out/persistence/JdbcSupportAccessGrantAdapter.kt` +- Create: `server/src/main/kotlin/com/readmates/club/adapter/out/persistence/JdbcAdminSupportSearchAdapter.kt` +- Test: `server/src/test/kotlin/com/readmates/club/application/service/AdminSupportWorkbenchServiceTest.kt` +- Test: `server/src/test/kotlin/com/readmates/club/application/service/SupportAccessGrantServiceTest.kt` + +- [ ] Extend `PlatformAdminError`. + +Add: + +```kotlin +SUPPORT_TARGET_NOT_FOUND, +SUPPORT_TARGET_NOT_ELIGIBLE, +GRANT_EXPIRY_REQUIRED, +GRANT_EXPIRY_IN_PAST, +GRANT_EXPIRY_TOO_LONG, +GRANT_DUPLICATE_ACTIVE, +``` + +- [ ] Add support models. + +`AdminSupportModels.kt`: + +```kotlin +data class AdminSupportSearchResult( + val subjectId: UUID, + val displayName: String, + val maskedEmail: String, + val kind: String, + val platformAdminRole: PlatformAdminRole?, + val platformAdminStatus: String?, + val clubMembershipSummary: List, + val grantEligible: Boolean, + val grantBlockedReason: String?, +) + +data class AdminSupportGrantLedgerItem( + val grantId: UUID, + val clubId: UUID, + val clubName: String, + val granteeUserId: UUID, + val granteeDisplayName: String, + val granteeMaskedEmail: String, + val scope: SupportAccessGrantScope, + val reason: String, + val expiresAt: OffsetDateTime, + val createdAt: OffsetDateTime, + val revokedAt: OffsetDateTime?, + val status: String, + val createdByRole: String, +) +``` + +- [ ] Add search port. + +`AdminSupportPorts.kt`: + +```kotlin +interface AdminSupportSearchPort { + fun search(query: String, clubId: UUID?, limit: Int): List +} + +interface AdminSupportGrantLedgerPort { + fun listLedger(clubId: UUID?, granteeUserId: UUID?, limit: Int): List + fun hasActiveGrant(clubId: UUID, granteeUserId: UUID): Boolean + fun isGrantEligibleClub(clubId: UUID): Boolean +} +``` + +- [ ] Implement support search service. + +Rules: + +- OWNER and OPERATOR can search. +- SUPPORT gets only empty results unless a future task adds assigned-work context. +- Search trims input and rejects blank input. +- Limit result count to 10. +- Output is masked. + +- [ ] Strengthen `SupportAccessGrantService`. + +Add constructor dependencies for `AdminSupportGrantLedgerPort` and `AdminSupportSearchPort` only if needed for eligibility checks. Validate: + +- reason non-blank; +- expiry future; +- expiry not beyond 24 hours by default; +- grantee is active platform admin; +- selected club is grant eligible; +- no duplicate active grant. + +Keep existing `/api/admin/support-access-grants` compatible. It can still receive `granteeUserId`, but invalid grantees must fail safely. + +- [ ] Unit tests. + +Add tests for each validation rule and for OPERATOR/SUPPORT denial on create/revoke. + +- [ ] Run: + +```bash +./server/gradlew -p server unitTest --tests "com.readmates.club.application.service.AdminSupportWorkbenchServiceTest" --tests "com.readmates.club.application.service.SupportAccessGrantServiceTest" +``` + +Expected after implementation: pass. + +## Task 13 — S4 Backend Workbench Controller + +**Files:** + +- Create: `server/src/main/kotlin/com/readmates/club/adapter/in/web/PlatformAdminSupportWorkbenchController.kt` +- Test: `server/src/test/kotlin/com/readmates/club/api/PlatformAdminSupportWorkbenchControllerTest.kt` +- Modify: `server/src/test/kotlin/com/readmates/club/api/SupportAccessGrantControllerTest.kt` + +- [ ] Add workbench routes. + +Expose: + +```kotlin +@GetMapping("/api/admin/support/search") +fun search(admin: CurrentPlatformAdmin, @RequestParam query: String, @RequestParam(required = false) clubId: UUID?): List + +@GetMapping("/api/admin/support/grants") +fun grants(admin: CurrentPlatformAdmin, @RequestParam(required = false) clubId: UUID?, @RequestParam(required = false) granteeUserId: UUID?): List + +@PostMapping("/api/admin/support/grants") +fun create(admin: CurrentPlatformAdmin, @RequestBody request: AdminSupportGrantRequest): SupportAccessGrantResponse + +@DeleteMapping("/api/admin/support/grants/{grantId}") +@ResponseStatus(HttpStatus.NO_CONTENT) +fun revoke(admin: CurrentPlatformAdmin, @PathVariable grantId: UUID) +``` + +New create route delegates to existing `CreateSupportAccessGrantUseCase` after converting `granteeSubjectId` to `granteeUserId`. + +- [ ] Controller tests. + +Assert: + +- OWNER search returns masked email; +- OPERATOR search returns masked email; +- SUPPORT search returns limited safe context or empty result as implemented in Task 12; +- create without a selected active platform admin returns bad request; +- create with valid selected grantee returns OK; +- revoke returns no content; +- old `/api/admin/support-access-grants` still rejects ineligible grantee. + +- [ ] Run: + +```bash +./server/gradlew -p server integrationTest --tests "com.readmates.club.api.PlatformAdminSupportWorkbenchControllerTest" --tests "com.readmates.club.api.SupportAccessGrantControllerTest" +``` + +Expected after implementation: pass. + +## Task 14 — S4 Frontend Contracts, API, Query, And Loader + +**Files:** + +- Create: `front/features/platform-admin/model/platform-admin-support-model.ts` +- Create: `front/features/platform-admin/api/platform-admin-support-api.ts` +- Create: `front/features/platform-admin/queries/platform-admin-support-queries.ts` +- Create: `front/features/platform-admin/route/admin-support-data.ts` +- Modify: `front/features/platform-admin/api/platform-admin-contracts.ts` +- Modify: `front/src/app/routes/admin.tsx` + +- [ ] Add support contracts. + +`platform-admin-support-model.ts`: + +```ts +export type AdminSupportSearchResult = { + subjectId: string; + displayName: string; + maskedEmail: string; + kind: string; + platformAdminRole: "OWNER" | "OPERATOR" | "SUPPORT" | null; + platformAdminStatus: string | null; + clubMembershipSummary: Array<{ clubId: string; clubName: string; role: string; status: string }>; + grantEligible: boolean; + grantBlockedReason: string | null; +}; + +export type AdminSupportGrantLedgerItem = { + grantId: string; + clubId: string; + clubName: string; + granteeUserId: string; + granteeDisplayName: string; + granteeMaskedEmail: string; + scope: "METADATA_READ" | "HOST_SUPPORT_READ"; + reason: string; + expiresAt: string; + createdAt: string; + revokedAt: string | null; + status: string; + createdByRole: string; +}; + +export type AdminSupportGrantRequest = { + clubId: string; + granteeSubjectId: string; + scope: "METADATA_READ" | "HOST_SUPPORT_READ"; + reason: string; + expiresAt: string; +}; +``` + +- [ ] Add API functions. + +`platform-admin-support-api.ts`: + +```ts +import { readmatesFetch } from "@/shared/api/client"; +import type { + AdminSupportGrantLedgerItem, + AdminSupportGrantRequest, + AdminSupportSearchResult, +} from "@/features/platform-admin/model/platform-admin-support-model"; +import type { SupportAccessGrantResponse } from "@/features/platform-admin/api/platform-admin-contracts"; + +export function searchAdminSupportSubjects(query: string, clubId?: string) { + const params = new URLSearchParams({ query }); + if (clubId) params.set("clubId", clubId); + return readmatesFetch( + `/api/admin/support/search?${params.toString()}`, + undefined, + { clubSlug: undefined }, + ); +} + +export function fetchAdminSupportGrantLedger(filters: { clubId?: string; granteeUserId?: string } = {}) { + const params = new URLSearchParams(); + if (filters.clubId) params.set("clubId", filters.clubId); + if (filters.granteeUserId) params.set("granteeUserId", filters.granteeUserId); + const search = params.toString(); + return readmatesFetch( + `/api/admin/support/grants${search ? `?${search}` : ""}`, + undefined, + { clubSlug: undefined }, + ); +} + +export function createAdminSupportGrant(request: AdminSupportGrantRequest) { + return readmatesFetch( + "/api/admin/support/grants", + { method: "POST", body: JSON.stringify(request) }, + { clubSlug: undefined }, + ); +} + +export function revokeAdminSupportGrant(grantId: string) { + return readmatesFetch( + `/api/admin/support/grants/${encodeURIComponent(grantId)}`, + { method: "DELETE" }, + { clubSlug: undefined }, + ); +} +``` + +All calls must pass `{ clubSlug: undefined }`. + +- [ ] Add Query helpers and loader. + +Use query keys under `["platform-admin", "support"]`. + +`adminSupportLoaderFactory(queryClient)` should seed platform clubs for the club selector and support ledger if `clubId` exists in the URL. + +- [ ] Wire ready route loader in `front/src/app/routes/admin.tsx` for the existing `support` case. + +- [ ] Run: + +```bash +pnpm --dir front exec vitest run features/platform-admin/route/admin-support-route.test.tsx +``` + +Expected before UI replacement: existing test may fail because copy changes in Task 15. + +## Task 15 — S4 Frontend Workbench UI And E2E + +**Files:** + +- Create: `front/features/platform-admin/ui/admin-support-workbench.tsx` +- Create: `front/features/platform-admin/ui/admin-support-workbench.test.tsx` +- Modify: `front/features/platform-admin/route/admin-support-route.tsx` +- Modify: `front/features/platform-admin/route/admin-support-route.test.tsx` +- Modify: `front/src/styles/globals.css` +- Create: `front/tests/e2e/admin-support.spec.ts` + +- [ ] Build `AdminSupportWorkbench`. + +Props: + +```ts +type AdminSupportWorkbenchProps = { + clubs: PlatformAdminClub[]; + selectedClubId: string | null; + query: string; + results: AdminSupportSearchResult[]; + selectedResult: AdminSupportSearchResult | null; + ledger: AdminSupportGrantLedgerItem[]; + reason: string; + expiresAt: string; + busy: boolean; + error: string | null; + canCreateGrant: boolean; + onQueryChange: (value: string) => void; + onSearch: () => Promise; + onSelectResult: (result: AdminSupportSearchResult) => void; + onClubChange: (clubId: string) => void; + onReasonChange: (value: string) => void; + onExpiresAtChange: (value: string) => void; + onCreateGrant: () => Promise; + onRevokeGrant: (grantId: string) => Promise; +}; +``` + +Rules: + +- grant form is hidden until `selectedResult` exists; +- create button disabled unless selected result is grant eligible, club selected, reason non-blank, expiry present, and `canCreateGrant` true; +- raw UUID-only input does not appear as the primary grant control; +- errors render inline. + +- [ ] Update route. + +`AdminSupportRoute` owns search query state, selected result state, selected club URL state, and mutations. + +Use copy: + +- search heading: `지원 대상 검색` +- grant heading: `지원 접근 권한 발급` +- role denial: `현재 역할은 지원 접근 권한을 발급할 수 없습니다.` +- empty results: `검색 결과가 없습니다.` + +- [ ] Add UI tests. + +Assert: + +- search field renders before grant form; +- grant form is absent before selection; +- selecting an eligible result reveals grant form; +- create disabled without reason; +- raw `Grantee User ID` label is absent; +- revoke button calls callback with grant id. + +- [ ] Add E2E. + +Mock: + +- auth/me as OWNER; +- admin summary and clubs; +- support search result with masked email; +- support grants list; +- create grant response; +- revoke success. + +Assert search -> select -> create -> revoke, and raw email is absent. + +- [ ] Run: + +```bash +pnpm --dir front exec vitest run features/platform-admin/route/admin-support-route.test.tsx features/platform-admin/ui/admin-support-workbench.test.tsx +pnpm --dir front exec playwright test tests/e2e/admin-support.spec.ts --project=chromium +``` + +Expected after implementation: pass. + +## Task 16 — Release Notes, Architecture Docs, And Final Verification + +**Files:** + +- Modify: `CHANGELOG.md` +- Modify: `docs/development/architecture.md` +- Modify: `docs/development/server-state-migration.md` + +- [ ] Add CHANGELOG entries only for phases that shipped. + +Suggested `Unreleased` engineering bullets: + +```markdown +- **platform-admin:** `/admin/notifications` is now an actionable notification/outbox operations route with masked ledgers, failure clusters, and two-step replay. +- **platform-admin:** `/admin/clubs/:id` now shows a read-mostly operations snapshot for member activity, session progress, notification health, AI usage, and readiness. +- **platform-admin:** `/admin/support` now uses search-based support grants, requiring a resolved safe result before grant creation. +``` + +- [ ] Update `docs/development/architecture.md` only if shipped code changes product boundaries or endpoint lists. + +Add concise notes under platform admin / notification sections: + +- `/admin/notifications` owns cross-club notification operations and replay; +- `/admin/clubs/:id` owns aggregate operations snapshots, not host commands; +- `/admin/support` owns support search and support grants. + +- [ ] Update `docs/development/server-state-migration.md` if new platform-admin query modules ship. + +Add a completed bullet for platform-admin notifications, club operations, and support workbench Query ownership. + +- [ ] Run targeted checks: + +```bash +git diff --check -- CHANGELOG.md docs/development/architecture.md docs/development/server-state-migration.md +./scripts/build-public-release-candidate.sh +./scripts/public-release-check.sh .tmp/public-release-candidate +``` + +Expected: no trailing whitespace and public-release scanner passes. + +- [ ] Run integrated verification: + +```bash +pnpm --dir front lint +pnpm --dir front test +pnpm --dir front build +pnpm --dir front test:e2e +./server/gradlew -p server clean test +./server/gradlew -p server architectureTest +./scripts/build-public-release-candidate.sh +./scripts/public-release-check.sh .tmp/public-release-candidate +graphify update . +git diff --check +git status --short +``` + +Expected: all checks pass and final status contains only intentional changed files before staging. + +## Execution Notes + +- Keep commits phase-local if executing manually: S5 backend, S5 frontend, S3 backend, S3 frontend, S4 backend, S4 frontend, release docs. +- Do not expose raw recipient emails, raw SMTP/provider errors, generated AI result JSON, private notes, local paths, deployment identifiers, or token-shaped examples in code fixtures or docs. +- If a verification command mutates generated artifacts, re-run the relevant targeted tests after staging the changed generated files. +- Run `graphify update .` after meaningful code changes so local graph discovery remains current. diff --git a/docs/superpowers/plans/2026-05-27-readmates-architecture-flexibility-implementation-plan.md b/docs/superpowers/plans/2026-05-27-readmates-architecture-flexibility-implementation-plan.md new file mode 100644 index 00000000..77707a59 --- /dev/null +++ b/docs/superpowers/plans/2026-05-27-readmates-architecture-flexibility-implementation-plan.md @@ -0,0 +1,1148 @@ +# ReadMates Architecture Flexibility Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Strengthen ReadMates' clean architecture, route-first frontend boundaries, and vertical-slice working rules without changing product behavior. + +**Architecture:** Start with boundary tests because the value of this work is enforcement, then fix the concrete pilot violations that those tests reveal. Keep the app in one Spring Boot module and one Vite frontend, but classify server slices and frontend feature layers explicitly so future features fail fast when they cross boundaries. + +**Tech Stack:** Kotlin/Spring Boot, ArchUnit, React/Vite, React Router 7, TanStack Query v5, Vitest, Markdown docs. + +--- + +## Source Spec + +- `docs/superpowers/specs/2026-05-27-readmates-architecture-flexibility-design.md` + +## File Structure + +Server boundary work: + +- Modify `server/src/test/kotlin/com/readmates/architecture/ServerArchitectureBoundaryTest.kt` + - Owns server slice registry and ArchUnit/source-scan rules. +- Create `server/src/main/kotlin/com/readmates/aigen/application/model/AiGenerationActor.kt` + - Application-safe caller identity value object that replaces `CurrentMember` in aigen application ports/services. +- Modify `server/src/main/kotlin/com/readmates/aigen/application/port/in/AiGenerationUseCases.kt` + - Replace `CurrentMember` in `CommitGenerationUseCase`. +- Modify `server/src/main/kotlin/com/readmates/aigen/application/port/in/ClubAiDefaultsUseCases.kt` + - Replace `CurrentMember` in AI defaults ports. +- Modify `server/src/main/kotlin/com/readmates/aigen/application/service/AiGenerationCommitService.kt` + - Use `AiGenerationActor` instead of `CurrentMember`. +- Modify `server/src/main/kotlin/com/readmates/aigen/application/service/ClubAiDefaultsService.kt` + - Use `AiGenerationActor` instead of `CurrentMember`. +- Create `server/src/main/kotlin/com/readmates/aigen/application/port/in/AiGenerationAuthorizationUseCases.kt` + - Input port for session-level aigen authorization. +- Create `server/src/main/kotlin/com/readmates/aigen/application/port/out/LoadAiGenerationSessionMetaPort.kt` + - Outbound port for loading `SessionMeta`. +- Create `server/src/main/kotlin/com/readmates/aigen/application/service/AiGenerationAuthorizationService.kt` + - Application service that checks host access using `AiGenerationActor`. +- Create `server/src/main/kotlin/com/readmates/aigen/adapter/out/persistence/JdbcAiGenerationSessionMetaAdapter.kt` + - JDBC implementation of `LoadAiGenerationSessionMetaPort`. +- Modify `server/src/main/kotlin/com/readmates/aigen/adapter/in/web/AiGenerationAuthorizationPolicy.kt` + - Keep only the web-facing policy interface and a small adapter from `CurrentMember` to `AiGenerationActor`. +- Modify `server/src/main/kotlin/com/readmates/aigen/adapter/in/web/AiGenerationController.kt` + - Pass `AiGenerationActor` to application ports. +- Modify `server/src/main/kotlin/com/readmates/aigen/adapter/in/web/ClubAiDefaultsController.kt` + - Pass `AiGenerationActor` to application ports. + +Frontend boundary work: + +- Modify `front/tests/unit/frontend-boundaries.test.ts` + - Add `queries` layer rules and generalize boundary helpers. +- Modify `front/features/platform-admin/ui/admin-health-grid.tsx` + - Convert from query-owning component to props/callback presentation. +- Modify `front/features/platform-admin/route/admin-health-route.tsx` + - Own `useQuery`, stale timer, refresh callback, and data-to-UI assembly. +- Modify `front/features/platform-admin/ui/admin-health-grid.test.tsx` + - Test props-driven rendering and refresh callback instead of API fetch. + +Documentation work: + +- Modify `docs/development/architecture.md` + - Add slice classification and frontend `queries` layer rules. +- Modify `docs/development/adr/0002-server-clean-architecture-with-archunit.md` + - Update follow-up status around slice registry and aigen workflow-side rules. +- Modify `docs/development/adr/0003-frontend-route-first-architecture.md` + - Update route-first architecture with `queries` and shared promotion criteria. +- Modify `docs/agents/front.md` + - Add `queries` layer and vertical-slice checklist references. +- Modify `docs/agents/server.md` + - Add server slice types and aigen workflow-side guidance. +- Modify `docs/agents/docs.md` + - Add architecture-flexibility doc update guidance. +- Create `docs/development/vertical-slice-checklist.md` + - One-page checklist for feature changes crossing frontend/server/BFF/tests. + +## Task 1: Add Server Slice Registry Boundary Tests + +**Files:** +- Modify: `server/src/test/kotlin/com/readmates/architecture/ServerArchitectureBoundaryTest.kt` + +- [ ] **Step 1: Replace hard-coded migrated package arrays with a slice registry** + +In `ServerArchitectureBoundaryTest`, replace the two array properties with this registry and derived arrays: + +```kotlin + private enum class ServerSliceType { + WRITE, + READ, + OPS_READ, + WORKFLOW, + SHARED, + } + + private data class ServerSlice( + val name: String, + val type: ServerSliceType, + val webAdapterPackages: List = emptyList(), + val applicationPackages: List = emptyList(), + ) + + private val serverSlices = + listOf( + ServerSlice( + name = "session", + type = ServerSliceType.WRITE, + webAdapterPackages = listOf("com.readmates.session.adapter.in.web.."), + applicationPackages = listOf("com.readmates.session.application.."), + ), + ServerSlice( + name = "note", + type = ServerSliceType.READ, + webAdapterPackages = listOf("com.readmates.note.adapter.in.web.."), + applicationPackages = listOf("com.readmates.note.application.."), + ), + ServerSlice( + name = "publication", + type = ServerSliceType.READ, + webAdapterPackages = listOf("com.readmates.publication.adapter.in.web.."), + applicationPackages = listOf("com.readmates.publication.application.."), + ), + ServerSlice( + name = "archive", + type = ServerSliceType.READ, + webAdapterPackages = listOf("com.readmates.archive.adapter.in.web.."), + applicationPackages = listOf("com.readmates.archive.application.."), + ), + ServerSlice( + name = "feedback", + type = ServerSliceType.WORKFLOW, + webAdapterPackages = listOf("com.readmates.feedback.adapter.in.web.."), + applicationPackages = listOf("com.readmates.feedback.application.."), + ), + ServerSlice( + name = "auth", + type = ServerSliceType.WRITE, + webAdapterPackages = listOf("com.readmates.auth.adapter.in.web.."), + applicationPackages = listOf("com.readmates.auth.application.."), + ), + ServerSlice( + name = "notification", + type = ServerSliceType.WRITE, + webAdapterPackages = listOf("com.readmates.notification.adapter.in.web.."), + applicationPackages = listOf("com.readmates.notification.application.."), + ), + ServerSlice( + name = "club", + type = ServerSliceType.WRITE, + webAdapterPackages = listOf("com.readmates.club.adapter.in.web.."), + applicationPackages = listOf("com.readmates.club.application.."), + ), + ServerSlice( + name = "admin.audit", + type = ServerSliceType.READ, + webAdapterPackages = listOf("com.readmates.admin.audit.adapter.in.web.."), + applicationPackages = listOf("com.readmates.admin.audit.application.."), + ), + ServerSlice( + name = "admin.health", + type = ServerSliceType.OPS_READ, + webAdapterPackages = listOf("com.readmates.admin.health.adapter.in.web.."), + applicationPackages = listOf("com.readmates.admin.health.application.."), + ), + ServerSlice( + name = "aigen", + type = ServerSliceType.WORKFLOW, + webAdapterPackages = listOf("com.readmates.aigen.adapter.in.web.."), + applicationPackages = listOf("com.readmates.aigen.application.."), + ), + ServerSlice( + name = "shared", + type = ServerSliceType.SHARED, + webAdapterPackages = listOf("com.readmates.shared.adapter.in.web.."), + ), + ) + + private val migratedWebAdapterPackages = + serverSlices.flatMap(ServerSlice::webAdapterPackages).toTypedArray() + + private val migratedApplicationPackages = + serverSlices.flatMap(ServerSlice::applicationPackages).toTypedArray() +``` + +- [ ] **Step 2: Add a registry coverage test** + +Add this test after the registry: + +```kotlin + @Test + fun `server architecture registry includes recent admin and aigen slices`() { + val registered = serverSlices.map(ServerSlice::name).toSet() + + assertTrue( + registered.containsAll(setOf("admin.audit", "admin.health", "aigen")), + "Server slice registry must include admin.audit, admin.health, and aigen.", + ) + } +``` + +- [ ] **Step 3: Add a source rule that keeps aigen application free of CurrentMember** + +Add this test near the existing `application packages do not depend on spring web http or security types` rule: + +```kotlin + @Test + fun `aigen application does not depend on web current member`() { + val violations = + sourceRoot() + .resolve("com/readmates/aigen/application") + .takeIf(Files::exists) + ?.let { root -> + Files + .walk(root) + .use { paths -> + paths + .filter { it.name.endsWith(".kt") } + .flatMap { sourceFile -> + sourceFile + .readLines() + .mapIndexedNotNull { index, line -> + if ("CurrentMember" in line) { + "${sourceFile.relativeTo(sourceRoot())}:${index + 1}: ${line.trim()}" + } else { + null + } + }.stream() + }.toList() + } + } ?: emptyList() + + assertTrue( + violations.isEmpty(), + "Aigen application code must use application-safe actor values instead of CurrentMember:\n" + + violations.joinToString("\n"), + ) + } +``` + +- [ ] **Step 4: Run the focused architecture test and confirm the intended failure** + +Run: + +```bash +./server/gradlew -p server architectureTest --tests com.readmates.architecture.ServerArchitectureBoundaryTest +``` + +Expected: FAIL. The failure should include `DefaultAiGenerationAuthorizationPolicy` depending on `JdbcTemplate` from `adapter.in.web`, and aigen application files referencing `CurrentMember`. + +- [ ] **Step 5: Commit the failing test only if using a red/green branch workflow** + +If the execution session is committing every red/green checkpoint, use: + +```bash +git add server/src/test/kotlin/com/readmates/architecture/ServerArchitectureBoundaryTest.kt +git commit -m "test: register server architecture slices" +``` + +If the execution session keeps red tests unstaged until green, leave the file unstaged and continue to Task 2. + +## Task 2: Move Aigen Authorization and Actor Identity Behind Application Boundaries + +**Files:** +- Create: `server/src/main/kotlin/com/readmates/aigen/application/model/AiGenerationActor.kt` +- Create: `server/src/main/kotlin/com/readmates/aigen/application/port/in/AiGenerationAuthorizationUseCases.kt` +- Create: `server/src/main/kotlin/com/readmates/aigen/application/port/out/LoadAiGenerationSessionMetaPort.kt` +- Create: `server/src/main/kotlin/com/readmates/aigen/application/service/AiGenerationAuthorizationService.kt` +- Create: `server/src/main/kotlin/com/readmates/aigen/adapter/out/persistence/JdbcAiGenerationSessionMetaAdapter.kt` +- Modify: `server/src/main/kotlin/com/readmates/aigen/adapter/in/web/AiGenerationAuthorizationPolicy.kt` +- Modify: `server/src/main/kotlin/com/readmates/aigen/adapter/in/web/AiGenerationController.kt` +- Modify: `server/src/main/kotlin/com/readmates/aigen/adapter/in/web/ClubAiDefaultsController.kt` +- Modify: `server/src/main/kotlin/com/readmates/aigen/application/port/in/AiGenerationUseCases.kt` +- Modify: `server/src/main/kotlin/com/readmates/aigen/application/port/in/ClubAiDefaultsUseCases.kt` +- Modify: `server/src/main/kotlin/com/readmates/aigen/application/service/AiGenerationCommitService.kt` +- Modify: `server/src/main/kotlin/com/readmates/aigen/application/service/ClubAiDefaultsService.kt` + +- [ ] **Step 1: Create the application-safe actor model** + +Create `server/src/main/kotlin/com/readmates/aigen/application/model/AiGenerationActor.kt`: + +```kotlin +package com.readmates.aigen.application.model + +import java.util.UUID + +data class AiGenerationActor( + val userId: UUID, + val clubId: UUID, + val clubSlug: String, + val isHost: Boolean, +) +``` + +- [ ] **Step 2: Add the authorization input port** + +Create `server/src/main/kotlin/com/readmates/aigen/application/port/in/AiGenerationAuthorizationUseCases.kt`: + +```kotlin +package com.readmates.aigen.application.port.`in` + +import com.readmates.aigen.application.model.AiGenerationActor +import com.readmates.aigen.application.model.SessionMeta +import java.util.UUID + +interface AuthorizeAiGenerationSessionUseCase { + fun authorize( + sessionId: UUID, + actor: AiGenerationActor, + ): SessionMeta +} +``` + +- [ ] **Step 3: Add the outbound session metadata port** + +Create `server/src/main/kotlin/com/readmates/aigen/application/port/out/LoadAiGenerationSessionMetaPort.kt`: + +```kotlin +package com.readmates.aigen.application.port.out + +import com.readmates.aigen.application.model.SessionMeta +import java.util.UUID + +interface LoadAiGenerationSessionMetaPort { + fun load(sessionId: UUID): SessionMeta? +} +``` + +- [ ] **Step 4: Add the application authorization service** + +Create `server/src/main/kotlin/com/readmates/aigen/application/service/AiGenerationAuthorizationService.kt`: + +```kotlin +package com.readmates.aigen.application.service + +import com.readmates.aigen.application.model.AiGenerationActor +import com.readmates.aigen.application.model.SessionMeta +import com.readmates.aigen.application.port.`in`.AuthorizeAiGenerationSessionUseCase +import com.readmates.aigen.application.port.out.LoadAiGenerationSessionMetaPort +import com.readmates.shared.security.AccessDeniedException +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.stereotype.Service +import java.util.UUID + +@Service +@ConditionalOnProperty(prefix = "readmates.aigen", name = ["enabled"], havingValue = "true") +class AiGenerationAuthorizationService( + private val sessionMetaPort: LoadAiGenerationSessionMetaPort, +) : AuthorizeAiGenerationSessionUseCase { + override fun authorize( + sessionId: UUID, + actor: AiGenerationActor, + ): SessionMeta { + val meta = sessionMetaPort.load(sessionId) + ?: throw AccessDeniedException("Session $sessionId not found") + if (meta.clubId != actor.clubId || !actor.isHost) { + throw AccessDeniedException("Host access to session $sessionId is required") + } + return meta + } +} +``` + +- [ ] **Step 5: Move JDBC session metadata loading to an outbound adapter** + +Create `server/src/main/kotlin/com/readmates/aigen/adapter/out/persistence/JdbcAiGenerationSessionMetaAdapter.kt`: + +```kotlin +package com.readmates.aigen.adapter.out.persistence + +import com.readmates.aigen.application.model.AuthorNameMode +import com.readmates.aigen.application.model.SessionMeta +import com.readmates.aigen.application.port.out.LoadAiGenerationSessionMetaPort +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.stereotype.Component +import java.time.LocalDate +import java.util.UUID + +@Component +@ConditionalOnProperty(prefix = "readmates.aigen", name = ["enabled"], havingValue = "true") +class JdbcAiGenerationSessionMetaAdapter( + private val jdbc: JdbcTemplate, +) : LoadAiGenerationSessionMetaPort { + override fun load(sessionId: UUID): SessionMeta? { + val row = + jdbc + .queryForList( + """ + select s.club_id, s.number, s.book_title, s.book_author, s.session_date + from sessions s + where s.id = ? + """.trimIndent(), + sessionId.toString(), + ).firstOrNull() + ?: return null + + val expectedAuthorNames = + jdbc.queryForList( + """ + select u.name + from session_participants sp + join memberships m on m.id = sp.membership_id + join users u on u.id = m.user_id + where sp.session_id = ? + and sp.participation_status = 'ACTIVE' + order by sp.id + """.trimIndent(), + String::class.java, + sessionId.toString(), + ) + + return SessionMeta( + sessionId = sessionId, + clubId = UUID.fromString(row["club_id"] as String), + sessionNumber = (row["number"] as Number).toInt(), + bookTitle = row["book_title"] as String, + bookAuthor = row["book_author"] as String?, + meetingDate = (row["session_date"] as java.sql.Date).toLocalDate() ?: LocalDate.now(), + expectedAuthorNames = expectedAuthorNames, + authorNameMode = AuthorNameMode.REAL, + ) + } +} +``` + +- [ ] **Step 6: Keep the web policy as a thin adapter** + +Replace `server/src/main/kotlin/com/readmates/aigen/adapter/in/web/AiGenerationAuthorizationPolicy.kt` with: + +```kotlin +package com.readmates.aigen.adapter.`in`.web + +import com.readmates.aigen.application.model.AiGenerationActor +import com.readmates.aigen.application.model.SessionMeta +import com.readmates.aigen.application.port.`in`.AuthorizeAiGenerationSessionUseCase +import com.readmates.shared.security.CurrentMember +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.stereotype.Component +import java.util.UUID + +interface AiGenerationAuthorizationPolicy { + fun requireHostAccess( + sessionId: UUID, + member: CurrentMember, + ): SessionMeta + + fun actor(member: CurrentMember): AiGenerationActor = + AiGenerationActor( + userId = member.userId, + clubId = member.clubId, + clubSlug = member.clubSlug, + isHost = member.isHost, + ) +} + +@Component +@ConditionalOnProperty(prefix = "readmates.aigen", name = ["enabled"], havingValue = "true") +class DefaultAiGenerationAuthorizationPolicy( + private val authorizeSession: AuthorizeAiGenerationSessionUseCase, +) : AiGenerationAuthorizationPolicy { + override fun requireHostAccess( + sessionId: UUID, + member: CurrentMember, + ): SessionMeta = authorizeSession.authorize(sessionId, actor(member)) +} +``` + +- [ ] **Step 7: Replace aigen application ports that accept CurrentMember** + +In `AiGenerationUseCases.kt`, replace the `CurrentMember` import with `AiGenerationActor`, and change `CommitGenerationUseCase`: + +```kotlin +interface CommitGenerationUseCase { + fun commit( + host: AiGenerationActor, + sessionId: UUID, + jobId: UUID, + recordVisibility: SessionRecordVisibility, + overrideResult: SessionImportV1Snapshot?, + ): SessionImportCommitResult +} +``` + +In `ClubAiDefaultsUseCases.kt`, replace the `CurrentMember` import with `AiGenerationActor`, and change both ports: + +```kotlin +interface GetClubAiDefaultsUseCase { + fun get( + clubSlug: String, + actor: AiGenerationActor, + ): ClubAiDefaultsView +} + +interface UpdateClubAiDefaultsUseCase { + fun update( + clubSlug: String, + defaultModel: String, + actor: AiGenerationActor, + ) +} +``` + +- [ ] **Step 8: Update aigen services to use AiGenerationActor** + +In `AiGenerationCommitService`, replace the `CurrentMember` import with `AiGenerationActor`, and change the method signature: + +```kotlin + override fun commit( + host: AiGenerationActor, + sessionId: UUID, + jobId: UUID, + recordVisibility: SessionRecordVisibility, + overrideResult: SessionImportV1Snapshot?, + ): SessionImportCommitResult { +``` + +Keep existing `host.userId`, `host.clubId`, and `host.clubSlug` property reads. The actor model intentionally exposes the same application-safe fields used by the service. + +In `ClubAiDefaultsService`, replace `CurrentMember` with `AiGenerationActor`: + +```kotlin + override fun get( + clubSlug: String, + actor: AiGenerationActor, + ): ClubAiDefaultsView { + requireHostOfClub(clubSlug, actor) + val row = clubDefaultPort.load(actor.clubId) + return ClubAiDefaultsView(defaultModel = row?.defaultModel) + } + + override fun update( + clubSlug: String, + defaultModel: String, + actor: AiGenerationActor, + ) { + requireHostOfClub(clubSlug, actor) + val resolved = + resolveAllowlistedModel(defaultModel) + ?: throw AiGenerationException.Coded( + ErrorCode.AI_DISABLED, + "model '$defaultModel' is not allowlisted", + ) + clubDefaultPort.upsert( + clubId = actor.clubId, + defaultModel = resolved.name, + updatedBy = actor.userId, + ) + } + + private fun requireHostOfClub( + clubSlug: String, + actor: AiGenerationActor, + ) { + if (actor.clubSlug != clubSlug || !actor.isHost) { + throw AccessDeniedException("Host of '$clubSlug' required") + } + } +``` + +- [ ] **Step 9: Update web controllers to pass actors into application use cases** + +In `AiGenerationController.commit`, replace: + +```kotlin + auth.requireHostAccess(sessionId, member) + return commitUc.commit( + host = member, +``` + +with: + +```kotlin + auth.requireHostAccess(sessionId, member) + return commitUc.commit( + host = auth.actor(member), +``` + +In `ClubAiDefaultsController`, map `CurrentMember` before calling the use cases: + +```kotlin + val actor = auth.actor(member) + return getDefaults.get(clubSlug, actor) +``` + +and: + +```kotlin + val actor = auth.actor(member) + updateDefaults.update(clubSlug, request.defaultModel, actor) +``` + +If `ClubAiDefaultsController` does not currently inject `AiGenerationAuthorizationPolicy`, add it as a constructor dependency and keep the existing request/response mapping unchanged. + +- [ ] **Step 10: Run focused server tests** + +Run: + +```bash +./server/gradlew -p server architectureTest --tests com.readmates.architecture.ServerArchitectureBoundaryTest +./server/gradlew -p server unitTest --tests "*AiGeneration*" +``` + +Expected: both commands PASS. + +- [ ] **Step 11: Commit server boundary changes** + +```bash +git add server/src/test/kotlin/com/readmates/architecture/ServerArchitectureBoundaryTest.kt \ + server/src/main/kotlin/com/readmates/aigen +git commit -m "refactor: enforce server architecture slices" +``` + +## Task 3: Make Platform Admin Health UI Props-Driven + +**Files:** +- Modify: `front/features/platform-admin/ui/admin-health-grid.tsx` +- Modify: `front/features/platform-admin/route/admin-health-route.tsx` +- Modify: `front/features/platform-admin/ui/admin-health-grid.test.tsx` + +- [ ] **Step 1: Convert AdminHealthGrid to a presentation component** + +Replace the top of `admin-health-grid.tsx` so it exports props instead of calling `useQuery`: + +```tsx +import { AdminHealthCard } from "@/features/platform-admin/ui/admin-health-card"; +import { AdminHealthDeployStrip } from "@/features/platform-admin/ui/admin-health-deploy-strip"; +import type { PlatformHealthSnapshot } from "@/features/platform-admin/model/platform-admin-health-model"; + +export type AdminHealthGridProps = { + snapshot: PlatformHealthSnapshot | null; + loading: boolean; + error: boolean; + fetching: boolean; + stale: boolean; + onRefresh: () => void; +}; + +export function AdminHealthGrid({ + snapshot, + loading, + error, + fetching, + stale, + onRefresh, +}: AdminHealthGridProps) { + if (loading) return

로딩 중...

; + if (error || !snapshot) { + return

스냅샷을 불러오지 못했습니다.

; + } + const stripCard = snapshot.cards.find((c) => c.id === "deploy_attempts_strip"); + const rest = snapshot.cards.filter((c) => c.id !== "deploy_attempts_strip"); +``` + +Inside the JSX, replace `query.data` with `snapshot`, `query.isFetching` with `fetching`, `isStale` with `stale`, and `query.refetch()` with `onRefresh()`: + +```tsx + {snapshot.schema} · 생성 {formatTimestamp(snapshot.generatedAt)} +``` + +```tsx + stale ? "admin-health-grid__stale admin-health-grid__stale--warn" : "admin-health-grid__stale" +``` + +```tsx + {fetching ? "갱신 중" : stale ? "30초 이상 경과" : "최신"} +``` + +```tsx + disabled={fetching} + onClick={onRefresh} +``` + +- [ ] **Step 2: Move query ownership to the route component** + +Replace `front/features/platform-admin/route/admin-health-route.tsx` with: + +```tsx +import { useEffect, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { platformAdminHealthSnapshotQuery } from "@/features/platform-admin/queries/platform-admin-health-queries"; +import { AdminHealthGrid } from "@/features/platform-admin/ui/admin-health-grid"; + +const STALE_AFTER_MS = 30_000; + +export function AdminHealthRoute() { + const query = useQuery(platformAdminHealthSnapshotQuery()); + const [now, setNow] = useState(() => Date.now()); + + useEffect(() => { + const timer = window.setInterval(() => setNow(Date.now()), 1_000); + return () => window.clearInterval(timer); + }, []); + + const stale = query.dataUpdatedAt > 0 && now - query.dataUpdatedAt > STALE_AFTER_MS; + + return ( +
+
+

Platform Health

+

서비스·큐·AI 가용성·outbox·배포 신호를 한 화면에서 봅니다.

+
+ void query.refetch()} + /> +
+ ); +} +``` + +- [ ] **Step 3: Update AdminHealthGrid tests to pass props** + +In `admin-health-grid.test.tsx`, remove `QueryClient`, `QueryClientProvider`, `readmatesFetchMock`, and the `vi.mock("@/shared/api/client", ...)` block. Replace `renderGrid()` with: + +```tsx +function renderGrid( + props: Partial> = {}, +) { + const defaultProps: React.ComponentProps = { + snapshot: HEALTH_SNAPSHOT, + loading: false, + error: false, + fetching: false, + stale: false, + onRefresh: vi.fn(), + }; + render( + + + , + ); + return { onRefresh: defaultProps.onRefresh }; +} +``` + +Update the refresh test: + +```tsx + it("calls the refresh callback", async () => { + const user = userEvent.setup(); + const onRefresh = vi.fn(); + + renderGrid({ onRefresh }); + + await user.click(screen.getByRole("button", { name: "새로고침" })); + + expect(onRefresh).toHaveBeenCalledTimes(1); + }); +``` + +Update the stale test: + +```tsx + it("marks the snapshot stale when the route says it is stale", () => { + renderGrid({ stale: true }); + + expect(screen.getByText("30초 이상 경과")).toBeInTheDocument(); + }); +``` + +- [ ] **Step 4: Run focused frontend tests** + +Run: + +```bash +pnpm --dir front exec vitest run features/platform-admin/ui/admin-health-grid.test.tsx +``` + +Expected: PASS. + +- [ ] **Step 5: Commit the platform-admin pilot refactor** + +```bash +git add front/features/platform-admin/ui/admin-health-grid.tsx \ + front/features/platform-admin/route/admin-health-route.tsx \ + front/features/platform-admin/ui/admin-health-grid.test.tsx +git commit -m "refactor: keep admin health ui presentation-only" +``` + +## Task 4: Expand Frontend Boundary Tests for Queries Layer + +**Files:** +- Modify: `front/tests/unit/frontend-boundaries.test.ts` + +- [ ] **Step 1: Add the `feature-queries` rule id** + +Extend `BoundaryRuleId`: + +```ts +type BoundaryRuleId = + | "shared-boundary" + | "feature-to-feature" + | "feature-model" + | "feature-queries" + | "feature-route" + | "feature-ui" + | "readmates-api-compat" + | "feature-components-public"; +``` + +- [ ] **Step 2: Add query layer helper functions** + +Add these helpers near the existing feature layer helpers: + +```ts +function isFeatureQueriesFile(relativePath: string) { + return /^features\/[^/]+\/queries\//.test(relativePath); +} + +function isFeatureQueriesBoundaryImport(sourceFile: SourceFile, projectPath: string | null) { + if (!isFeatureQueriesFile(sourceFile.relativePath) || projectPath === null) { + return false; + } + + return ( + isFeatureLayerImport(projectPath, "ui") || + isFeatureLayerImport(projectPath, "route") || + projectPath.startsWith("src/pages/") || + projectPath.startsWith("src/app/") + ); +} +``` + +- [ ] **Step 3: Forbid model imports from queries** + +In `isFeatureModelBoundaryImport`, add: + +```ts + isFeatureLayerImport(importSpecifier.projectPath, "queries") || +``` + +The final return block should include `api`, `queries`, `route`, `ui`, `src/pages`, and `src/app`. + +- [ ] **Step 4: Forbid UI imports from queries** + +In `isFeatureUiBoundaryImport`, add: + +```ts + isFeatureLayerImport(projectPath, "queries") || +``` + +The final return block should include `shared/api`, `api`, `queries`, `route`, `src/pages`, and `src/app`. + +- [ ] **Step 5: Add query boundary violation checks** + +In the main import loop, after the model rule and before the UI rule, add: + +```ts + if (isFeatureQueriesBoundaryImport(sourceFile, importSpecifier.projectPath)) { + addImportViolation( + violations, + consumedLegacyExceptions, + sourceFile, + importSpecifier, + "feature-queries", + "feature query files must not import UI, route, app, or page modules.", + ); + } +``` + +- [ ] **Step 6: Add helper unit coverage** + +Add this test before the main boundary test: + +```ts + it("rejects query imports from UI and route modules", () => { + const sourceFile: SourceFile = { + absolutePath: "/unused/features/platform-admin/queries/platform-admin-health-queries.ts", + displayPath: "front/features/platform-admin/queries/platform-admin-health-queries.ts", + relativePath: "features/platform-admin/queries/platform-admin-health-queries.ts", + }; + + const uiImport = normalizeImportSpecifier(sourceFile, "@/features/platform-admin/ui/admin-health-grid"); + const routeImport = normalizeImportSpecifier(sourceFile, "@/features/platform-admin/route/admin-health-route"); + const apiImport = normalizeImportSpecifier(sourceFile, "@/features/platform-admin/api/platform-admin-health-api"); + + expect(isFeatureQueriesBoundaryImport(sourceFile, uiImport.projectPath)).toBe(true); + expect(isFeatureQueriesBoundaryImport(sourceFile, routeImport.projectPath)).toBe(true); + expect(isFeatureQueriesBoundaryImport(sourceFile, apiImport.projectPath)).toBe(false); + }); +``` + +- [ ] **Step 7: Run the focused frontend boundary test** + +Run: + +```bash +pnpm --dir front exec vitest run tests/unit/frontend-boundaries.test.ts +``` + +Expected: PASS. + +- [ ] **Step 8: Commit frontend boundary changes** + +```bash +git add front/tests/unit/frontend-boundaries.test.ts +git commit -m "test: enforce frontend query boundaries" +``` + +## Task 5: Update Architecture Docs and Agent Guides + +**Files:** +- Modify: `docs/development/architecture.md` +- Modify: `docs/development/adr/0002-server-clean-architecture-with-archunit.md` +- Modify: `docs/development/adr/0003-frontend-route-first-architecture.md` +- Modify: `docs/agents/front.md` +- Modify: `docs/agents/server.md` +- Modify: `docs/agents/docs.md` +- Create: `docs/development/vertical-slice-checklist.md` + +- [ ] **Step 1: Update frontend architecture layer text** + +In `docs/development/architecture.md`, replace the four feature bullets under "Feature는 가능한 범위에서..." with: + +```markdown +Feature는 가능한 범위에서 `api`, `queries`, `model`, `route`, `ui`로 나눕니다. + +- `features//api`는 해당 feature의 BFF endpoint 호출과 request/response contract만 담당합니다. +- `features//queries`는 TanStack Query key, `queryOptions`, mutation hook, invalidation policy를 담당합니다. UI와 route module을 import하지 않습니다. +- `features//model`은 React, React Router, TanStack Query, API client를 import하지 않는 순수 화면 모델 계산만 둡니다. +- `features//route`는 loader/action, route error/loading state, query seeding, API/model 호출, UI props 조립을 담당합니다. +- `features//ui`는 props와 callback으로만 렌더링하며 `fetch`, `shared/api`, feature API, feature queries, route module을 직접 import하지 않습니다. +``` + +- [ ] **Step 2: Update server slice classification in architecture docs** + +In `docs/development/architecture.md`, update "CQRS Read vs Write Package Split" so it includes: + +```markdown +### Ops Read-side +- `admin.health` — 운영 상태 snapshot과 deploy ledger를 provider/adapter에서 읽어 aggregate card model로 반환합니다. +- Mutation surface가 아니며, application service는 provider 결과를 card-local failure로 격리합니다. + +### Mixed / Workflow-side +- `feedback` — 문서 업로드 mutation + 조회를 함께 보유합니다. +- `sessionimport` — preview read path와 commit write path를 함께 보유합니다. +- `aigen` — 외부 LLM provider, Redis job handoff, Kafka worker, commit/recovery orchestration을 포함합니다. Application layer는 provider SDK, Redis, JDBC, Kafka detail이 아니라 outbound port에 의존합니다. +``` + +- [ ] **Step 3: Update ADR-0002 follow-up section** + +In `docs/development/adr/0002-server-clean-architecture-with-archunit.md`, add this bullet to the "결과" or "후속 작업" area: + +```markdown +- 2026-05-27 architecture flexibility update: `ServerArchitectureBoundaryTest`는 slice registry를 통해 `admin.audit`, `admin.health`, `aigen`까지 최근 확장 surface를 명시적으로 등록한다. `aigen`은 workflow-side slice로 분류하고, `CurrentMember` 같은 web/session carrier는 application-safe actor value로 변환해 전달한다. +``` + +- [ ] **Step 4: Update ADR-0003 with queries layer** + +In `docs/development/adr/0003-frontend-route-first-architecture.md`, update the frontend structure block so it includes: + +```markdown + queries/ — TanStack Query key, queryOptions, mutation hook, invalidation policy +``` + +Add this rule to the dependency direction list: + +```markdown +- `features//queries/`는 API contract와 shared query primitive를 사용할 수 있지만 UI, route, app, page module을 import하지 않는다. +``` + +- [ ] **Step 5: Update agent guides** + +In `docs/agents/front.md`, change the feature bullets to include: + +```markdown +- `features//queries`: TanStack Query keys, `queryOptions`, mutation hooks, and invalidation policy; do not import UI, route, app, or page modules. +``` + +In `docs/agents/server.md`, add after the CQRS paragraph: + +```markdown +Recent architecture work classifies server slices as write-side, read-side, ops read-side, or workflow-side. `admin.audit` is read-side, `admin.health` is ops read-side, and `aigen` is workflow-side. Workflow-side slices may orchestrate transactions and side effects, but provider SDKs, Redis, JDBC, Kafka, and mail details stay behind outbound ports/adapters. +``` + +In `docs/agents/docs.md`, add to documentation rules: + +```markdown +- Architecture flexibility changes should keep `docs/development/architecture.md`, ADR-0002, ADR-0003, `docs/agents/front.md`, and `docs/agents/server.md` aligned with the boundary tests that enforce the rule. +``` + +- [ ] **Step 6: Add the vertical slice checklist** + +Create `docs/development/vertical-slice-checklist.md`: + +```markdown +# Vertical Slice Checklist + +Use this checklist when a change crosses frontend, BFF, server API, auth, persistence, or public-safety boundaries. + +## 1. Surface + +- Product surface is one of public, member, host, platform admin, auth, BFF, or operations. +- The owning feature folder is named before code changes start. +- The change does not introduce real member data, secrets, private domains, deployment state, local paths, OCIDs, or token-shaped examples. + +## 2. Server + +- Controller parses HTTP input and maps responses only. +- Application service owns authorization, lifecycle rules, orchestration, and application errors. +- Persistence, Redis, Kafka, mail, provider SDK, and HTTP client details are behind outbound ports/adapters. +- Read-side services use `@ReadOnlyApplicationService` and do not depend on mutation ports. +- Workflow-side services keep side effects behind ports and document retry or recovery behavior in tests. + +## 3. BFF / Auth + +- Browser traffic uses same-origin `/api/bff/**` when the frontend calls Spring API. +- Internal `x-readmates-*` response headers and secrets are stripped. +- Club context is derived from trusted BFF input, not browser-supplied internal headers. +- Route return values and redirects use safe relative paths unless an allowlisted absolute return flow is explicitly documented. + +## 4. Frontend + +- `api` owns BFF calls and response contracts. +- `queries` owns query keys, `queryOptions`, mutation hooks, and invalidation. +- `model` owns pure view-model calculation and imports no React, router, query, or API client. +- `route` owns loader/action behavior, auth/redirect, URL state, query seeding, and UI prop assembly. +- `ui` renders from props/callbacks and imports no API, query, route, or `shared/api` client. + +## 5. Tests + +- Server boundary change: run `./server/gradlew -p server architectureTest`. +- Server behavior change: run the focused unit or integration test for the slice. +- Frontend boundary change: run `pnpm --dir front exec vitest run tests/unit/frontend-boundaries.test.ts`. +- Frontend behavior change: run the focused Vitest file and the smallest relevant route/component test. +- API, auth, BFF, or user-flow change: run `pnpm --dir front test:e2e`. +- Public release change: run `./scripts/build-public-release-candidate.sh` and `./scripts/public-release-check.sh .tmp/public-release-candidate`. +``` + +- [ ] **Step 7: Run docs checks** + +Run: + +```bash +git diff --check -- docs/development/architecture.md \ + docs/development/adr/0002-server-clean-architecture-with-archunit.md \ + docs/development/adr/0003-frontend-route-first-architecture.md \ + docs/agents/front.md \ + docs/agents/server.md \ + docs/agents/docs.md \ + docs/development/vertical-slice-checklist.md +rg -n "(/[U]sers/|ocid1\\.|AKIA[0-9A-Z]{16}|AIza[0-9A-Za-z_-]{30,}|sk-[A-Za-z0-9_-]{20,}|xox[baprs]-[A-Za-z0-9-]+)" \ + docs/development/architecture.md \ + docs/development/adr/0002-server-clean-architecture-with-archunit.md \ + docs/development/adr/0003-frontend-route-first-architecture.md \ + docs/agents/front.md \ + docs/agents/server.md \ + docs/agents/docs.md \ + docs/development/vertical-slice-checklist.md +``` + +Expected: `git diff --check` exits 0. The `rg` command exits 1 with no matches. + +- [ ] **Step 8: Commit docs and guide updates** + +```bash +git add docs/development/architecture.md \ + docs/development/adr/0002-server-clean-architecture-with-archunit.md \ + docs/development/adr/0003-frontend-route-first-architecture.md \ + docs/agents/front.md \ + docs/agents/server.md \ + docs/agents/docs.md \ + docs/development/vertical-slice-checklist.md +git commit -m "docs: document architecture slice boundaries" +``` + +## Task 6: Final Verification + +**Files:** +- No new files. +- Verify all changed files from Tasks 1-5. + +- [ ] **Step 1: Run server architecture and focused aigen tests** + +```bash +./server/gradlew -p server architectureTest +./server/gradlew -p server unitTest --tests "*AiGeneration*" --tests "*ClubAiDefaults*" +``` + +Expected: PASS. + +- [ ] **Step 2: Run frontend boundary and focused platform-admin tests** + +```bash +pnpm --dir front exec vitest run tests/unit/frontend-boundaries.test.ts +pnpm --dir front exec vitest run features/platform-admin/ui/admin-health-grid.test.tsx +``` + +Expected: PASS. + +- [ ] **Step 3: Run broad local checks for touched surfaces** + +```bash +pnpm --dir front lint +pnpm --dir front test +pnpm --dir front build +./server/gradlew -p server check +``` + +Expected: PASS. + +- [ ] **Step 4: Run docs whitespace and public-safety checks** + +```bash +git diff --check +rg -n "(/[U]sers/|ocid1\\.|AKIA[0-9A-Z]{16}|AIza[0-9A-Za-z_-]{30,}|sk-[A-Za-z0-9_-]{20,}|xox[baprs]-[A-Za-z0-9-]+)" \ + docs/development docs/agents docs/superpowers/specs/2026-05-27-readmates-architecture-flexibility-design.md +``` + +Expected: `git diff --check` exits 0. The `rg` command exits 1 with no matches. + +- [ ] **Step 5: Inspect final diff** + +```bash +git status --short +git diff --stat HEAD +``` + +Expected: working tree contains only intentional changes from this plan. No generated build output, private local state, or unrelated files appear. + +- [ ] **Step 6: Commit final verification adjustments if needed** + +If Step 5 shows uncommitted intentional fixes, commit them: + +```bash +git add server/src/test/kotlin/com/readmates/architecture/ServerArchitectureBoundaryTest.kt \ + server/src/main/kotlin/com/readmates/aigen \ + front/tests/unit/frontend-boundaries.test.ts \ + front/features/platform-admin \ + docs/development \ + docs/agents +git commit -m "chore: finish architecture boundary hardening" +``` + +Expected: final `git status --short` is clean. + +## Spec Coverage Self-Review + +- Goals and non-goals: covered by Task 5 docs and Task 6 verification. +- Server clean architecture hardening: covered by Tasks 1 and 2. +- `admin.audit`, `admin.health`, `aigen` registry coverage: covered by Task 1. +- Aigen workflow-side pilot: covered by Task 2. +- Frontend `queries` layer and UI boundary: covered by Tasks 3 and 4. +- Vertical slice standard: covered by Task 5. +- Verification strategy: covered by Task 6. +- Public repo safety: covered by Task 5 and Task 6 scans. diff --git a/docs/superpowers/plans/2026-05-29-readmates-admin-club-ops-triage.md b/docs/superpowers/plans/2026-05-29-readmates-admin-club-ops-triage.md new file mode 100644 index 00000000..1d743fe9 --- /dev/null +++ b/docs/superpowers/plans/2026-05-29-readmates-admin-club-ops-triage.md @@ -0,0 +1,691 @@ +# Admin Club Operations Triage List Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Turn `/admin/clubs` from a flat table into a triage list that surfaces clubs needing attention first, with a severity badge, human reasons, and a severity filter, so an operator can instantly see which clubs are in trouble and drill in. + +**Architecture:** Frontend-only vertical slice. A new pure model (`platform-admin-club-triage-model.ts`) computes a severity (`critical`/`attention`/`ok`) and reason list from fields already present on `PlatformAdminClub` (`status`, `domainActionRequiredCount`, `firstHostOnboardingState`). The clubs route reuses the model to sort, badge, and filter rows. No server change. The detail route already provides safe drill-down (notifications / AI Ops links), so this plan completes the list-side gate of S3+. + +**Tech Stack:** React 19, TypeScript, Vite, `@tanstack/react-query`, react-router-dom, vitest + `@testing-library/react`, Playwright. + +**Scope source:** `docs/superpowers/specs/2026-05-29-readmates-admin-vnext-operating-depth-reporting-design.md` (slice S3+). + +**Explicitly deferred to a follow-up plan (NOT in this plan):** Per-club notification-failure and AI-failure counts in the list. Those require a server set-based aggregation over `notification_deliveries` and the AI audit table plus FK-heavy integration-test seeding (`notification_deliveries` is FK-bound to `notification_event_outbox` and `memberships`). They belong in their own server slice/plan. This plan delivers triage from readiness/domain/host signals, which mirror the existing server `readiness.blockingReasons` (`HOST_REQUIRED`, `DOMAIN_ACTION_REQUIRED`, `CLUB_NOT_ACTIVE`). + +--- + +## File Structure + +- Create: `front/features/platform-admin/model/platform-admin-club-triage-model.ts` — pure severity/reason/sort/filter functions and labels. One responsibility: classify and order clubs for triage. +- Create: `front/features/platform-admin/model/platform-admin-club-triage-model.test.ts` — unit tests for the model. +- Modify: `front/features/platform-admin/route/admin-clubs-route.tsx` — consume the model: sort rows, render severity badge + reasons, add severity filter toolbar. +- Modify: `front/features/platform-admin/route/admin-clubs-route.test.tsx` — add ordering, badge, and filter tests. +- Modify: `front/src/styles/...` (admin stylesheet that defines `admin-clubs*`) — add `admin-clubs__triage*` styles. Exact file located in Task 4. +- Modify: `front/tests/e2e/...` admin clubs spec (or create one) — happy-path triage E2E. Exact file located in Task 5. +- Modify: `CHANGELOG.md` — `Unreleased` entry. + +--- + +## Task 1: Triage model (pure functions) + +**Files:** +- Create: `front/features/platform-admin/model/platform-admin-club-triage-model.ts` +- Test: `front/features/platform-admin/model/platform-admin-club-triage-model.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `front/features/platform-admin/model/platform-admin-club-triage-model.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; +import type { PlatformAdminClub } from "@/features/platform-admin/model/platform-admin-domain-types"; +import { + CLUB_TRIAGE_LABEL, + clubTriageReasons, + clubTriageSeverity, + filterClubsBySeverity, + rankClubsByTriage, +} from "./platform-admin-club-triage-model"; + +function club(overrides: Partial): PlatformAdminClub { + return { + clubId: "c-1", + slug: "alpha", + name: "Alpha", + tagline: "", + about: "", + status: "ACTIVE", + publicVisibility: "PRIVATE", + domainCount: 0, + domainActionRequiredCount: 0, + firstHostOnboardingState: "ASSIGNED", + ...overrides, + }; +} + +describe("clubTriageSeverity", () => { + it("is ok for an active club with no blockers", () => { + expect(clubTriageSeverity(club({}))).toBe("ok"); + }); + + it("is critical when a domain needs action", () => { + expect(clubTriageSeverity(club({ domainActionRequiredCount: 2 }))).toBe("critical"); + }); + + it("is critical when suspended or archived", () => { + expect(clubTriageSeverity(club({ status: "SUSPENDED" }))).toBe("critical"); + expect(clubTriageSeverity(club({ status: "ARCHIVED" }))).toBe("critical"); + }); + + it("is attention when setup is incomplete or host is not assigned", () => { + expect(clubTriageSeverity(club({ status: "SETUP_REQUIRED" }))).toBe("attention"); + expect(clubTriageSeverity(club({ firstHostOnboardingState: "MISSING" }))).toBe("attention"); + expect(clubTriageSeverity(club({ firstHostOnboardingState: "INVITED" }))).toBe("attention"); + }); +}); + +describe("clubTriageReasons", () => { + it("lists each active blocker in Korean", () => { + expect(clubTriageReasons(club({ domainActionRequiredCount: 1, firstHostOnboardingState: "MISSING" }))).toEqual([ + "도메인 조치 필요", + "호스트 없음", + ]); + }); + + it("is empty for a healthy club", () => { + expect(clubTriageReasons(club({}))).toEqual([]); + }); +}); + +describe("rankClubsByTriage", () => { + it("orders critical before attention before ok and is stable within a bucket", () => { + const ok = club({ clubId: "ok" }); + const attention = club({ clubId: "att", status: "SETUP_REQUIRED" }); + const critical = club({ clubId: "crit", domainActionRequiredCount: 1 }); + const ranked = rankClubsByTriage([ok, attention, critical]); + expect(ranked.map((c) => c.clubId)).toEqual(["crit", "att", "ok"]); + }); + + it("does not mutate the input array", () => { + const input = [club({ clubId: "ok" }), club({ clubId: "crit", domainActionRequiredCount: 1 })]; + rankClubsByTriage(input); + expect(input.map((c) => c.clubId)).toEqual(["ok", "crit"]); + }); +}); + +describe("filterClubsBySeverity", () => { + it("returns all clubs for the 'all' filter", () => { + const clubs = [club({ clubId: "a" }), club({ clubId: "b", status: "SUSPENDED" })]; + expect(filterClubsBySeverity(clubs, "all")).toHaveLength(2); + }); + + it("keeps only clubs matching the selected severity", () => { + const clubs = [club({ clubId: "ok" }), club({ clubId: "crit", status: "SUSPENDED" })]; + expect(filterClubsBySeverity(clubs, "critical").map((c) => c.clubId)).toEqual(["crit"]); + }); +}); + +describe("CLUB_TRIAGE_LABEL", () => { + it("maps every severity to a Korean label", () => { + expect(CLUB_TRIAGE_LABEL).toEqual({ critical: "긴급", attention: "주의", ok: "정상" }); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --dir front test platform-admin-club-triage-model` +Expected: FAIL — cannot resolve `./platform-admin-club-triage-model` (module not found). + +- [ ] **Step 3: Write the model** + +Create `front/features/platform-admin/model/platform-admin-club-triage-model.ts`: + +```ts +import type { PlatformAdminClub } from "@/features/platform-admin/model/platform-admin-domain-types"; + +export type ClubTriageSeverity = "critical" | "attention" | "ok"; +export type ClubTriageFilter = ClubTriageSeverity | "all"; + +const SEVERITY_RANK: Record = { + critical: 0, + attention: 1, + ok: 2, +}; + +export const CLUB_TRIAGE_LABEL: Record = { + critical: "긴급", + attention: "주의", + ok: "정상", +}; + +export function clubTriageReasons(club: PlatformAdminClub): string[] { + const reasons: string[] = []; + if (club.domainActionRequiredCount > 0) { + reasons.push("도메인 조치 필요"); + } + if (club.firstHostOnboardingState === "MISSING") { + reasons.push("호스트 없음"); + } else if (club.firstHostOnboardingState === "INVITED") { + reasons.push("호스트 초대 대기"); + } + if (club.status === "SUSPENDED") { + reasons.push("정지됨"); + } else if (club.status === "ARCHIVED") { + reasons.push("보관됨"); + } else if (club.status === "SETUP_REQUIRED") { + reasons.push("설정 미완료"); + } + return reasons; +} + +export function clubTriageSeverity(club: PlatformAdminClub): ClubTriageSeverity { + if (club.domainActionRequiredCount > 0 || club.status === "SUSPENDED" || club.status === "ARCHIVED") { + return "critical"; + } + if (club.status === "SETUP_REQUIRED" || club.firstHostOnboardingState !== "ASSIGNED") { + return "attention"; + } + return "ok"; +} + +export function rankClubsByTriage(clubs: PlatformAdminClub[]): PlatformAdminClub[] { + return [...clubs].sort( + (a, b) => SEVERITY_RANK[clubTriageSeverity(a)] - SEVERITY_RANK[clubTriageSeverity(b)], + ); +} + +export function filterClubsBySeverity( + clubs: PlatformAdminClub[], + filter: ClubTriageFilter, +): PlatformAdminClub[] { + if (filter === "all") { + return clubs; + } + return clubs.filter((club) => clubTriageSeverity(club) === filter); +} +``` + +Note: `Array.prototype.sort` is stable in all supported engines, so clubs keep the server order (updated_at desc) within a severity bucket. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm --dir front test platform-admin-club-triage-model` +Expected: PASS — all cases green. + +- [ ] **Step 5: Commit** + +```bash +git add front/features/platform-admin/model/platform-admin-club-triage-model.ts front/features/platform-admin/model/platform-admin-club-triage-model.test.ts +git commit -m "feat: add platform admin club triage model" +``` + +--- + +## Task 2: Sort the clubs list by triage and show severity badge + reasons + +**Files:** +- Modify: `front/features/platform-admin/route/admin-clubs-route.tsx` +- Test: `front/features/platform-admin/route/admin-clubs-route.test.tsx:8-56` + +- [ ] **Step 1: Update the test factory and add failing tests** + +In `front/features/platform-admin/route/admin-clubs-route.test.tsx`, replace the import line and add the model import, then add new tests. First update the imports at the top: + +```ts +import { render, screen, fireEvent, within } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { MemoryRouter, Routes, Route } from "react-router-dom"; +import { describe, expect, it } from "vitest"; +import { platformAdminClubsQuery } from "@/features/platform-admin/queries/platform-admin-queries"; +import { AdminClubsRoute } from "./admin-clubs-route"; +``` + +Then append these tests inside the existing `describe("AdminClubsRoute", ...)` block: + +```ts + it("orders critical clubs before healthy clubs", () => { + renderRoute([ + { + clubId: "ok-1", slug: "healthy", name: "Healthy", status: "ACTIVE", + publicVisibility: "PUBLIC", domainCount: 1, domainActionRequiredCount: 0, + firstHostOnboardingState: "ASSIGNED", tagline: "", about: "", + }, + { + clubId: "crit-1", slug: "broken", name: "Broken", status: "ACTIVE", + publicVisibility: "PRIVATE", domainCount: 1, domainActionRequiredCount: 2, + firstHostOnboardingState: "ASSIGNED", tagline: "", about: "", + }, + ]); + const rows = screen.getAllByRole("row").slice(1); // drop header row + expect(within(rows[0]).getByText("Broken")).toBeInTheDocument(); + expect(within(rows[1]).getByText("Healthy")).toBeInTheDocument(); + }); + + it("shows a severity badge and reason for an at-risk club", () => { + renderRoute([ + { + clubId: "crit-1", slug: "broken", name: "Broken", status: "ACTIVE", + publicVisibility: "PRIVATE", domainCount: 1, domainActionRequiredCount: 2, + firstHostOnboardingState: "ASSIGNED", tagline: "", about: "", + }, + ]); + expect(screen.getByText("긴급")).toBeInTheDocument(); + expect(screen.getByText("도메인 조치 필요")).toBeInTheDocument(); + }); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pnpm --dir front test admin-clubs-route` +Expected: FAIL — "긴급" / "도메인 조치 필요" not found, and order assertion fails (current code renders server order, no badge). + +- [ ] **Step 3: Update the route to sort and render the triage column** + +Replace the contents of `front/features/platform-admin/route/admin-clubs-route.tsx` with: + +```tsx +import { useQuery } from "@tanstack/react-query"; +import { Link } from "react-router-dom"; +import { platformAdminClubsQuery } from "@/features/platform-admin/queries/platform-admin-queries"; +import { + CLUB_TRIAGE_LABEL, + clubTriageReasons, + clubTriageSeverity, + rankClubsByTriage, +} from "@/features/platform-admin/model/platform-admin-club-triage-model"; + +export function AdminClubsRoute() { + const clubs = useQuery(platformAdminClubsQuery()).data!; + const ordered = rankClubsByTriage(clubs.items); + return ( +
+
+

클럽

+

플랫폼이 보유한 모든 클럽 목록입니다. 조치가 필요한 클럽이 위에 옵니다.

+ 새 클럽 +
+ {ordered.length === 0 ? ( +

클럽이 없습니다.

+ ) : ( + + + + + + + + + + + + + + {ordered.map((club) => { + const severity = clubTriageSeverity(club); + const reasons = clubTriageReasons(club); + return ( + + + + + + + + + + ); + })} + +
상태 신호Slug이름상태공개도메인호스트
+ + {CLUB_TRIAGE_LABEL[severity]} + + {reasons.length > 0 ? ( + {reasons.join(" · ")} + ) : null} + {club.slug} + {club.name} + {club.status}{club.publicVisibility} + {club.domainCount} + {club.domainActionRequiredCount > 0 ? ` · ${club.domainActionRequiredCount} 조치 필요` : ""} + {club.firstHostOnboardingState}
+ )} +
+ ); +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `pnpm --dir front test admin-clubs-route` +Expected: PASS — including the two pre-existing tests (row render, navigation) which still find `alpha`/`Alpha`/`ACTIVE`/`PRIVATE` and the `Alpha` link. + +- [ ] **Step 5: Commit** + +```bash +git add front/features/platform-admin/route/admin-clubs-route.tsx front/features/platform-admin/route/admin-clubs-route.test.tsx +git commit -m "feat: order admin clubs list by triage severity" +``` + +--- + +## Task 3: Severity filter toolbar + +**Files:** +- Modify: `front/features/platform-admin/route/admin-clubs-route.tsx` +- Test: `front/features/platform-admin/route/admin-clubs-route.test.tsx` + +- [ ] **Step 1: Add failing filter test** + +Append inside `describe("AdminClubsRoute", ...)` in `admin-clubs-route.test.tsx`: + +```ts + it("filters the list to only critical clubs when the 긴급 filter is selected", () => { + renderRoute([ + { + clubId: "ok-1", slug: "healthy", name: "Healthy", status: "ACTIVE", + publicVisibility: "PUBLIC", domainCount: 1, domainActionRequiredCount: 0, + firstHostOnboardingState: "ASSIGNED", tagline: "", about: "", + }, + { + clubId: "crit-1", slug: "broken", name: "Broken", status: "SUSPENDED", + publicVisibility: "PRIVATE", domainCount: 1, domainActionRequiredCount: 0, + firstHostOnboardingState: "ASSIGNED", tagline: "", about: "", + }, + ]); + fireEvent.click(screen.getByRole("button", { name: "긴급" })); + expect(screen.getByText("Broken")).toBeInTheDocument(); + expect(screen.queryByText("Healthy")).not.toBeInTheDocument(); + }); + + it("shows an empty hint when a filter matches no clubs", () => { + renderRoute([ + { + clubId: "ok-1", slug: "healthy", name: "Healthy", status: "ACTIVE", + publicVisibility: "PUBLIC", domainCount: 1, domainActionRequiredCount: 0, + firstHostOnboardingState: "ASSIGNED", tagline: "", about: "", + }, + ]); + fireEvent.click(screen.getByRole("button", { name: "긴급" })); + expect(screen.getByText("선택한 필터에 해당하는 클럽이 없습니다.")).toBeInTheDocument(); + }); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pnpm --dir front test admin-clubs-route` +Expected: FAIL — no button named "긴급", empty hint text absent. + +- [ ] **Step 3: Add filter state, toolbar, and filtering to the route** + +Edit `front/features/platform-admin/route/admin-clubs-route.tsx`. Update the imports to add `useState` and the filter helpers: + +```tsx +import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { Link } from "react-router-dom"; +import { platformAdminClubsQuery } from "@/features/platform-admin/queries/platform-admin-queries"; +import { + CLUB_TRIAGE_LABEL, + clubTriageReasons, + clubTriageSeverity, + filterClubsBySeverity, + rankClubsByTriage, + type ClubTriageFilter, +} from "@/features/platform-admin/model/platform-admin-club-triage-model"; + +const FILTER_OPTIONS: ReadonlyArray<{ value: ClubTriageFilter; label: string }> = [ + { value: "all", label: "전체" }, + { value: "critical", label: "긴급" }, + { value: "attention", label: "주의" }, +]; +``` + +Then replace the function body's first two statements (the `clubs` and `ordered` lines) and the header/table region. The full updated function: + +```tsx +export function AdminClubsRoute() { + const clubs = useQuery(platformAdminClubsQuery()).data!; + const [filter, setFilter] = useState("all"); + const ordered = rankClubsByTriage(filterClubsBySeverity(clubs.items, filter)); + return ( +
+
+

클럽

+

플랫폼이 보유한 모든 클럽 목록입니다. 조치가 필요한 클럽이 위에 옵니다.

+ 새 클럽 +
+
+ {FILTER_OPTIONS.map((option) => ( + + ))} +
+ {clubs.items.length === 0 ? ( +

클럽이 없습니다.

+ ) : ordered.length === 0 ? ( +

선택한 필터에 해당하는 클럽이 없습니다.

+ ) : ( + + + + + + + + + + + + + + {ordered.map((club) => { + const severity = clubTriageSeverity(club); + const reasons = clubTriageReasons(club); + return ( + + + + + + + + + + ); + })} + +
상태 신호Slug이름상태공개도메인호스트
+ + {CLUB_TRIAGE_LABEL[severity]} + + {reasons.length > 0 ? ( + {reasons.join(" · ")} + ) : null} + {club.slug} + {club.name} + {club.status}{club.publicVisibility} + {club.domainCount} + {club.domainActionRequiredCount > 0 ? ` · ${club.domainActionRequiredCount} 조치 필요` : ""} + {club.firstHostOnboardingState}
+ )} +
+ ); +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `pnpm --dir front test admin-clubs-route` +Expected: PASS — filter narrows to critical, empty hint shows, prior tests still pass (note the "navigates" test clicks the `Alpha` link which remains visible under the default `all` filter). + +- [ ] **Step 5: Commit** + +```bash +git add front/features/platform-admin/route/admin-clubs-route.tsx front/features/platform-admin/route/admin-clubs-route.test.tsx +git commit -m "feat: add severity filter to admin clubs list" +``` + +--- + +## Task 4: Triage badge styles + +**Files:** +- Modify: the admin stylesheet that already defines `admin-clubs*` classes (locate in Step 1). + +- [ ] **Step 1: Locate the stylesheet that defines `admin-clubs__table`** + +Run: `grep -rl "admin-clubs__table" front/src front/features` +Expected: one stylesheet path (for example a global admin CSS). Use that file in Step 2. If `admin-clubs__table` styling lives in a CSS module next to the shell, edit that same file. + +- [ ] **Step 2: Add triage styles** + +Append to the located stylesheet (adjust selector nesting to match the file's existing convention; these are flat class selectors consistent with the existing `admin-clubs__*` classes): + +```css +.admin-clubs__filters { + display: flex; + gap: 0.5rem; + margin: 0.75rem 0 1rem; + flex-wrap: wrap; +} + +.admin-clubs__triage { + display: inline-block; + padding: 0.1rem 0.5rem; + border-radius: 999px; + font-size: 0.75rem; + font-weight: 600; + white-space: nowrap; +} + +.admin-clubs__triage--critical { + background: var(--color-danger-soft, #fdecec); + color: var(--color-danger, #b3261e); +} + +.admin-clubs__triage--attention { + background: var(--color-warning-soft, #fff4e5); + color: var(--color-warning, #9a6700); +} + +.admin-clubs__triage--ok { + background: var(--color-success-soft, #e8f5e9); + color: var(--color-success, #1b5e20); +} + +.admin-clubs__triage-reasons { + display: block; + margin-top: 0.2rem; + font-size: 0.72rem; + color: var(--color-text-muted, #6b6b6b); +} +``` + +If the existing CSS does not define the referenced custom properties, the fallback hex values keep the badges legible. + +- [ ] **Step 3: Verify the build and lint pass** + +Run: `pnpm --dir front lint && pnpm --dir front build` +Expected: PASS — no lint errors, build succeeds. + +- [ ] **Step 4: Commit** + +```bash +git add front/src front/features +git commit -m "style: add admin clubs triage badge styles" +``` + +--- + +## Task 5: E2E happy path + docs + +**Files:** +- Modify or create: admin clubs Playwright spec (locate in Step 1). +- Modify: `CHANGELOG.md` + +- [ ] **Step 1: Locate the admin E2E pattern** + +Run: `ls front/tests/e2e && grep -rln "/admin/" front/tests/e2e` +Expected: existing admin specs (for example an admin shell or health spec). Mirror that file's auth/dev-login setup. Use the closest existing admin spec as the template for the new test below (same `test.describe`, same login helper, same base URL usage). + +- [ ] **Step 2: Write the failing E2E test** + +Add a test to the admin clubs spec (create `front/tests/e2e/admin-clubs-triage.spec.ts` if no clubs spec exists), using the same login/setup helpers the located admin spec uses. The assertion body: + +```ts +// Inside the admin-authenticated describe block, after navigating to /admin/clubs: +await page.goto("/admin/clubs"); +await expect(page.getByRole("heading", { name: "클럽" })).toBeVisible(); + +// Triage filter toolbar is present. +await expect(page.getByRole("button", { name: "전체" })).toBeVisible(); +await expect(page.getByRole("button", { name: "긴급" })).toBeVisible(); + +// Filtering to 긴급 keeps the page usable (no crash, table or empty hint shows). +await page.getByRole("button", { name: "긴급" }).click(); +const table = page.locator(".admin-clubs__table"); +const emptyHint = page.getByText("선택한 필터에 해당하는 클럽이 없습니다."); +await expect(table.or(emptyHint)).toBeVisible(); + +// Reset to 전체 and drill into the first club row. +await page.getByRole("button", { name: "전체" }).click(); +const firstClubLink = page.locator(".admin-clubs__table tbody tr td a").first(); +await firstClubLink.click(); +await expect(page).toHaveURL(/\/admin\/clubs\/.+/); +``` + +Note: `locator.or()` requires a recent Playwright; if unavailable, assert `table` visible only when the dev seed has clubs (the dev seed used by other admin E2E specs always seeds at least one club — confirm against the located spec's expectations). + +- [ ] **Step 3: Run the E2E test to verify it passes (or fails meaningfully first)** + +Run: `pnpm --dir front test:e2e` +Expected: the new test passes against the dev-login admin session. If it fails because the dev seed has no clubs, adjust the assertion to the empty-hint branch — do not weaken the drill-in assertion when clubs exist. + +- [ ] **Step 4: Update CHANGELOG** + +In `CHANGELOG.md`, under the `Unreleased` section, add a bullet describing shipped behavior (not plan language): + +```markdown +- `/admin/clubs`: triage list now orders clubs by operational severity (긴급/주의/정상), shows the blocking reasons inline, and adds a severity filter so operators see at-risk clubs first. +``` + +- [ ] **Step 5: Run the full frontend regression suite** + +Run: `pnpm --dir front lint && pnpm --dir front test && pnpm --dir front build` +Expected: PASS — all green. + +- [ ] **Step 6: Commit** + +```bash +git add front/tests/e2e CHANGELOG.md +git commit -m "test: cover admin clubs triage e2e and note changelog" +``` + +--- + +## Verification Gates (whole plan) + +- [ ] `pnpm --dir front test platform-admin-club-triage-model` — model unit tests pass. +- [ ] `pnpm --dir front test admin-clubs-route` — route ordering, badge, filter, and prior tests pass. +- [ ] `pnpm --dir front lint` — no lint errors. +- [ ] `pnpm --dir front build` — production build succeeds. +- [ ] `pnpm --dir front test:e2e` — admin clubs triage happy path passes. +- [ ] `git diff --check` — no whitespace/conflict markers in changed files. +- [ ] Manual browser smoke: dev-login as platform admin, open `/admin/clubs`, confirm at-risk clubs sort to the top with a readable badge, the filter narrows the list, and clicking a club name opens the detail route. + +## Public Safety + +- No server change, no new data exposure. Triage labels and reasons are derived from already-exposed club fields (`status`, `domainCount`/`domainActionRequiredCount`, `firstHostOnboardingState`). No member data, secrets, provider errors, or private operational details are added to the UI, tests, fixtures, or docs. + +## Deferred Follow-up (next S3+ plan) + +Per-club notification-failure and AI-failure counts in the list. This needs a server set-based aggregation extending `CLUB_BASE_SQL` in `JdbcPlatformAdminClubAdapter` with `left join` subqueries over `notification_deliveries` (status in `FAILED`,`DEAD`) and the AI audit table (status `FAILED`, last 7 days), new fields on `PlatformAdminClubListItem` / `PlatformAdminClubResponse` (with safe defaults), the matching frontend `operationalSignals` field, and an `@Tag("integration")` test that seeds the FK chain (`clubs` → `notification_event_outbox` + `memberships` → `notification_deliveries`). Fold those counts into `clubTriageSeverity` (failure count > 0 ⇒ critical) when shipped. diff --git a/docs/superpowers/plans/2026-05-30-admin-s6-p1-ai-ops-failure-code-drilldown.md b/docs/superpowers/plans/2026-05-30-admin-s6-p1-ai-ops-failure-code-drilldown.md new file mode 100644 index 00000000..6e673357 --- /dev/null +++ b/docs/superpowers/plans/2026-05-30-admin-s6-p1-ai-ops-failure-code-drilldown.md @@ -0,0 +1,761 @@ +# Admin S6 P1 — AI Ops Failure-Code Drilldown Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** `/admin/ai-ops`에서 summary의 실패 코드를 클릭하면 그 코드에 영향받은 클럽/세션(job)으로 job 목록을 필터링하고, 필터를 URL state(`?errorCode=`, `?clubId=`)로 보존하며 "전체 보기"로 해제할 수 있게 한다. + +**Architecture:** 순수 frontend 변경이다. 서버 계약과 endpoint(`GET /api/admin/ai-generation/jobs?errorCode=&clubId=`)는 이미 필터를 지원하므로 신규 인프라가 없다. S8 analytics의 URL-state 패턴(`useSearchParams` + model 헬퍼 + loader가 `args.request.url`에서 seeding)을 그대로 재사용한다. route 모듈이 URL→filter→query를 소유하고, UI는 props/callback으로만 렌더링한다. + +**Tech Stack:** React + Vite, React Router (`useSearchParams`), TanStack Query, Vitest + Testing Library, Playwright(e2e). + +이 plan은 S6 P1만 구현한다. P2(health/audit 연결성), P3(비용/추세), P4(retry 조치)는 포함하지 않는다. 단, URL filter 계약에 `clubId`를 함께 넣어 P2가 deep-link로 재사용할 수 있게 한다(UI 상호작용은 P1에서 실패코드 선택/해제만). + +Charter: `docs/superpowers/specs/2026-05-30-admin-vnext-closeout-execution-charter-design.md` §4.2(1). Closeout roadmap §6 S6. + +--- + +## File Structure + +- Create: `front/features/platform-admin/model/platform-admin-ai-ops-model.ts` — URL filter 계약과 헬퍼(파싱/직렬화/쿼리 변환). 단일 책임: ai-ops job 필터의 URL state 변환. +- Create: `front/features/platform-admin/model/platform-admin-ai-ops-model.test.ts` — 헬퍼 단위 테스트. +- Modify: `front/features/platform-admin/route/admin-ai-ops-data.ts` — loader가 URL의 필터로 jobs를 seeding. +- Modify: `front/features/platform-admin/route/admin-ai-ops-route.tsx` — `useSearchParams`로 필터 파생, jobs query에 주입, UI에 active filter + 핸들러 전달. +- Modify: `front/features/platform-admin/route/admin-ai-ops-route.test.tsx` — 필터 wiring/URL 갱신 테스트. +- Modify: `front/features/platform-admin/ui/platform-admin-ai-ops.tsx` — 실패 코드 버튼화, active filter 칩 + "전체 보기" 해제, 필터 적용 시 정직한 empty state. +- Modify: `front/features/platform-admin/ui/platform-admin-ai-ops.test.tsx` — 클릭/콜백/empty state 테스트. +- Create: `front/tests/e2e/admin-ai-ops-drilldown.spec.ts` — Playwright happy path. +- Modify: `CHANGELOG.md` — Unreleased에 shipped 동작 기록. + +--- + +## Task 1: URL filter model helpers + +**Files:** +- Create: `front/features/platform-admin/model/platform-admin-ai-ops-model.ts` +- Test: `front/features/platform-admin/model/platform-admin-ai-ops-model.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `front/features/platform-admin/model/platform-admin-ai-ops-model.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; +import { + EMPTY_AI_OPS_FILTER, + aiOpsFilterFromSearchParams, + aiOpsFilterToQuery, + aiOpsSearchFromFilter, + hasActiveAiOpsFilter, +} from "./platform-admin-ai-ops-model"; + +describe("platform-admin ai-ops filter model", () => { + it("parses errorCode and clubId from search params", () => { + const params = new URLSearchParams("errorCode=PROVIDER_RATE_LIMITED&clubId=club-1"); + expect(aiOpsFilterFromSearchParams(params)).toEqual({ + errorCode: "PROVIDER_RATE_LIMITED", + clubId: "club-1", + }); + }); + + it("treats empty/absent params as null", () => { + expect(aiOpsFilterFromSearchParams(new URLSearchParams(""))).toEqual(EMPTY_AI_OPS_FILTER); + expect(aiOpsFilterFromSearchParams(new URLSearchParams("errorCode="))).toEqual(EMPTY_AI_OPS_FILTER); + }); + + it("serializes only set fields, dropping nulls", () => { + expect(aiOpsSearchFromFilter({ errorCode: "X", clubId: null }).toString()).toBe("errorCode=X"); + expect(aiOpsSearchFromFilter(EMPTY_AI_OPS_FILTER).toString()).toBe(""); + }); + + it("reports active filter state", () => { + expect(hasActiveAiOpsFilter(EMPTY_AI_OPS_FILTER)).toBe(false); + expect(hasActiveAiOpsFilter({ errorCode: "X", clubId: null })).toBe(true); + expect(hasActiveAiOpsFilter({ errorCode: null, clubId: "club-1" })).toBe(true); + }); + + it("maps filter to the API query shape, omitting nulls", () => { + expect(aiOpsFilterToQuery({ errorCode: "X", clubId: null })).toEqual({ errorCode: "X" }); + expect(aiOpsFilterToQuery(EMPTY_AI_OPS_FILTER)).toEqual({}); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --dir front test platform-admin-ai-ops-model` +Expected: FAIL — cannot resolve `./platform-admin-ai-ops-model`. + +- [ ] **Step 3: Write minimal implementation** + +Create `front/features/platform-admin/model/platform-admin-ai-ops-model.ts`: + +```ts +import type { PlatformAdminAiOpsFilters } from "@/features/platform-admin/api/platform-admin-contracts"; + +export type AiOpsJobFilter = { + errorCode: string | null; + clubId: string | null; +}; + +export const EMPTY_AI_OPS_FILTER: AiOpsJobFilter = { errorCode: null, clubId: null }; + +export function aiOpsFilterFromSearchParams(params: URLSearchParams): AiOpsJobFilter { + return { + errorCode: params.get("errorCode") || null, + clubId: params.get("clubId") || null, + }; +} + +export function aiOpsSearchFromFilter(filter: AiOpsJobFilter): URLSearchParams { + const params = new URLSearchParams(); + if (filter.errorCode) { + params.set("errorCode", filter.errorCode); + } + if (filter.clubId) { + params.set("clubId", filter.clubId); + } + return params; +} + +export function hasActiveAiOpsFilter(filter: AiOpsJobFilter): boolean { + return Boolean(filter.errorCode || filter.clubId); +} + +export function aiOpsFilterToQuery(filter: AiOpsJobFilter): PlatformAdminAiOpsFilters { + const query: PlatformAdminAiOpsFilters = {}; + if (filter.errorCode) { + query.errorCode = filter.errorCode; + } + if (filter.clubId) { + query.clubId = filter.clubId; + } + return query; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm --dir front test platform-admin-ai-ops-model` +Expected: PASS (5 tests). + +- [ ] **Step 5: Commit** + +```bash +git add front/features/platform-admin/model/platform-admin-ai-ops-model.ts front/features/platform-admin/model/platform-admin-ai-ops-model.test.ts +git commit -m "feat: add admin ai-ops job filter url model" +``` + +--- + +## Task 2: Seed filtered jobs in the route loader + +**Files:** +- Modify: `front/features/platform-admin/route/admin-ai-ops-data.ts` + +This mirrors `admin-analytics-data.ts`, which reads `args.request.url` and seeds the windowed query. We seed the summary (unfiltered) plus the jobs query for the active URL filter so a deep-linked `?errorCode=...` is warm on first paint. + +- [ ] **Step 1: Replace the loader factory implementation** + +Replace the entire contents of `front/features/platform-admin/route/admin-ai-ops-data.ts` with: + +```ts +import type { QueryClient } from "@tanstack/react-query"; +import type { LoaderFunctionArgs } from "react-router-dom"; +import { + aiOpsFilterFromSearchParams, + aiOpsFilterToQuery, + EMPTY_AI_OPS_FILTER, +} from "@/features/platform-admin/model/platform-admin-ai-ops-model"; +import { + platformAdminAiOpsJobsQuery, + platformAdminAiOpsSummaryQuery, +} from "@/features/platform-admin/queries/platform-admin-ai-ops-queries"; + +export function adminAiOpsLoaderFactory(queryClient: QueryClient) { + return async function loadAdminAiOps(args?: LoaderFunctionArgs) { + const filter = args + ? aiOpsFilterFromSearchParams(new URL(args.request.url).searchParams) + : EMPTY_AI_OPS_FILTER; + await Promise.all([ + queryClient.fetchQuery(platformAdminAiOpsSummaryQuery()), + queryClient.fetchQuery(platformAdminAiOpsJobsQuery(aiOpsFilterToQuery(filter))), + ]); + return null; + }; +} +``` + +- [ ] **Step 2: Run the existing loader/route tests to verify no regression** + +Run: `pnpm --dir front test admin-ai-ops` +Expected: PASS (the existing route test still passes — loader signature accepts optional args). + +- [ ] **Step 3: Commit** + +```bash +git add front/features/platform-admin/route/admin-ai-ops-data.ts +git commit -m "feat: seed admin ai-ops jobs from url filter in loader" +``` + +--- + +## Task 3: Drive the jobs query from URL filter in the route + +**Files:** +- Modify: `front/features/platform-admin/route/admin-ai-ops-route.tsx` +- Test: `front/features/platform-admin/route/admin-ai-ops-route.test.tsx` + +- [ ] **Step 1: Add the failing test** + +Append this test inside the existing `describe("AdminAiOpsRoute", ...)` block in `front/features/platform-admin/route/admin-ai-ops-route.test.tsx`. Also update the top `renderRoute` helper to accept an optional initial entry and seed a filtered jobs key. Replace the existing `renderRoute` function and add the new test: + +Replace `renderRoute` with: + +```tsx +function renderRoute(initialEntry = "/admin/ai-ops") { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + queryClient.setQueryData(platformAdminSummaryQuery().queryKey, { + platformRole: "OWNER", + activeClubCount: 0, + domainActionRequiredCount: 0, + domainsRequiringAction: [], + }); + queryClient.setQueryData(platformAdminAiOpsSummaryQuery().queryKey, { + activeJobCount: 0, + failedLast24h: 0, + monthToDateCostEstimateUsd: "0", + failureCodes: [{ code: "PROVIDER_RATE_LIMITED", count: 2 }], + providerCosts: [], + staleCandidateCount: 0, + }); + queryClient.setQueryData(platformAdminAiOpsJobsQuery().queryKey, { items: [] }); + queryClient.setQueryData( + platformAdminAiOpsJobsQuery({ errorCode: "PROVIDER_RATE_LIMITED" }).queryKey, + { items: [] }, + ); + return render( + + + + + , + ); +} +``` + +Add this import at the top of the test file (with the other query imports): + +```tsx +import userEvent from "@testing-library/user-event"; +``` + +Add the new test: + +```tsx + it("selecting a failure code pushes the errorCode filter to the URL", async () => { + renderRoute(); + await userEvent.click(screen.getByRole("button", { name: /PROVIDER_RATE_LIMITED/ })); + expect(await screen.findByRole("button", { name: "전체 보기" })).toBeInTheDocument(); + }); + + it("renders the active filter banner when navigated with an errorCode", () => { + renderRoute("/admin/ai-ops?errorCode=PROVIDER_RATE_LIMITED"); + expect(screen.getByText(/PROVIDER_RATE_LIMITED/)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "전체 보기" })).toBeInTheDocument(); + }); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --dir front test admin-ai-ops-route` +Expected: FAIL — no "전체 보기" button exists yet (UI not wired). + +- [ ] **Step 3: Update the route component** + +Replace the entire contents of `front/features/platform-admin/route/admin-ai-ops-route.tsx` with: + +```tsx +import { useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useSearchParams } from "react-router-dom"; +import { PlatformAdminAiOps } from "@/features/platform-admin/ui/platform-admin-ai-ops"; +import { + platformAdminAiOpsJobsQuery, + platformAdminAiOpsSummaryQuery, + useForceCancelPlatformAdminAiJobMutation, +} from "@/features/platform-admin/queries/platform-admin-ai-ops-queries"; +import { platformAdminSummaryQuery } from "@/features/platform-admin/queries/platform-admin-queries"; +import { + aiOpsFilterFromSearchParams, + aiOpsFilterToQuery, + aiOpsSearchFromFilter, + EMPTY_AI_OPS_FILTER, +} from "@/features/platform-admin/model/platform-admin-ai-ops-model"; + +export function AdminAiOpsRoute() { + const [searchParams, setSearchParams] = useSearchParams(); + const filter = useMemo(() => aiOpsFilterFromSearchParams(searchParams), [searchParams]); + const role = useQuery(platformAdminSummaryQuery()).data!.platformRole; + const summaryQuery = useQuery(platformAdminAiOpsSummaryQuery()); + const jobsQuery = useQuery(platformAdminAiOpsJobsQuery(aiOpsFilterToQuery(filter))); + const forceCancel = useForceCancelPlatformAdminAiJobMutation(); + + const disabled = summaryQuery.error instanceof Response && summaryQuery.error.status === 503; + + if (disabled) { + return ( +
+

AI Ops

+
+

운영 정상

+

AI generation이 일시 비활성 상태입니다. 활성화되면 작업 큐가 자동으로 다시 채워집니다.

+
+
+ ); + } + + return ( +
+

AI Ops

+ forceCancel.mutate(jobId)} + activeFilter={filter} + onSelectFailureCode={(code) => + setSearchParams(aiOpsSearchFromFilter({ ...EMPTY_AI_OPS_FILTER, errorCode: code })) + } + onClearFilter={() => setSearchParams(aiOpsSearchFromFilter(EMPTY_AI_OPS_FILTER))} + /> +
+ ); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm --dir front test admin-ai-ops-route` +Expected: PASS (existing + 2 new tests). NOTE: this depends on Task 4's UI changes for the "전체 보기" button and clickable failure code; if running tasks strictly in order, Step 4 passes only after Task 4. Run this verification again at the end of Task 4. + +- [ ] **Step 5: Commit** + +```bash +git add front/features/platform-admin/route/admin-ai-ops-route.tsx front/features/platform-admin/route/admin-ai-ops-route.test.tsx +git commit -m "feat: drive admin ai-ops jobs query from url filter" +``` + +--- + +## Task 4: Clickable failure codes, active-filter banner, filtered empty state (UI) + +**Files:** +- Modify: `front/features/platform-admin/ui/platform-admin-ai-ops.tsx` +- Test: `front/features/platform-admin/ui/platform-admin-ai-ops.test.tsx` + +- [ ] **Step 1: Add failing UI tests** + +Append these tests inside the existing `describe("PlatformAdminAiOps", ...)` block in `front/features/platform-admin/ui/platform-admin-ai-ops.test.tsx`: + +```tsx + it("renders failure codes as buttons and reports selection", async () => { + const onSelectFailureCode = vi.fn(); + render( + , + ); + await userEvent.click(screen.getByRole("button", { name: /PROVIDER_RATE_LIMITED/ })); + expect(onSelectFailureCode).toHaveBeenCalledWith("PROVIDER_RATE_LIMITED"); + }); + + it("shows an active-filter banner with a clear control", async () => { + const onClearFilter = vi.fn(); + render( + , + ); + expect(screen.getByText(/PROVIDER_RATE_LIMITED/)).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "전체 보기" })); + expect(onClearFilter).toHaveBeenCalledTimes(1); + }); + + it("shows an honest filtered empty state when a filter yields no jobs", () => { + render( + , + ); + expect(screen.getByText("이 필터에 해당하는 AI job이 없습니다.")).toBeInTheDocument(); + }); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --dir front test platform-admin-ai-ops.test` +Expected: FAIL — failure codes are list items not buttons; no "전체 보기"; no filtered empty text. + +- [ ] **Step 3: Update the UI component** + +In `front/features/platform-admin/ui/platform-admin-ai-ops.tsx`: + +(a) Extend `PlatformAdminAiOpsProps` (add the three new optional props): + +```tsx +type PlatformAdminAiOpsProps = { + role: PlatformAdminAiOpsRole; + summary: PlatformAdminAiOpsSummaryView | null; + jobs: PlatformAdminAiOpsJobView[]; + loading?: boolean; + error?: string | null; + onForceCancel?: (jobId: string) => void; + activeFilter?: { errorCode: string | null; clubId: string | null }; + onSelectFailureCode?: (code: string) => void; + onClearFilter?: () => void; +}; +``` + +(b) Update the function signature destructuring to include the new props: + +```tsx +export function PlatformAdminAiOps({ + role, + summary, + jobs, + loading = false, + error = null, + onForceCancel, + activeFilter, + onSelectFailureCode, + onClearFilter, +}: PlatformAdminAiOpsProps) { + const canAct = role === "OWNER" || role === "OPERATOR"; + const filterActive = Boolean(activeFilter?.errorCode || activeFilter?.clubId); +``` + +(c) Replace the "Failure codes" `SmallList` usage in the `__sidecars` block with a `FailureCodeList`. The block currently is: + +```tsx +
+ `${item.code} ${item.count}`)} + emptyText="최근 실패 코드 없음" + /> + `${item.provider} / ${item.model} $${item.costEstimateUsd}`)} + emptyText="비용 집계 없음" + /> +
+``` + +Replace it with: + +```tsx +
+ + `${item.provider} / ${item.model} $${item.costEstimateUsd}`)} + emptyText="비용 집계 없음" + /> +
+``` + +(d) Add the active-filter banner directly above the `__jobs` block. Insert before `
`: + +```tsx + {filterActive ? ( +
+ + 필터: {activeFilter?.errorCode ?? activeFilter?.clubId} + + +
+ ) : null} +``` + +(e) Replace the jobs empty branch. The current empty branch is: + +```tsx + ) : ( +

표시할 AI job이 없습니다.

+ )} +``` + +Replace with: + +```tsx + ) : ( +

+ {filterActive ? "이 필터에 해당하는 AI job이 없습니다." : "표시할 AI job이 없습니다."} +

+ )} +``` + +(f) Add the `FailureCodeList` component next to the existing `SmallList` helper at the bottom of the file: + +```tsx +function FailureCodeList({ + items, + activeCode, + onSelect, +}: { + items: Array<{ code: string; count: number }>; + activeCode: string | null; + onSelect?: (code: string) => void; +}) { + return ( +
+

Failure codes

+ {items.length > 0 ? ( +
    + {items.map((item) => ( +
  • + +
  • + ))} +
+ ) : ( +

최근 실패 코드 없음

+ )} +
+ ); +} +``` + +- [ ] **Step 4: Run UI tests to verify they pass** + +Run: `pnpm --dir front test platform-admin-ai-ops.test` +Expected: PASS (existing + 3 new tests). The existing "shows safe aggregate..." test still finds `PROVIDER_RATE_LIMITED` text (now inside a button). + +- [ ] **Step 5: Run the route tests again (Task 3 dependency closes here)** + +Run: `pnpm --dir front test admin-ai-ops-route` +Expected: PASS — the "전체 보기" button and clickable failure code now exist. + +- [ ] **Step 6: Commit** + +```bash +git add front/features/platform-admin/ui/platform-admin-ai-ops.tsx front/features/platform-admin/ui/platform-admin-ai-ops.test.tsx +git commit -m "feat: make admin ai-ops failure codes drill into filtered jobs" +``` + +--- + +## Task 5: Playwright e2e — failure-code drilldown happy path + +**Files:** +- Create: `front/tests/e2e/admin-ai-ops-drilldown.spec.ts` + +Mirrors `admin-analytics.spec.ts`: stub BFF auth/summary/clubs, then stub the ai-ops summary and jobs endpoints, returning a filtered job set when `errorCode` is present. + +- [ ] **Step 1: Write the e2e spec** + +Create `front/tests/e2e/admin-ai-ops-drilldown.spec.ts`: + +```ts +import { expect, test, type Page, type Route } from "@playwright/test"; +import type { PlatformAdminRole } from "@/features/platform-admin/api/platform-admin-contracts"; +import type { AuthMeResponse } from "@/shared/auth/auth-contracts"; + +function platformAdminAuth(role: PlatformAdminRole): AuthMeResponse { + const email = `${role.toLowerCase()}@example.com`; + return { + authenticated: true, + userId: `platform-${role.toLowerCase()}-user`, + membershipId: null, + clubId: null, + email, + displayName: `${role} admin`, + accountName: `${role} admin`, + role: null, + membershipStatus: null, + approvalState: "INACTIVE", + currentMembership: null, + joinedClubs: [], + platformAdmin: { userId: `platform-${role.toLowerCase()}-user`, email, role }, + recommendedAppEntryUrl: "/admin", + }; +} + +async function json(route: Route, status: number, body: unknown): Promise { + await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) }); +} + +async function routeShell(page: Page, role: PlatformAdminRole): Promise { + await page.route("**/api/bff/api/auth/me**", async (route) => { + await json(route, 200, platformAdminAuth(role)); + }); + await page.route("**/api/bff/api/admin/summary", async (route) => { + await json(route, 200, { + platformRole: role, + activeClubCount: 1, + domainActionRequiredCount: 0, + domains: [], + domainsRequiringAction: [], + }); + }); + await page.route("**/api/bff/api/admin/clubs", async (route) => { + await json(route, 200, { items: [] }); + }); +} + +function job(overrides: Record) { + return { + jobId: "job-1", + club: { clubId: "club-1", slug: "club-one", name: "Club One" }, + session: { sessionId: "session-1", number: 7, bookTitle: "Book" }, + status: "FAILED", + stage: null, + provider: "OPENAI", + model: "gpt-model", + errorCode: "PROVIDER_RATE_LIMITED", + safeErrorMessage: "rate limited", + costEstimateUsd: "0.1000", + createdAt: "2026-05-30T00:00:00Z", + lastUpdatedAt: "2026-05-30T00:01:00Z", + expiresAt: null, + staleCandidate: false, + availableActions: [], + ...overrides, + }; +} + +async function routeAiOps(page: Page): Promise { + await page.route("**/api/bff/api/admin/ai-generation/summary", async (route) => { + await json(route, 200, { + activeJobCount: 0, + failedLast24h: 2, + monthToDateCostEstimateUsd: "0.2000", + failureCodes: [{ code: "PROVIDER_RATE_LIMITED", count: 2 }], + providerCosts: [], + staleCandidateCount: 0, + }); + }); + await page.route("**/api/bff/api/admin/ai-generation/jobs**", async (route) => { + const url = new URL(route.request().url()); + const errorCode = url.searchParams.get("errorCode"); + const items = errorCode === "PROVIDER_RATE_LIMITED" ? [job({})] : []; + await json(route, 200, { items, nextCursor: null }); + }); +} + +test("owner drills from a failure code into the affected jobs", async ({ page }) => { + await routeShell(page, "OWNER"); + await routeAiOps(page); + + await page.goto("/admin/ai-ops"); + + await expect(page.getByRole("heading", { name: "AI Ops", level: 1 })).toBeVisible(); + await expect(page.getByText("표시할 AI job이 없습니다.")).toBeVisible(); + + await page.getByRole("button", { name: /PROVIDER_RATE_LIMITED/ }).click(); + + await expect(page).toHaveURL(/errorCode=PROVIDER_RATE_LIMITED/); + await expect(page.getByText("Club One")).toBeVisible(); + await expect(page.getByRole("button", { name: "전체 보기" })).toBeVisible(); + + await page.getByRole("button", { name: "전체 보기" }).click(); + await expect(page).not.toHaveURL(/errorCode=/); + + await expect(page.getByText("@example.com")).toHaveCount(0); + await expect(page.getByText("{\"")).toHaveCount(0); +}); +``` + +- [ ] **Step 2: Run the e2e spec** + +Run: `pnpm --dir front test:e2e admin-ai-ops-drilldown` +Expected: PASS. (If the e2e runner needs the full suite invocation, run `pnpm --dir front test:e2e -- admin-ai-ops-drilldown`.) + +- [ ] **Step 3: Commit** + +```bash +git add front/tests/e2e/admin-ai-ops-drilldown.spec.ts +git commit -m "test: cover admin ai-ops failure-code drilldown e2e" +``` + +--- + +## Task 6: CHANGELOG + full verification + +**Files:** +- Modify: `CHANGELOG.md` + +- [ ] **Step 1: Add a CHANGELOG Unreleased entry** + +In `CHANGELOG.md`, under `## Unreleased` → `### Engineering`, add this bullet (describe shipped behavior, no internal plan language): + +```markdown +- **platform-admin:** `/admin/ai-ops` failure codes are now drilldown controls. Selecting a failure code filters the job list to the affected clubs/sessions and reflects the filter in URL state (`?errorCode=`), with a "전체 보기" control to clear it. Filtered empty states stay honest ("이 필터에 해당하는 AI job이 없습니다.") and no raw provider error/content fields are exposed. +``` + +- [ ] **Step 2: Run the full frontend gate** + +Run each and confirm PASS: + +```bash +pnpm --dir front lint +pnpm --dir front test +pnpm --dir front build +pnpm --dir front test:e2e admin-ai-ops-drilldown +``` + +Expected: lint clean, unit tests pass, build succeeds, e2e passes. + +- [ ] **Step 3: Public-safety scan of changed files** + +Run: `git diff --name-only origin/main..HEAD | xargs grep -nE "@example\.com|OCID|ocid1\.|[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}" 2>/dev/null` +Expected: only `@example.com` matches inside `front/tests/e2e/*.spec.ts` (test fixtures, allowed). No tokens/OCIDs/private domains in source or CHANGELOG. + +- [ ] **Step 4: Commit** + +```bash +git add CHANGELOG.md +git commit -m "docs: record admin ai-ops failure-code drilldown in changelog" +``` + +--- + +## Verification Gates (charter §8 — S6 subset applicable to P1) + +- Contract: frontend `PlatformAdminAiOpsFilters` (errorCode/clubId) drives `GET /api/admin/ai-generation/jobs`; e2e mock reads the same params. No server change. +- Authorization: drilldown is read-only; OWNER/OPERATOR/SUPPORT all view. No new write path. +- Public safety: filtered jobs reuse the existing safe projection (errorCode + safeErrorMessage only); no raw provider error/transcript/result JSON. Verified by Task 6 Step 3 scan and the e2e negative assertions. +- UI: Playwright happy path (Task 5) + filtered/empty unit coverage (Task 4). +- Hardening gate (charter §6) applied to the touched surface: failure codes are keyboard-focusable buttons with `aria-pressed`; banner uses `role="status"`; honest filtered empty state; calm operating-ledger tone reusing existing classes. +- Regression: `pnpm --dir front lint`, `pnpm --dir front test`, `pnpm --dir front build`, `pnpm --dir front test:e2e` (Task 6). + +## Out of Scope (later S6 sub-plans) + +- P2: health/audit AI 신호 → `/admin/ai-ops` deep-link 연결성 (이 plan이 만든 `?errorCode=`/`?clubId=` URL 계약을 재사용). +- P3: 비용/사용량 윈도우 추세. +- P4: stale job retry 조치(서버 `AiOpsAction.RETRY` + audit). diff --git a/docs/superpowers/plans/2026-05-30-admin-s6-t2-aiops-cost-usage-trend.md b/docs/superpowers/plans/2026-05-30-admin-s6-t2-aiops-cost-usage-trend.md new file mode 100644 index 00000000..8960f910 --- /dev/null +++ b/docs/superpowers/plans/2026-05-30-admin-s6-t2-aiops-cost-usage-trend.md @@ -0,0 +1,965 @@ +# Admin S6-T2 AI Ops Cost/Usage Windowed Trend Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Extend `/admin/ai-ops` summary from a single month-to-date cost number to a 7/30/90-day windowed cost+usage trend with current-vs-prior delta, reusing the S8 analytics raw-count→pure-derive pattern, with no charting library. + +**Architecture:** Server adds an aigen-local window enum and a `costTrend` block to `AiOpsSummary`; the JDBC adapter returns only raw cost+job-count for a window range, and the application service derives delta/availability (pure, unit-tested). The controller accepts `?window=`. The frontend adds `?window=` URL state, threads it through the summary query, and renders a delta metric with an honest empty state. The existing `monthToDateCostEstimateUsd` headline stays (additive, non-breaking). + +**Tech Stack:** Kotlin/Spring Boot, JdbcTemplate, JUnit5/AssertJ (server); React/Vite, TanStack Query, react-router, Vitest, Playwright (frontend). + +**Source spec:** `docs/superpowers/specs/2026-05-30-admin-vnext-s6-aiops-depth-s9-host-reinforcement-design.md` §5.1. This plan implements **Slice A only**; Slices B/C/D get their own plans. + +**Charter constraints (do not violate):** +- No charting library — trend is current-vs-prior delta only. +- No AI generation state-machine changes. +- No provider raw error / transcript / result JSON exposure. +- aigen-local window enum — do **not** import `com.readmates.admin.analytics.*` (keeps the ai-ops filter model framework-independent). + +--- + +## File Structure + +**Server (create/modify):** +- Modify `server/src/main/kotlin/com/readmates/aigen/application/model/AiGenerationOpsModels.kt` — add `AiOpsCostWindow`, `AiOpsTrendAvailability`, `AiOpsDeltaDirection`, `AiOpsWindowUsage`, `AiOpsCostTrend`; add `costTrend` to `AiOpsSummary`. +- Modify `server/src/main/kotlin/com/readmates/aigen/application/port/out/AiGenerationOpsAuditPorts.kt` — add `windowUsageBetween`. +- Modify `server/src/main/kotlin/com/readmates/aigen/application/port/in/AiGenerationOpsUseCases.kt` — add `window` param (defaulted) to `GetAiOpsSummaryUseCase.summary`. +- Modify `server/src/main/kotlin/com/readmates/aigen/application/service/AiGenerationOpsService.kt` — derive `costTrend`. +- Modify `server/src/main/kotlin/com/readmates/aigen/adapter/out/persistence/JdbcAiGenerationOpsAuditRepository.kt` — implement `windowUsageBetween`. +- Modify `server/src/main/kotlin/com/readmates/aigen/adapter/in/web/AiGenerationOpsController.kt` — accept `?window=`. +- Modify `server/src/main/kotlin/com/readmates/aigen/adapter/in/web/AiGenerationOpsWebDtos.kt` — add `costTrend` to `AiOpsSummaryResponse`. + +**Server tests (modify):** +- `server/src/test/kotlin/com/readmates/aigen/application/service/AiGenerationOpsServiceTest.kt` — configurable fake usage + trend derivation tests. +- `server/src/test/kotlin/com/readmates/aigen/adapter/out/persistence/JdbcAiGenerationOpsAuditRepositoryTest.kt` — window usage query test. +- `server/src/test/kotlin/com/readmates/aigen/adapter/in/web/AiGenerationOpsControllerTest.kt` — window param + costTrend response. + +**Frontend (modify):** +- `front/features/platform-admin/model/platform-admin-domain-types.ts` — add `costTrend` to `PlatformAdminAiOpsSummaryResponse`. +- `front/features/platform-admin/model/platform-admin-ai-ops-model.ts` — window URL-state helpers. +- `front/features/platform-admin/api/platform-admin-api.ts` — `fetchPlatformAdminAiOpsSummary(window?)`. +- `front/features/platform-admin/queries/platform-admin-ai-ops-queries.ts` — window-keyed summary query. +- `front/features/platform-admin/route/admin-ai-ops-data.ts` — seed window from search params. +- `front/features/platform-admin/route/admin-ai-ops-route.tsx` — window state + selector wiring. +- `front/features/platform-admin/ui/platform-admin-ai-ops.tsx` — render trend + window selector + view type. + +**Frontend tests (modify/create co-located):** +- `front/features/platform-admin/model/platform-admin-ai-ops-model.test.ts` +- `front/features/platform-admin/ui/platform-admin-ai-ops.test.tsx` +- `front/tests/e2e/` — extend the existing admin ai-ops e2e. + +**Docs:** +- `CHANGELOG.md` — Unreleased entry. + +--- + +## Task 1: Server domain models for windowed cost trend + +**Files:** +- Modify: `server/src/main/kotlin/com/readmates/aigen/application/model/AiGenerationOpsModels.kt` +- Test: `server/src/test/kotlin/com/readmates/aigen/application/model/AiOpsCostWindowTest.kt` (create) + +- [ ] **Step 1: Write the failing test** + +Create `server/src/test/kotlin/com/readmates/aigen/application/model/AiOpsCostWindowTest.kt`: + +```kotlin +package com.readmates.aigen.application.model + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +class AiOpsCostWindowTest { + @Test + fun `fromWire maps known wire values`() { + assertThat(AiOpsCostWindow.fromWire("7d")).isEqualTo(AiOpsCostWindow.LAST_7D) + assertThat(AiOpsCostWindow.fromWire("30d")).isEqualTo(AiOpsCostWindow.LAST_30D) + assertThat(AiOpsCostWindow.fromWire("90d")).isEqualTo(AiOpsCostWindow.LAST_90D) + } + + @Test + fun `fromWire defaults to 30 days for null or unknown`() { + assertThat(AiOpsCostWindow.fromWire(null)).isEqualTo(AiOpsCostWindow.LAST_30D) + assertThat(AiOpsCostWindow.fromWire("bogus")).isEqualTo(AiOpsCostWindow.LAST_30D) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./server/gradlew -p server compileTestKotlin` +Expected: FAIL — `AiOpsCostWindow` unresolved reference. + +- [ ] **Step 3: Add the models** + +In `AiGenerationOpsModels.kt`, add below the existing `enum class AiOpsAction { FORCE_CANCEL }` line: + +```kotlin +enum class AiOpsCostWindow(val days: Long, val wire: String) { + LAST_7D(7, "7d"), + LAST_30D(30, "30d"), + LAST_90D(90, "90d"), + ; + + companion object { + fun fromWire(value: String?): AiOpsCostWindow = entries.firstOrNull { it.wire == value } ?: LAST_30D + } +} + +enum class AiOpsTrendAvailability { AVAILABLE, NOT_ENOUGH_DATA } + +enum class AiOpsDeltaDirection { UP, DOWN, FLAT, NONE } + +data class AiOpsWindowUsage( + val costUsd: BigDecimal, + val jobCount: Long, +) + +data class AiOpsCostTrend( + val window: AiOpsCostWindow, + val currentCostUsd: BigDecimal, + val priorCostUsd: BigDecimal, + val currentJobCount: Long, + val priorJobCount: Long, + val deltaDirection: AiOpsDeltaDirection, + val availability: AiOpsTrendAvailability, +) +``` + +Then add `val costTrend: AiOpsCostTrend,` as the final field of `data class AiOpsSummary` (after `staleCandidateCount`). + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./server/gradlew -p server test --tests "com.readmates.aigen.application.model.AiOpsCostWindowTest"` +Expected: PASS. (Other modules referencing `AiOpsSummary` will not yet compile — fixed in Task 3/4; that is expected at this point.) + +- [ ] **Step 5: Commit** + +```bash +git add server/src/main/kotlin/com/readmates/aigen/application/model/AiGenerationOpsModels.kt \ + server/src/test/kotlin/com/readmates/aigen/application/model/AiOpsCostWindowTest.kt +git commit -m "feat: add ai-ops windowed cost-trend domain models" +``` + +--- + +## Task 2: Adapter port + JDBC window usage query + +**Files:** +- Modify: `server/src/main/kotlin/com/readmates/aigen/application/port/out/AiGenerationOpsAuditPorts.kt` +- Modify: `server/src/main/kotlin/com/readmates/aigen/adapter/out/persistence/JdbcAiGenerationOpsAuditRepository.kt` +- Test: `server/src/test/kotlin/com/readmates/aigen/adapter/out/persistence/JdbcAiGenerationOpsAuditRepositoryTest.kt` + +- [ ] **Step 1: Write the failing test** + +Add to `JdbcAiGenerationOpsAuditRepositoryTest`: + +```kotlin + @Test + fun `windowUsageBetween sums cost and counts rows in the half-open range`() { + val start = Instant.parse("2026-05-10T00:00:00Z") + val end = Instant.parse("2026-05-20T00:00:00Z") + // in range + insertAuditRow(provider = "OPENAI", model = "gpt-model", cost = BigDecimal("0.1000"), createdAt = Instant.parse("2026-05-12T00:00:00Z")) + insertAuditRow(provider = "CLAUDE", model = "claude-model", cost = BigDecimal("0.0500"), createdAt = Instant.parse("2026-05-19T23:59:59Z")) + // out of range: before start, and on the exclusive end boundary + insertAuditRow(provider = "OPENAI", model = "gpt-model", cost = BigDecimal("9.0000"), createdAt = Instant.parse("2026-05-09T23:59:59Z")) + insertAuditRow(provider = "OPENAI", model = "gpt-model", cost = BigDecimal("9.0000"), createdAt = end) + + val usage = repository.windowUsageBetween(start, end) + + assertThat(usage.jobCount).isEqualTo(2L) + assertThat(usage.costUsd).isEqualByComparingTo(BigDecimal("0.1500")) + } +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./server/gradlew -p server compileTestKotlin` +Expected: FAIL — `windowUsageBetween` unresolved. + +- [ ] **Step 3: Add port method + JDBC implementation** + +In `AiGenerationOpsAuditPorts.kt`, add the import `import com.readmates.aigen.application.model.AiOpsWindowUsage` and add to interface `AiGenerationAuditQueryPort`: + +```kotlin + fun windowUsageBetween( + start: Instant, + endExclusive: Instant, + ): AiOpsWindowUsage +``` + +In `JdbcAiGenerationOpsAuditRepository.kt`, add the import `import com.readmates.aigen.application.model.AiOpsWindowUsage` and implement (place after `costSince`): + +```kotlin + override fun windowUsageBetween( + start: Instant, + endExclusive: Instant, + ): AiOpsWindowUsage = + jdbcTemplate.query( + """ + select + coalesce(sum(cost_estimate_usd), 0) as cost, + count(*) as cnt + from ai_generation_audit_log + where created_at >= ? + and created_at < ? + """.trimIndent(), + { rs, _ -> + AiOpsWindowUsage( + costUsd = rs.getBigDecimal("cost"), + jobCount = rs.getLong("cnt"), + ) + }, + Timestamp.from(start), + Timestamp.from(endExclusive), + ).first() +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./server/gradlew -p server test --tests "com.readmates.aigen.adapter.out.persistence.JdbcAiGenerationOpsAuditRepositoryTest"` +Expected: PASS (integration-tagged; runs MySQL testcontainer). + +- [ ] **Step 5: Commit** + +```bash +git add server/src/main/kotlin/com/readmates/aigen/application/port/out/AiGenerationOpsAuditPorts.kt \ + server/src/main/kotlin/com/readmates/aigen/adapter/out/persistence/JdbcAiGenerationOpsAuditRepository.kt \ + server/src/test/kotlin/com/readmates/aigen/adapter/out/persistence/JdbcAiGenerationOpsAuditRepositoryTest.kt +git commit -m "feat: query ai-ops window cost/usage from audit log" +``` + +--- + +## Task 3: Service derives costTrend + +**Files:** +- Modify: `server/src/main/kotlin/com/readmates/aigen/application/port/in/AiGenerationOpsUseCases.kt` +- Modify: `server/src/main/kotlin/com/readmates/aigen/application/service/AiGenerationOpsService.kt` +- Test: `server/src/test/kotlin/com/readmates/aigen/application/service/AiGenerationOpsServiceTest.kt` + +- [ ] **Step 1: Write the failing test** + +In `AiGenerationOpsServiceTest.kt`, replace the `EmptyAuditQueryPort` class with a configurable usage map and add trend tests. First, update the fake (replace the `windowUsageBetween` gap by adding a settable map keyed by start instant): + +```kotlin +private class EmptyAuditQueryPort : AiGenerationAuditQueryPort { + var jobById: AiOpsJobListItem? = null + val usageByStart = mutableMapOf() + + override fun countFailuresSince(since: Instant): Long = 0 + + override fun costSince(since: Instant): BigDecimal = BigDecimal.ZERO + + override fun failureCodesSince(since: Instant): List = emptyList() + + override fun providerCostsSince(since: Instant): List = emptyList() + + override fun windowUsageBetween(start: Instant, endExclusive: Instant): AiOpsWindowUsage = + usageByStart[start] ?: AiOpsWindowUsage(BigDecimal.ZERO, 0) + + override fun listJobs(filters: AiOpsJobFilters): AiOpsJobList = AiOpsJobList(emptyList(), null) + + override fun findJobById(jobId: UUID): AiOpsJobListItem? = jobById +} +``` + +Add imports `com.readmates.aigen.application.model.AiOpsCostWindow`, `AiOpsDeltaDirection`, `AiOpsTrendAvailability`, `AiOpsWindowUsage`. Then add tests (clock is fixed at `2026-05-18T00:00:00Z`): + +```kotlin + @Test + fun `summary derives 30d cost trend up when current exceeds prior`() { + val now = Instant.parse("2026-05-18T00:00:00Z") + auditQuery.usageByStart[now.minusSeconds(30 * 86400)] = AiOpsWindowUsage(BigDecimal("2.0000"), 5) + auditQuery.usageByStart[now.minusSeconds(60 * 86400)] = AiOpsWindowUsage(BigDecimal("1.0000"), 4) + + val trend = service.summary(admin(PlatformAdminRole.OWNER)).costTrend + + assertThat(trend.window).isEqualTo(AiOpsCostWindow.LAST_30D) + assertThat(trend.currentCostUsd).isEqualByComparingTo(BigDecimal("2.0000")) + assertThat(trend.priorCostUsd).isEqualByComparingTo(BigDecimal("1.0000")) + assertThat(trend.currentJobCount).isEqualTo(5L) + assertThat(trend.deltaDirection).isEqualTo(AiOpsDeltaDirection.UP) + assertThat(trend.availability).isEqualTo(AiOpsTrendAvailability.AVAILABLE) + } + + @Test + fun `summary reports NOT_ENOUGH_DATA when prior window had no jobs`() { + val now = Instant.parse("2026-05-18T00:00:00Z") + auditQuery.usageByStart[now.minusSeconds(7 * 86400)] = AiOpsWindowUsage(BigDecimal("0.5000"), 3) + auditQuery.usageByStart[now.minusSeconds(14 * 86400)] = AiOpsWindowUsage(BigDecimal.ZERO, 0) + + val trend = service.summary(admin(PlatformAdminRole.OWNER), AiOpsCostWindow.LAST_7D).costTrend + + assertThat(trend.window).isEqualTo(AiOpsCostWindow.LAST_7D) + assertThat(trend.availability).isEqualTo(AiOpsTrendAvailability.NOT_ENOUGH_DATA) + assertThat(trend.deltaDirection).isEqualTo(AiOpsDeltaDirection.NONE) + } +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./server/gradlew -p server compileTestKotlin` +Expected: FAIL — `costTrend` and the second `summary` arg unresolved. + +- [ ] **Step 3: Implement** + +In `AiGenerationOpsUseCases.kt`, add import `com.readmates.aigen.application.model.AiOpsCostWindow` and change the interface method: + +```kotlin +interface GetAiOpsSummaryUseCase { + fun summary( + admin: CurrentPlatformAdmin, + window: AiOpsCostWindow = AiOpsCostWindow.LAST_30D, + ): AiOpsSummary +} +``` + +In `AiGenerationOpsService.kt`, add imports for `AiOpsCostTrend`, `AiOpsCostWindow`, `AiOpsDeltaDirection`, `AiOpsTrendAvailability`, `AiOpsWindowUsage`. Change the override signature and append the trend: + +```kotlin + override fun summary( + admin: CurrentPlatformAdmin, + window: AiOpsCostWindow, + ): AiOpsSummary { + val now = clock.instant() + val activeJobs = jobStore.loadActiveJobs() + val monthStart = + now + .atZone(ZoneOffset.UTC) + .withDayOfMonth(1) + .toLocalDate() + .atStartOfDay(ZoneOffset.UTC) + .toInstant() + return AiOpsSummary( + activeJobCount = activeJobs.size, + failedLast24h = auditQueryPort.countFailuresSince(now.minus(Duration.ofHours(24))), + monthToDateCostEstimateUsd = auditQueryPort.costSince(monthStart), + failureCodes = auditQueryPort.failureCodesSince(monthStart), + providerCosts = auditQueryPort.providerCostsSince(monthStart), + staleCandidateCount = + activeJobs.count { + it.status in STALE_CANDIDATE_STATUSES && + it.lastUpdatedAt.isBefore(now.minus(STALE_CANDIDATE_AGE)) + }, + costTrend = costTrend(now, window), + ) + } + + private fun costTrend( + now: java.time.Instant, + window: AiOpsCostWindow, + ): AiOpsCostTrend { + val windowSeconds = Duration.ofDays(window.days) + val currentStart = now.minus(windowSeconds) + val priorStart = now.minus(windowSeconds.multipliedBy(2)) + val current = auditQueryPort.windowUsageBetween(currentStart, now) + val prior = auditQueryPort.windowUsageBetween(priorStart, currentStart) + val available = prior.jobCount > 0 + val direction = + if (!available) { + AiOpsDeltaDirection.NONE + } else { + when (current.costUsd.compareTo(prior.costUsd)) { + 1 -> AiOpsDeltaDirection.UP + -1 -> AiOpsDeltaDirection.DOWN + else -> AiOpsDeltaDirection.FLAT + } + } + return AiOpsCostTrend( + window = window, + currentCostUsd = current.costUsd, + priorCostUsd = prior.costUsd, + currentJobCount = current.jobCount, + priorJobCount = prior.jobCount, + deltaDirection = direction, + availability = if (available) AiOpsTrendAvailability.AVAILABLE else AiOpsTrendAvailability.NOT_ENOUGH_DATA, + ) + } +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./server/gradlew -p server test --tests "com.readmates.aigen.application.service.AiGenerationOpsServiceTest"` +Expected: PASS (all existing + 2 new tests). + +- [ ] **Step 5: Commit** + +```bash +git add server/src/main/kotlin/com/readmates/aigen/application/port/in/AiGenerationOpsUseCases.kt \ + server/src/main/kotlin/com/readmates/aigen/application/service/AiGenerationOpsService.kt \ + server/src/test/kotlin/com/readmates/aigen/application/service/AiGenerationOpsServiceTest.kt +git commit -m "feat: derive ai-ops windowed cost trend in summary service" +``` + +--- + +## Task 4: Controller window param + response DTO + +**Files:** +- Modify: `server/src/main/kotlin/com/readmates/aigen/adapter/in/web/AiGenerationOpsController.kt` +- Modify: `server/src/main/kotlin/com/readmates/aigen/adapter/in/web/AiGenerationOpsWebDtos.kt` +- Test: `server/src/test/kotlin/com/readmates/aigen/adapter/in/web/AiGenerationOpsControllerTest.kt` + +- [ ] **Step 1: Write the failing test** + +Open `AiGenerationOpsControllerTest.kt`, find the existing summary test for its setup style, and add a test asserting the window is parsed and `costTrend` is serialized. Mirror the existing mock/MockMvc style already in that file. Use this assertion shape (adapt the mock-setup lines to the file's existing helpers): + +```kotlin + @Test + fun `summary passes window to use case and serializes cost trend`() { + whenever(summaryUseCase.summary(any(), eq(AiOpsCostWindow.LAST_7D))).thenReturn( + sampleSummary( + costTrend = AiOpsCostTrend( + window = AiOpsCostWindow.LAST_7D, + currentCostUsd = BigDecimal("2.0000"), + priorCostUsd = BigDecimal("1.0000"), + currentJobCount = 5, + priorJobCount = 4, + deltaDirection = AiOpsDeltaDirection.UP, + availability = AiOpsTrendAvailability.AVAILABLE, + ), + ), + ) + + mockMvc.perform(get("/api/admin/ai-generation/summary?window=7d")) + .andExpect(status().isOk) + .andExpect(jsonPath("$.costTrend.window").value("7d")) + .andExpect(jsonPath("$.costTrend.currentCostUsd").value("2.0000")) + .andExpect(jsonPath("$.costTrend.deltaDirection").value("UP")) + .andExpect(jsonPath("$.costTrend.availability").value("AVAILABLE")) + } +``` + +If the test file has no `sampleSummary(...)` helper, add one that builds an `AiOpsSummary` with empty lists/zeros and the given `costTrend` (default a `LAST_30D`, `NOT_ENOUGH_DATA`, `NONE`, zeros trend so existing tests still compile). Add imports for `AiOpsCostTrend`, `AiOpsCostWindow`, `AiOpsDeltaDirection`, `AiOpsTrendAvailability`. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./server/gradlew -p server compileTestKotlin` +Expected: FAIL — `costTrend` not on `AiOpsSummaryResponse`, `summary` has no window arg. + +- [ ] **Step 3: Implement** + +In `AiGenerationOpsController.kt`, add imports `import com.readmates.aigen.application.model.AiOpsCostWindow` and `import org.springframework.web.bind.annotation.RequestParam` (already imported). Change the summary mapping: + +```kotlin + @GetMapping("/summary") + fun summary( + admin: CurrentPlatformAdmin, + @RequestParam(required = false) window: String?, + ): AiOpsSummaryResponse = AiOpsSummaryResponse.from(summaryUseCase.summary(admin, AiOpsCostWindow.fromWire(window))) +``` + +In `AiGenerationOpsWebDtos.kt`, add imports `import com.readmates.aigen.application.model.AiOpsCostTrend`. Add a `costTrend: AiOpsCostTrendResponse` field to `AiOpsSummaryResponse` (after `staleCandidateCount`), map it in `from(...)`, and add: + +```kotlin +data class AiOpsCostTrendResponse( + val window: String, + val currentCostUsd: String, + val priorCostUsd: String, + val currentJobCount: Long, + val priorJobCount: Long, + val deltaDirection: String, + val availability: String, +) { + companion object { + fun from(trend: AiOpsCostTrend): AiOpsCostTrendResponse = + AiOpsCostTrendResponse( + window = trend.window.wire, + currentCostUsd = trend.currentCostUsd.toPlainString(), + priorCostUsd = trend.priorCostUsd.toPlainString(), + currentJobCount = trend.currentJobCount, + priorJobCount = trend.priorJobCount, + deltaDirection = trend.deltaDirection.name, + availability = trend.availability.name, + ) + } +} +``` + +In `AiOpsSummaryResponse.from`, add `costTrend = AiOpsCostTrendResponse.from(summary.costTrend),`. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./server/gradlew -p server test --tests "com.readmates.aigen.adapter.in.web.AiGenerationOpsControllerTest"` +Expected: PASS. + +- [ ] **Step 5: Server full check + commit** + +Run: `./server/gradlew -p server unitTest` +Expected: PASS (whole server unit suite green). + +```bash +git add server/src/main/kotlin/com/readmates/aigen/adapter/in/web/AiGenerationOpsController.kt \ + server/src/main/kotlin/com/readmates/aigen/adapter/in/web/AiGenerationOpsWebDtos.kt \ + server/src/test/kotlin/com/readmates/aigen/adapter/in/web/AiGenerationOpsControllerTest.kt +git commit -m "feat: expose ai-ops window param and cost trend response" +``` + +--- + +## Task 5: Frontend contract type + +**Files:** +- Modify: `front/features/platform-admin/model/platform-admin-domain-types.ts:147-154` + +- [ ] **Step 1: Add `costTrend` to the response type** + +In `platform-admin-domain-types.ts`, add to `PlatformAdminAiOpsSummaryResponse` (after `staleCandidateCount`): + +```typescript + costTrend: { + window: "7d" | "30d" | "90d"; + currentCostUsd: string; + priorCostUsd: string; + currentJobCount: number; + priorJobCount: number; + deltaDirection: "UP" | "DOWN" | "FLAT" | "NONE"; + availability: "AVAILABLE" | "NOT_ENOUGH_DATA"; + }; +``` + +- [ ] **Step 2: Typecheck** + +Run: `pnpm --dir front exec tsc -p tsconfig.json --noEmit` +Expected: errors only where mock fixtures lack `costTrend` (fixed in Task 8 tests) — no errors in `platform-admin-domain-types.ts` itself. + +- [ ] **Step 3: Commit** + +```bash +git add front/features/platform-admin/model/platform-admin-domain-types.ts +git commit -m "feat: add ai-ops costTrend to admin summary contract type" +``` + +--- + +## Task 6: Frontend window URL-state model + +**Files:** +- Modify: `front/features/platform-admin/model/platform-admin-ai-ops-model.ts` +- Test: `front/features/platform-admin/model/platform-admin-ai-ops-model.test.ts` + +- [ ] **Step 1: Write the failing test** + +Add to `platform-admin-ai-ops-model.test.ts`: + +```typescript +import { + AI_OPS_DEFAULT_WINDOW, + aiOpsWindowFromSearchParams, +} from "./platform-admin-ai-ops-model"; + +describe("aiOpsWindowFromSearchParams", () => { + it("reads a valid window", () => { + expect(aiOpsWindowFromSearchParams(new URLSearchParams("window=7d"))).toBe("7d"); + expect(aiOpsWindowFromSearchParams(new URLSearchParams("window=90d"))).toBe("90d"); + }); + + it("falls back to the default for missing or unknown window", () => { + expect(aiOpsWindowFromSearchParams(new URLSearchParams())).toBe(AI_OPS_DEFAULT_WINDOW); + expect(aiOpsWindowFromSearchParams(new URLSearchParams("window=year"))).toBe(AI_OPS_DEFAULT_WINDOW); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --dir front test platform-admin-ai-ops-model` +Expected: FAIL — `aiOpsWindowFromSearchParams` / `AI_OPS_DEFAULT_WINDOW` not exported. + +- [ ] **Step 3: Implement** + +Append to `platform-admin-ai-ops-model.ts`: + +```typescript +export type AiOpsCostWindow = "7d" | "30d" | "90d"; + +export const AI_OPS_COST_WINDOWS: AiOpsCostWindow[] = ["7d", "30d", "90d"]; + +export const AI_OPS_DEFAULT_WINDOW: AiOpsCostWindow = "30d"; + +export function aiOpsWindowFromSearchParams(params: URLSearchParams): AiOpsCostWindow { + const raw = params.get("window"); + return AI_OPS_COST_WINDOWS.includes(raw as AiOpsCostWindow) ? (raw as AiOpsCostWindow) : AI_OPS_DEFAULT_WINDOW; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm --dir front test platform-admin-ai-ops-model` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add front/features/platform-admin/model/platform-admin-ai-ops-model.ts \ + front/features/platform-admin/model/platform-admin-ai-ops-model.test.ts +git commit -m "feat: add ai-ops cost window url-state helpers" +``` + +--- + +## Task 7: Frontend api client + query thread window + +**Files:** +- Modify: `front/features/platform-admin/api/platform-admin-api.ts:107-113` +- Modify: `front/features/platform-admin/queries/platform-admin-ai-ops-queries.ts` +- Modify: `front/features/platform-admin/route/admin-ai-ops-data.ts` + +- [ ] **Step 1: Update the api client** + +In `platform-admin-api.ts`, replace `fetchPlatformAdminAiOpsSummary`: + +```typescript +export function fetchPlatformAdminAiOpsSummary(window?: string) { + const search = window ? `?window=${encodeURIComponent(window)}` : ""; + return readmatesFetch( + `/api/admin/ai-generation/summary${search}`, + undefined, + { clubSlug: undefined }, + ); +} +``` + +- [ ] **Step 2: Update the query options** + +In `platform-admin-ai-ops-queries.ts`, change the summary key + query to be window-keyed: + +```typescript +export const platformAdminAiOpsKeys = { + all: ["platform-admin", "ai-ops"] as const, + summary: (window?: string) => [...platformAdminAiOpsKeys.all, "summary", window ?? null] as const, + jobs: (filters?: PlatformAdminAiOpsFilters) => + [...platformAdminAiOpsKeys.all, "jobs", normalizeFilters(filters)] as const, +} as const; + +export function platformAdminAiOpsSummaryQuery(window?: string) { + return queryOptions({ + queryKey: platformAdminAiOpsKeys.summary(window), + queryFn: () => fetchPlatformAdminAiOpsSummary(window), + }); +} +``` + +In `useForceCancelPlatformAdminAiJobMutation`, the existing `invalidateQueries({ queryKey: platformAdminAiOpsKeys.summary() })` still matches all windows because `summary()` (no arg) produces the prefix `[..., "summary", null]`; change it to invalidate the whole family instead for correctness: + +```typescript + onSuccess: () => queryClient.invalidateQueries({ queryKey: platformAdminAiOpsKeys.all }), +``` + +- [ ] **Step 3: Seed window in the loader** + +In `admin-ai-ops-data.ts`, import `aiOpsWindowFromSearchParams` and seed the summary query with the window: + +```typescript +import { + aiOpsFilterFromSearchParams, + aiOpsFilterToQuery, + aiOpsWindowFromSearchParams, + AI_OPS_DEFAULT_WINDOW, + EMPTY_AI_OPS_FILTER, +} from "@/features/platform-admin/model/platform-admin-ai-ops-model"; +``` + +and inside the loader: + +```typescript + const filter = args + ? aiOpsFilterFromSearchParams(new URL(args.request.url).searchParams) + : EMPTY_AI_OPS_FILTER; + const window = args + ? aiOpsWindowFromSearchParams(new URL(args.request.url).searchParams) + : AI_OPS_DEFAULT_WINDOW; + await Promise.all([ + queryClient.fetchQuery(platformAdminAiOpsSummaryQuery(window)), + queryClient.fetchQuery(platformAdminAiOpsJobsQuery(aiOpsFilterToQuery(filter))), + ]); +``` + +- [ ] **Step 4: Typecheck + existing query test** + +Run: `pnpm --dir front test platform-admin-ai-ops-queries` +Expected: PASS (update any summary-key assertion in that test to include the window segment if present). + +- [ ] **Step 5: Commit** + +```bash +git add front/features/platform-admin/api/platform-admin-api.ts \ + front/features/platform-admin/queries/platform-admin-ai-ops-queries.ts \ + front/features/platform-admin/route/admin-ai-ops-data.ts \ + front/features/platform-admin/queries/platform-admin-ai-ops-queries.test.tsx +git commit -m "feat: thread ai-ops cost window through summary query and loader" +``` + +--- + +## Task 8: UI renders trend + window selector + +**Files:** +- Modify: `front/features/platform-admin/ui/platform-admin-ai-ops.tsx` +- Modify: `front/features/platform-admin/route/admin-ai-ops-route.tsx` +- Test: `front/features/platform-admin/ui/platform-admin-ai-ops.test.tsx` + +- [ ] **Step 1: Write the failing test** + +Add to `platform-admin-ai-ops.test.tsx` (mirror the file's existing render helper / props): + +```typescript + it("shows the windowed cost trend with a delta direction", () => { + render( + , + ); + expect(screen.getByText(/\$2\.0000/)).toBeInTheDocument(); + expect(screen.getByLabelText(/cost trend direction/i)).toHaveTextContent(/▲|UP/); + }); + + it("shows an honest empty state when the trend lacks prior data", () => { + render( + , + ); + expect(screen.getByText("데이터 부족")).toBeInTheDocument(); + }); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --dir front test platform-admin-ai-ops.test` +Expected: FAIL — `window` prop / `costTrend` view not handled. + +- [ ] **Step 3: Implement the UI** + +In `platform-admin-ai-ops.tsx`: + +Add `costTrend` to `PlatformAdminAiOpsSummaryView` (after `staleCandidateCount`): + +```typescript + costTrend: { + window: "7d" | "30d" | "90d"; + currentCostUsd: string; + priorCostUsd: string; + currentJobCount: number; + priorJobCount: number; + deltaDirection: "UP" | "DOWN" | "FLAT" | "NONE"; + availability: "AVAILABLE" | "NOT_ENOUGH_DATA"; + }; +``` + +Add to `PlatformAdminAiOpsProps`: + +```typescript + window?: "7d" | "30d" | "90d"; + onSelectWindow?: (window: "7d" | "30d" | "90d") => void; +``` + +Destructure `window`, `onSelectWindow` in the component params. Add a trend block after the `platform-admin-ai-ops__metrics` div. Render the window selector (a small button group over `["7d","30d","90d"]` calling `onSelectWindow`), then: + +```tsx +
+
+ {(["7d", "30d", "90d"] as const).map((w) => ( + + ))} +
+ {summary && summary.costTrend.availability === "NOT_ENOUGH_DATA" ? ( +

데이터 부족

+ ) : ( +

+ ${summary?.costTrend.currentCostUsd ?? "0.0000"}{" "} + {directionGlyph(summary?.costTrend.deltaDirection)}{" "} + 직전 ${summary?.costTrend.priorCostUsd ?? "0.0000"} +

+ )} +
+``` + +Add the helper near the bottom of the file: + +```tsx +function directionGlyph(direction?: "UP" | "DOWN" | "FLAT" | "NONE"): string { + switch (direction) { + case "UP": + return "▲"; + case "DOWN": + return "▼"; + case "FLAT": + return "→"; + default: + return "·"; + } +} +``` + +In `admin-ai-ops-route.tsx`, add window state and wire it: + +```tsx +import { + aiOpsFilterFromSearchParams, + aiOpsFilterToQuery, + aiOpsSearchFromFilter, + aiOpsWindowFromSearchParams, + EMPTY_AI_OPS_FILTER, +} from "@/features/platform-admin/model/platform-admin-ai-ops-model"; +``` + +then inside the component: + +```tsx + const window = useMemo(() => aiOpsWindowFromSearchParams(searchParams), [searchParams]); + const summaryQuery = useQuery(platformAdminAiOpsSummaryQuery(window)); +``` + +Pass to the UI: + +```tsx + window={window} + onSelectWindow={(next) => { + const params = aiOpsSearchFromFilter(filter); + params.set("window", next); + setSearchParams(params); + }} +``` + +Note: this preserves the active failure-code/club filter in the URL while changing the window (`aiOpsSearchFromFilter` serializes the current filter, then we add `window`). + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm --dir front test platform-admin-ai-ops.test` +Expected: PASS. + +- [ ] **Step 5: Frontend full checks + commit** + +Run: `pnpm --dir front lint && pnpm --dir front test && pnpm --dir front build` +Expected: PASS. + +```bash +git add front/features/platform-admin/ui/platform-admin-ai-ops.tsx \ + front/features/platform-admin/ui/platform-admin-ai-ops.test.tsx \ + front/features/platform-admin/route/admin-ai-ops-route.tsx +git commit -m "feat: render ai-ops windowed cost trend and window selector" +``` + +--- + +## Task 9: E2E — window toggle updates trend + +**Files:** +- Modify: the existing admin ai-ops e2e spec under `front/tests/e2e/` (locate with the grep below) and its BFF/admin mock. + +- [ ] **Step 1: Locate the existing ai-ops e2e + mock** + +Run: `grep -rln "ai-generation/summary\|ai-ops\|AI Ops" front/tests/e2e` +Expected: find the spec and the mock that serves `/api/admin/ai-generation/summary`. + +- [ ] **Step 2: Add `costTrend` to the mocked summary + a window assertion** + +Add `costTrend` to the mocked summary payload (`window: "30d"`, `currentCostUsd: "2.0000"`, `priorCostUsd: "1.0000"`, `currentJobCount: 5`, `priorJobCount: 4`, `deltaDirection: "UP"`, `availability: "AVAILABLE"`) so the route renders. If the mock can vary by `?window=`, return `availability: "NOT_ENOUGH_DATA"` for `window=7d`. Add a test step: load `/admin/ai-ops`, assert the trend value renders; click the `7d` window button; assert the URL contains `window=7d` and (if mock varies) the empty state "데이터 부족" appears. + +- [ ] **Step 3: Run the e2e** + +Run: `pnpm --dir front test:e2e` +Expected: PASS (ai-ops spec green; no `@example.com` or raw JSON in rendered output). + +- [ ] **Step 4: Commit** + +```bash +git add front/tests/e2e +git commit -m "test: cover admin ai-ops cost window toggle e2e" +``` + +--- + +## Task 10: CHANGELOG + final verification + +**Files:** +- Modify: `CHANGELOG.md` (Unreleased → Engineering) + +- [ ] **Step 1: Add the Unreleased entry** + +Under `## Unreleased` → `### Engineering`, add: + +```markdown +- **platform-admin:** `/admin/ai-ops` summary now shows a 7/30/90-day cost/usage trend (`?window=`) with current-vs-prior delta. The JDBC adapter returns only raw window cost/count; the application service derives delta/availability (pure, unit-tested) and reports `NOT_ENOUGH_DATA` honestly when the prior window had no jobs. No charting library added; the month-to-date headline is unchanged. aigen-local window enum keeps the slice framework-independent. +``` + +- [ ] **Step 2: Full regression** + +Run in sequence: +- `./server/gradlew -p server unitTest` +- `pnpm --dir front lint` +- `pnpm --dir front test` +- `pnpm --dir front build` +- `pnpm --dir front test:e2e` + +Expected: all PASS. If any check is skipped, report the exact command and reason. + +- [ ] **Step 3: Public-safety quick scan** + +Run: `git diff origin/main..HEAD -- front server | grep -niE "@example.com|@gmail|BEGIN .*KEY|ocid1\." || echo "clean"` +Expected: `clean` (no secrets/private data/token-shaped strings in the diff). + +- [ ] **Step 4: Commit** + +```bash +git add CHANGELOG.md +git commit -m "docs: record admin ai-ops cost/usage window trend in changelog" +``` + +--- + +## Self-Review Notes (verified before handoff) + +- **Spec coverage (§5.1):** windowed trend (Tasks 1–4, 8), `?window=` URL state (Tasks 6–8), S8 raw→derive split (Tasks 2–3), no chart library (Task 8 uses text/glyph), `NOT_ENOUGH_DATA` honesty (Task 3 + Task 8 empty state), MTD headline preserved (Task 3 keeps the field), aigen-local enum / no analytics import (Task 1). +- **Type consistency:** `AiOpsCostTrend` fields (`window/currentCostUsd/priorCostUsd/currentJobCount/priorJobCount/deltaDirection/availability`) are identical across domain model (Task 1), web DTO (Task 4), frontend contract (Task 5), and UI view (Task 8). Window wire values `7d|30d|90d` consistent server↔front. `windowUsageBetween(start, endExclusive)` signature consistent across port (Task 2), JDBC impl (Task 2), and service caller (Task 3). +- **Compile-order caveat:** Task 1 leaves the server temporarily non-compiling (callers of `AiOpsSummary` lack `costTrend`) until Task 3/4. Run module-scoped `--tests` as written until Task 4's `unitTest`. The configurable fake (`EmptyAuditQueryPort`) is updated in Task 3 to satisfy the new port method. +- **Placeholder scan:** none — every code step contains concrete content. diff --git a/docs/superpowers/plans/2026-05-30-admin-s6-t3-aiops-admin-retry.md b/docs/superpowers/plans/2026-05-30-admin-s6-t3-aiops-admin-retry.md new file mode 100644 index 00000000..d3ac974e --- /dev/null +++ b/docs/superpowers/plans/2026-05-30-admin-s6-t3-aiops-admin-retry.md @@ -0,0 +1,941 @@ +# Admin vNext S6-T3 — AI Ops Admin Retry Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a `RETRY_COMMIT` admin action to `/admin/ai-ops` that recovers a stuck `COMMITTING` AI-generation job back to `SUCCEEDED` so the host can re-commit it, OWNER/OPERATOR-gated and audit-ready. + +**Architecture:** Server adds a new `RetryAiOpsJobCommitUseCase` implemented by `AiGenerationOpsService`. The action reuses the *exact* `COMMITTING → SUCCEEDED` recovery transition the commit service already performs on every internal commit failure (`AiGenerationJobStore.transitionStatus`). It introduces **no** new terminal state or transition semantics, writes **no** session content (the host re-commits), and crucially **does not delete the transient payload** (the result snapshot must survive for the host's re-commit — the key difference from force-cancel). Frontend adds the action to the union type, an API wrapper, a TanStack mutation, a UI button, and route wiring, mirroring the existing force-cancel slice end-to-end. + +**Tech Stack:** Kotlin/Spring Boot (hexagonal: port-in interface + `@Service`), JUnit5 + AssertJ + MockMvc standalone; React/Vite + TanStack Query, Vitest + Testing Library, Playwright e2e with route mocking. + +**Source spec:** `docs/superpowers/specs/2026-05-30-admin-vnext-s6-aiops-depth-s9-host-reinforcement-design.md` §5.2 (Slice B). This is the **first of three** sequential slices (B → C → D); this plan implements **only** Slice B. + +**Key semantics (read before starting):** The host's `COMMIT_RETRY` is a pure frontend re-entry into the commit flow — there is no server commit-retry use-case to delegate to. Admin `RETRY_COMMIT` therefore means: a job stuck in `COMMITTING` (commit process died mid-flight; it is exactly a stale candidate) is reset to `SUCCEEDED`, unblocking the host to retry the commit themselves. See spec §5.2 "재정의 노트". + +--- + +## File Structure + +**Server — modify:** +- `server/src/main/kotlin/com/readmates/aigen/application/model/AiGenerationOpsModels.kt` — add `RETRY_COMMIT` to `AiOpsAction` enum. +- `server/src/main/kotlin/com/readmates/aigen/application/port/in/AiGenerationOpsUseCases.kt` — add `RetryAiOpsJobCommitUseCase` interface. +- `server/src/main/kotlin/com/readmates/aigen/application/service/AiGenerationOpsService.kt` — implement `retryCommit`, add `RETRY_COMMIT_STATUSES`, extend `availableActions`. +- `server/src/main/kotlin/com/readmates/aigen/adapter/in/web/AiGenerationOpsController.kt` — add `POST /jobs/{jobId}/retry-commit`, inject the use case. + +**Server — modify tests:** +- `server/src/test/kotlin/com/readmates/aigen/application/service/AiGenerationOpsServiceTest.kt` +- `server/src/test/kotlin/com/readmates/aigen/adapter/in/web/AiGenerationOpsControllerTest.kt` + +**Frontend — modify:** +- `front/features/platform-admin/model/platform-admin-domain-types.ts` — extend `PlatformAdminAiOpsAction` union. +- `front/features/platform-admin/api/platform-admin-api.ts` — add `retryCommitPlatformAdminAiJob`. +- `front/features/platform-admin/queries/platform-admin-ai-ops-queries.ts` — add `useRetryCommitPlatformAdminAiJobMutation`. +- `front/features/platform-admin/ui/platform-admin-ai-ops.tsx` — add `onRetryCommit` prop + button. +- `front/features/platform-admin/route/admin-ai-ops-route.tsx` — wire the mutation. + +**Frontend — modify tests:** +- `front/features/platform-admin/queries/platform-admin-ai-ops-queries.test.tsx` +- `front/features/platform-admin/ui/platform-admin-ai-ops.test.tsx` +- `front/tests/e2e/platform-admin-ai-ops.spec.ts` + +**Docs — modify:** +- `CHANGELOG.md` — Unreleased → Engineering entry. + +--- + +## Task 1: Add `RETRY_COMMIT` to the action enum + +**Files:** +- Modify: `server/src/main/kotlin/com/readmates/aigen/application/model/AiGenerationOpsModels.kt:56` + +- [ ] **Step 1: Extend the enum** + +Change line 56 from: + +```kotlin +enum class AiOpsAction { FORCE_CANCEL } +``` + +to: + +```kotlin +enum class AiOpsAction { FORCE_CANCEL, RETRY_COMMIT } +``` + +- [ ] **Step 2: Compile to confirm no breakage** + +Run: `./server/gradlew -p server compileKotlin` +Expected: BUILD SUCCESSFUL (the `when`/`if` over actions in `AiGenerationOpsService` does not exhaustively match on this enum, so no new compile error is expected). + +- [ ] **Step 3: Commit** + +```bash +git add server/src/main/kotlin/com/readmates/aigen/application/model/AiGenerationOpsModels.kt +git commit -m "feat(aigen): add RETRY_COMMIT to ai-ops action enum" +``` + +--- + +## Task 2: Add the `RetryAiOpsJobCommitUseCase` port + +**Files:** +- Modify: `server/src/main/kotlin/com/readmates/aigen/application/port/in/AiGenerationOpsUseCases.kt` + +- [ ] **Step 1: Add the interface** + +Append after the `ForceCancelAiOpsJobUseCase` interface (after line 38): + +```kotlin +interface RetryAiOpsJobCommitUseCase { + fun retryCommit( + admin: CurrentPlatformAdmin, + jobId: UUID, + ): AiOpsAdminActionResult +} +``` + +(`AiOpsAdminActionResult`, `CurrentPlatformAdmin`, and `UUID` are already imported in this file.) + +- [ ] **Step 2: Compile** + +Run: `./server/gradlew -p server compileKotlin` +Expected: BUILD SUCCESSFUL. + +- [ ] **Step 3: Commit** + +```bash +git add server/src/main/kotlin/com/readmates/aigen/application/port/in/AiGenerationOpsUseCases.kt +git commit -m "feat(aigen): add RetryAiOpsJobCommitUseCase port" +``` + +--- + +## Task 3: Service — `retryCommit` recovers a stuck COMMITTING job (failing test first) + +**Files:** +- Test: `server/src/test/kotlin/com/readmates/aigen/application/service/AiGenerationOpsServiceTest.kt` +- Modify: `server/src/main/kotlin/com/readmates/aigen/application/service/AiGenerationOpsService.kt` + +- [ ] **Step 1: Write the failing tests** + +In `AiGenerationOpsServiceTest.kt`, add these three tests after the existing `force cancel returns safe ops code...` test (after line 99). Note `AiGenerationTestFixtures.jobRecord(...)` is the fixture used elsewhere in this file; pass `status = JobStatus.COMMITTING`. + +```kotlin + @Test + fun `operator can retry-commit a stuck committing job back to succeeded`() { + val job = AiGenerationTestFixtures.jobRecord(status = JobStatus.COMMITTING, stage = JobStage.READY) + jobStore.save(job) + + val result = service.retryCommit(admin(PlatformAdminRole.OPERATOR), job.jobId) + + assertThat(result.previousStatus).isEqualTo(JobStatus.COMMITTING) + assertThat(result.nextStatus).isEqualTo(JobStatus.SUCCEEDED) + assertThat(jobStore.load(job.jobId)?.status).isEqualTo(JobStatus.SUCCEEDED) + assertThat(jobStore.transientPayloadDeleted).doesNotContain(job.jobId) + assertThat(actionAudit.entries.single().action).isEqualTo("RETRY_COMMIT") + assertThat(actionAudit.entries.single().previousStatus).isEqualTo("COMMITTING") + assertThat(actionAudit.entries.single().nextStatus).isEqualTo("SUCCEEDED") + } + + @Test + fun `support admin cannot retry-commit`() { + val job = AiGenerationTestFixtures.jobRecord(status = JobStatus.COMMITTING, stage = JobStage.READY) + jobStore.save(job) + + assertThatThrownBy { + service.retryCommit(admin(PlatformAdminRole.SUPPORT), job.jobId) + }.isInstanceOf(AccessDeniedException::class.java) + assertThat(jobStore.load(job.jobId)?.status).isEqualTo(JobStatus.COMMITTING) + assertThat(actionAudit.entries).isEmpty() + } + + @Test + fun `retry-commit rejects a job that is not committing`() { + val job = AiGenerationTestFixtures.jobRecord(status = JobStatus.RUNNING, stage = JobStage.TRANSCRIPT_LOADED) + jobStore.save(job) + + assertThatThrownBy { + service.retryCommit(admin(PlatformAdminRole.OPERATOR), job.jobId) + }.isInstanceOf(AiGenerationException.IllegalGenerationState::class.java) + assertThat(actionAudit.entries).isEmpty() + } +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `./server/gradlew -p server test --tests "com.readmates.aigen.application.service.AiGenerationOpsServiceTest"` +Expected: FAIL — compile error `unresolved reference: retryCommit` (the method does not exist yet). + +- [ ] **Step 3: Implement `retryCommit` in the service** + +In `AiGenerationOpsService.kt`: + +1. Add the import at the top (alongside the other `port.in` imports, around line 15-18): + +```kotlin +import com.readmates.aigen.application.port.`in`.RetryAiOpsJobCommitUseCase +``` + +2. Add the interface to the class declaration (line 41-44). Change: + +```kotlin +) : GetAiOpsSummaryUseCase, + ListAiOpsJobsUseCase, + GetAiOpsJobUseCase, + ForceCancelAiOpsJobUseCase { +``` + +to: + +```kotlin +) : GetAiOpsSummaryUseCase, + ListAiOpsJobsUseCase, + GetAiOpsJobUseCase, + ForceCancelAiOpsJobUseCase, + RetryAiOpsJobCommitUseCase { +``` + +3. Add the method immediately after `forceCancel` (after line 179, before `safeMissingLiveJob`): + +```kotlin + override fun retryCommit( + admin: CurrentPlatformAdmin, + jobId: UUID, + ): AiOpsAdminActionResult { + if (admin.role !in ACTION_ROLES) { + throw AccessDeniedException("Platform admin role ${admin.role} cannot retry AI generation commits") + } + val record = + jobStore.findJobById(jobId) + ?: throw safeMissingLiveJob(jobId) + if (record.status !in RETRY_COMMIT_STATUSES) { + throw AiGenerationException.IllegalGenerationState(jobId, record.status.name, "admin retry-commit") + } + val reset = + jobStore.transitionStatus( + jobId = jobId, + expected = RETRY_COMMIT_STATUSES, + next = JobStatus.SUCCEEDED, + stage = JobStage.READY, + progressPct = 100, + error = null, + ) + if (!reset) { + throw AiGenerationException.IllegalGenerationState( + jobId = jobId, + currentStatus = jobStore.load(jobId)?.status?.name ?: "MISSING", + attemptedAction = "admin retry-commit", + ) + } + // Intentionally NOT calling deleteTransientPayload: the host needs the + // result snapshot to survive so it can re-commit the recovered job. + adminActionAuditPort.record( + AiGenerationAdminActionAuditEntry( + jobId = jobId, + clubId = record.clubId, + sessionId = record.sessionId, + adminUserId = admin.userId, + adminRole = admin.role, + action = AiOpsAction.RETRY_COMMIT.name, + previousStatus = record.status.name, + nextStatus = JobStatus.SUCCEEDED.name, + result = "SUCCESS", + safeErrorCode = null, + createdAt = clock.instant(), + ), + ) + return AiOpsAdminActionResult(jobId, record.status, JobStatus.SUCCEEDED) + } +``` + +4. Add `JobStage` to imports if not present. Check the existing imports — `JobStatus` is imported (line 14) but `JobStage` may not be. Add alongside it: + +```kotlin +import com.readmates.aigen.application.model.JobStage +``` + +5. Add `RETRY_COMMIT_STATUSES` to the `companion object` (after line 218, alongside `FORCE_CANCEL_STATUSES`): + +```kotlin + val RETRY_COMMIT_STATUSES = setOf(JobStatus.COMMITTING) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `./server/gradlew -p server test --tests "com.readmates.aigen.application.service.AiGenerationOpsServiceTest"` +Expected: PASS (all tests, including the 5 pre-existing ones). + +- [ ] **Step 5: Commit** + +```bash +git add server/src/main/kotlin/com/readmates/aigen/application/service/AiGenerationOpsService.kt server/src/test/kotlin/com/readmates/aigen/application/service/AiGenerationOpsServiceTest.kt +git commit -m "feat(aigen): recover stuck committing job via admin retry-commit" +``` + +--- + +## Task 4: Service — expose `RETRY_COMMIT` in `availableActions` (failing test first) + +**Files:** +- Test: `server/src/test/kotlin/com/readmates/aigen/application/service/AiGenerationOpsServiceTest.kt` +- Modify: `server/src/main/kotlin/com/readmates/aigen/application/service/AiGenerationOpsService.kt:212` + +- [ ] **Step 1: Write the failing test** + +Add after the tests from Task 3: + +```kotlin + @Test + fun `committing job lists both force-cancel and retry-commit actions`() { + val job = AiGenerationTestFixtures.jobRecord(status = JobStatus.COMMITTING, stage = JobStage.READY) + jobStore.save(job) + + val item = service.list(admin(PlatformAdminRole.OWNER), AiOpsJobFilters(null, null, null, null)).items.single() + + assertThat(item.availableActions) + .containsExactlyInAnyOrder(AiOpsAction.FORCE_CANCEL, AiOpsAction.RETRY_COMMIT) + } + + @Test + fun `running job lists only force-cancel`() { + val job = AiGenerationTestFixtures.jobRecord(status = JobStatus.RUNNING, stage = JobStage.TRANSCRIPT_LOADED) + jobStore.save(job) + + val item = service.list(admin(PlatformAdminRole.OWNER), AiOpsJobFilters(null, null, null, null)).items.single() + + assertThat(item.availableActions).containsExactly(AiOpsAction.FORCE_CANCEL) + } +``` + +Add the import if missing (top of the test file alongside the other `application.model` imports): + +```kotlin +import com.readmates.aigen.application.model.AiOpsAction +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `./server/gradlew -p server test --tests "com.readmates.aigen.application.service.AiGenerationOpsServiceTest"` +Expected: FAIL — `committing job lists both...` fails because `availableActions` currently yields only `FORCE_CANCEL`. + +- [ ] **Step 3: Update `availableActions` computation** + +In `AiGenerationOpsService.kt`, replace line 212: + +```kotlin + availableActions = if (status in FORCE_CANCEL_STATUSES) setOf(AiOpsAction.FORCE_CANCEL) else emptySet(), +``` + +with: + +```kotlin + availableActions = + buildSet { + if (status in FORCE_CANCEL_STATUSES) add(AiOpsAction.FORCE_CANCEL) + if (status in RETRY_COMMIT_STATUSES) add(AiOpsAction.RETRY_COMMIT) + }, +``` + +- [ ] **Step 4: Run to verify pass** + +Run: `./server/gradlew -p server test --tests "com.readmates.aigen.application.service.AiGenerationOpsServiceTest"` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add server/src/main/kotlin/com/readmates/aigen/application/service/AiGenerationOpsService.kt server/src/test/kotlin/com/readmates/aigen/application/service/AiGenerationOpsServiceTest.kt +git commit -m "feat(aigen): expose retry-commit in ai-ops available actions" +``` + +--- + +## Task 5: Controller — `POST /jobs/{jobId}/retry-commit` (failing test first) + +**Files:** +- Test: `server/src/test/kotlin/com/readmates/aigen/adapter/in/web/AiGenerationOpsControllerTest.kt` +- Modify: `server/src/main/kotlin/com/readmates/aigen/adapter/in/web/AiGenerationOpsController.kt` + +- [ ] **Step 1: Write the failing test + fake** + +In `AiGenerationOpsControllerTest.kt`: + +1. Add a fake field next to `cancel` (after line 43): + +```kotlin + private val retry = FakeRetryCommitUseCase() +``` + +2. Pass it into the controller in `setUp()` (the `AiGenerationOpsController(...)` call, after `forceCancelUseCase = cancel,`): + +```kotlin + retryCommitUseCase = retry, +``` + +3. Add the import (alongside the other `port.in` imports near line 17): + +```kotlin +import com.readmates.aigen.application.port.`in`.RetryAiOpsJobCommitUseCase +``` + +4. Add the test after the `force cancel delegates to use case` test (after line 177): + +```kotlin + @Test + fun `retry commit delegates to use case`() { + retry.result = AiOpsAdminActionResult(sampleJobId, JobStatus.COMMITTING, JobStatus.SUCCEEDED) + + mockMvc + .post("/api/admin/ai-generation/jobs/$sampleJobId/retry-commit") + .andExpect { + status { isOk() } + jsonPath("$.jobId") { value(sampleJobId.toString()) } + jsonPath("$.previousStatus") { value("COMMITTING") } + jsonPath("$.nextStatus") { value("SUCCEEDED") } + } + + assertThat(retry.calls).containsExactly(admin to sampleJobId) + } +``` + +5. Add the fake class after `FakeForceCancelUseCase` (after line 255): + +```kotlin +private class FakeRetryCommitUseCase : RetryAiOpsJobCommitUseCase { + lateinit var result: AiOpsAdminActionResult + val calls = mutableListOf>() + + override fun retryCommit( + admin: CurrentPlatformAdmin, + jobId: UUID, + ): AiOpsAdminActionResult { + calls += admin to jobId + return result + } +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `./server/gradlew -p server test --tests "com.readmates.aigen.adapter.in.web.AiGenerationOpsControllerTest"` +Expected: FAIL — compile error: `AiGenerationOpsController` has no `retryCommitUseCase` parameter. + +- [ ] **Step 3: Implement the controller endpoint** + +In `AiGenerationOpsController.kt`: + +1. Add the import (alongside the other `port.in` imports, near line 6-9): + +```kotlin +import com.readmates.aigen.application.port.`in`.RetryAiOpsJobCommitUseCase +``` + +2. Add the constructor parameter (after `forceCancelUseCase`, line 27): + +```kotlin + private val retryCommitUseCase: RetryAiOpsJobCommitUseCase, +``` + +3. Add the endpoint after `forceCancel` (after line 66, before the closing brace): + +```kotlin + @PostMapping("/jobs/{jobId}/retry-commit") + fun retryCommit( + admin: CurrentPlatformAdmin, + @PathVariable jobId: UUID, + ): AiOpsAdminActionResponse = AiOpsAdminActionResponse.from(retryCommitUseCase.retryCommit(admin, jobId)) +``` + +- [ ] **Step 4: Run to verify pass** + +Run: `./server/gradlew -p server test --tests "com.readmates.aigen.adapter.in.web.AiGenerationOpsControllerTest"` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add server/src/main/kotlin/com/readmates/aigen/adapter/in/web/AiGenerationOpsController.kt server/src/test/kotlin/com/readmates/aigen/adapter/in/web/AiGenerationOpsControllerTest.kt +git commit -m "feat(aigen): add ai-ops retry-commit endpoint" +``` + +--- + +## Task 6: Server slice regression + +- [ ] **Step 1: Run aigen unit tests + architecture test** + +Run: `./server/gradlew -p server unitTest` +Expected: PASS. (No new package boundaries are crossed, so `architectureTest` is not strictly required, but run it if the slice touched the registry: `./server/gradlew -p server architectureTest`.) + +- [ ] **Step 2: No commit (verification only).** If anything fails, fix the offending task before proceeding to frontend. + +--- + +## Task 7: Frontend — extend the action union type + +**Files:** +- Modify: `front/features/platform-admin/model/platform-admin-domain-types.ts:145` + +- [ ] **Step 1: Extend the union** + +Change line 145 from: + +```typescript +export type PlatformAdminAiOpsAction = "FORCE_CANCEL"; +``` + +to: + +```typescript +export type PlatformAdminAiOpsAction = "FORCE_CANCEL" | "RETRY_COMMIT"; +``` + +- [ ] **Step 2: Typecheck** + +Run: `pnpm --dir front exec tsc -p tsconfig.json --noEmit` +Expected: PASS (no usages narrow on the old single-member type). + +- [ ] **Step 3: Commit** + +```bash +git add front/features/platform-admin/model/platform-admin-domain-types.ts +git commit -m "feat: add RETRY_COMMIT to admin ai-ops action type" +``` + +--- + +## Task 8: Frontend — API wrapper + +**Files:** +- Modify: `front/features/platform-admin/api/platform-admin-api.ts:131-137` + +- [ ] **Step 1: Add the wrapper** + +After `forceCancelPlatformAdminAiJob` (after line 137), add: + +```typescript +export function retryCommitPlatformAdminAiJob(jobId: string) { + return readmatesFetch( + `/api/admin/ai-generation/jobs/${encodeURIComponent(jobId)}/retry-commit`, + { method: "POST" }, + { clubSlug: undefined }, + ); +} +``` + +(`PlatformAdminAiOpsActionResponse` is already imported at line 5.) + +- [ ] **Step 2: Typecheck** + +Run: `pnpm --dir front exec tsc -p tsconfig.json --noEmit` +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add front/features/platform-admin/api/platform-admin-api.ts +git commit -m "feat: add admin ai-ops retry-commit api wrapper" +``` + +--- + +## Task 9: Frontend — TanStack mutation (failing test first) + +**Files:** +- Test: `front/features/platform-admin/queries/platform-admin-ai-ops-queries.test.tsx` +- Modify: `front/features/platform-admin/queries/platform-admin-ai-ops-queries.ts` + +- [ ] **Step 1: Write the failing test** + +In `platform-admin-ai-ops-queries.test.tsx`: + +1. Add `retryCommitPlatformAdminAiJob` to the `vi.mock` factory (line 10-14): + +```typescript +vi.mock("@/features/platform-admin/api/platform-admin-api", () => ({ + fetchPlatformAdminAiOpsJobs: vi.fn(), + fetchPlatformAdminAiOpsSummary: vi.fn(), + forceCancelPlatformAdminAiJob: vi.fn(), + retryCommitPlatformAdminAiJob: vi.fn(), +})); +``` + +2. Add it to the import block (line 16-20): + +```typescript +import { + fetchPlatformAdminAiOpsJobs, + fetchPlatformAdminAiOpsSummary, + forceCancelPlatformAdminAiJob, + retryCommitPlatformAdminAiJob, +} from "@/features/platform-admin/api/platform-admin-api"; +``` + +3. Add it to the queries import (line 21-26): + +```typescript +import { + platformAdminAiOpsJobsQuery, + platformAdminAiOpsKeys, + platformAdminAiOpsSummaryQuery, + useForceCancelPlatformAdminAiJobMutation, + useRetryCommitPlatformAdminAiJobMutation, +} from "./platform-admin-ai-ops-queries"; +``` + +4. Reset it in `beforeEach` (after line 74): + +```typescript + vi.mocked(retryCommitPlatformAdminAiJob).mockReset(); +``` + +5. Add the test inside the `"platform admin AI Ops mutation cache behavior"` describe block (after line 129): + +```typescript + it("invalidates summary and ledger queries after retry commit", async () => { + vi.mocked(retryCommitPlatformAdminAiJob).mockResolvedValue({ + jobId: "job-1", + previousStatus: "COMMITTING", + nextStatus: "SUCCEEDED", + }); + const { client, Wrapper } = createWrapper(); + const invalidateSpy = vi.spyOn(client, "invalidateQueries"); + const { result } = renderHook(() => useRetryCommitPlatformAdminAiJobMutation(), { wrapper: Wrapper }); + + await act(async () => { + await result.current.mutateAsync("job-1"); + }); + + expect(retryCommitPlatformAdminAiJob).toHaveBeenCalledWith("job-1"); + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: platformAdminAiOpsKeys.all }); + }); +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `pnpm --dir front test -- platform-admin-ai-ops-queries` +Expected: FAIL — `useRetryCommitPlatformAdminAiJobMutation` is not exported. + +- [ ] **Step 3: Implement the mutation** + +In `platform-admin-ai-ops-queries.ts`: + +1. Add to the api import (line 2-6): + +```typescript +import { + fetchPlatformAdminAiOpsJobs, + fetchPlatformAdminAiOpsSummary, + forceCancelPlatformAdminAiJob, + retryCommitPlatformAdminAiJob, +} from "@/features/platform-admin/api/platform-admin-api"; +``` + +2. Add the hook after `useForceCancelPlatformAdminAiJobMutation` (after line 45): + +```typescript +export function useRetryCommitPlatformAdminAiJobMutation() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (jobId: string) => retryCommitPlatformAdminAiJob(jobId), + onSuccess: () => queryClient.invalidateQueries({ queryKey: platformAdminAiOpsKeys.all }), + }); +} +``` + +- [ ] **Step 4: Run to verify pass** + +Run: `pnpm --dir front test -- platform-admin-ai-ops-queries` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add front/features/platform-admin/queries/platform-admin-ai-ops-queries.ts front/features/platform-admin/queries/platform-admin-ai-ops-queries.test.tsx +git commit -m "feat: add admin ai-ops retry-commit mutation" +``` + +--- + +## Task 10: Frontend — UI button (failing test first) + +**Files:** +- Test: `front/features/platform-admin/ui/platform-admin-ai-ops.test.tsx` +- Modify: `front/features/platform-admin/ui/platform-admin-ai-ops.tsx` + +- [ ] **Step 1: Write the failing tests** + +In `platform-admin-ai-ops.test.tsx`, add a committing-job fixture after `runningJob` (after line 44): + +```typescript +const committingJob: PlatformAdminAiOpsJobView = { + ...runningJob, + jobId: "job-2", + status: "COMMITTING", + stage: "READY", + availableActions: ["FORCE_CANCEL", "RETRY_COMMIT"], +}; +``` + +Add these tests inside the `describe("PlatformAdminAiOps", ...)` block (after line 70): + +```typescript + it("lets owner and operator roles retry-commit a committing job", async () => { + const onRetryCommit = vi.fn(); + const user = userEvent.setup(); + + render( + , + ); + + await user.click(screen.getByRole("button", { name: "Retry commit" })); + + expect(onRetryCommit).toHaveBeenCalledWith("job-2"); + }); + + it("hides retry-commit from support role", () => { + render(); + + expect(screen.queryByRole("button", { name: "Retry commit" })).not.toBeInTheDocument(); + }); + + it("does not show retry-commit when the job does not offer it", () => { + render(); + + expect(screen.queryByRole("button", { name: "Retry commit" })).not.toBeInTheDocument(); + }); +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `pnpm --dir front test -- platform-admin-ai-ops.test` +Expected: FAIL — `onRetryCommit` is not a prop and the "Retry commit" button is not rendered. + +- [ ] **Step 3: Add the prop and button** + +In `platform-admin-ai-ops.tsx`: + +1. Add `onRetryCommit` to `PlatformAdminAiOpsProps` (after line 45, the `onForceCancel` line): + +```typescript + onRetryCommit?: (jobId: string) => void; +``` + +2. Add it to the destructured params (after line 59, `onForceCancel,`): + +```typescript + onRetryCommit, +``` + +3. Replace the force-cancel button block (lines 166-170) with both buttons: + +```tsx + {canAct ? ( +
+ {job.availableActions.includes("FORCE_CANCEL") ? ( + + ) : null} + {job.availableActions.includes("RETRY_COMMIT") ? ( + + ) : null} +
+ ) : null} +``` + +- [ ] **Step 4: Run to verify pass** + +Run: `pnpm --dir front test -- platform-admin-ai-ops.test` +Expected: PASS (including the pre-existing force-cancel tests — the `Force cancel` button name is unchanged). + +- [ ] **Step 5: Commit** + +```bash +git add front/features/platform-admin/ui/platform-admin-ai-ops.tsx front/features/platform-admin/ui/platform-admin-ai-ops.test.tsx +git commit -m "feat: render admin ai-ops retry-commit action button" +``` + +--- + +## Task 11: Frontend — wire the mutation in the route + +**Files:** +- Modify: `front/features/platform-admin/route/admin-ai-ops-route.tsx` + +- [ ] **Step 1: Import the new hook** + +In the queries import block (line 5-9), add `useRetryCommitPlatformAdminAiJobMutation`: + +```typescript +import { + platformAdminAiOpsJobsQuery, + platformAdminAiOpsSummaryQuery, + useForceCancelPlatformAdminAiJobMutation, + useRetryCommitPlatformAdminAiJobMutation, +} from "@/features/platform-admin/queries/platform-admin-ai-ops-queries"; +``` + +- [ ] **Step 2: Instantiate and pass it** + +After line 26 (`const forceCancel = ...`), add: + +```typescript + const retryCommit = useRetryCommitPlatformAdminAiJobMutation(); +``` + +In the `` JSX, after `onForceCancel={(jobId) => forceCancel.mutate(jobId)}` (line 51), add: + +```tsx + onRetryCommit={(jobId) => retryCommit.mutate(jobId)} +``` + +- [ ] **Step 3: Typecheck + route test** + +Run: `pnpm --dir front exec tsc -p tsconfig.json --noEmit && pnpm --dir front test -- admin-ai-ops-route` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add front/features/platform-admin/route/admin-ai-ops-route.tsx +git commit -m "feat: wire admin ai-ops retry-commit mutation in route" +``` + +--- + +## Task 12: E2E — owner sees a retry-commit affordance on a committing job + +**Files:** +- Modify: `front/tests/e2e/platform-admin-ai-ops.spec.ts` + +- [ ] **Step 1: Add a committing job to the jobs mock and a new test** + +In the `**/api/bff/api/admin/ai-generation/jobs` route handler, add a second item to the `items` array (after the existing `job-1` object, inside the array at line 104): + +```typescript + { + jobId: "job-2", + club: { clubId: "club-1", slug: "reading-sai", name: "읽는사이" }, + session: { sessionId: "session-2", number: 8, bookTitle: "Stuck Book" }, + status: "COMMITTING", + stage: "READY", + provider: "OPENAI", + model: "gpt-model", + errorCode: null, + safeErrorMessage: null, + costEstimateUsd: "0.1500", + createdAt: "2026-05-18T00:00:00Z", + lastUpdatedAt: "2026-05-18T00:02:00Z", + expiresAt: "2026-05-18T06:00:00Z", + staleCandidate: true, + availableActions: ["FORCE_CANCEL", "RETRY_COMMIT"], + }, +``` + +Add this test at the end of the file (after line 146): + +```typescript +test("platform owner sees retry-commit affordance on a committing job", async ({ page }) => { + await routePlatformAdminShell(page, "OWNER"); + + await page.goto("/admin/ai-ops"); + + await expect(page.getByText("Stuck Book")).toBeVisible(); + await expect(page.getByRole("button", { name: "Retry commit" })).toBeVisible(); +}); + +test("platform support cannot retry-commit", async ({ page }) => { + await routePlatformAdminShell(page, "SUPPORT"); + + await page.goto("/admin/ai-ops"); + + await expect(page.getByText("Stuck Book")).toBeVisible(); + await expect(page.getByRole("button", { name: "Retry commit" })).toHaveCount(0); +}); +``` + +- [ ] **Step 2: Run the e2e spec** + +Run: `pnpm --dir front test:e2e -- platform-admin-ai-ops` +Expected: PASS. (The pre-existing `force cancel` and `cost window` tests must still pass — adding a second job does not change their assertions, which target `Book`/`Force cancel`.) + +- [ ] **Step 3: Commit** + +```bash +git add front/tests/e2e/platform-admin-ai-ops.spec.ts +git commit -m "test: cover admin ai-ops retry-commit affordance e2e" +``` + +--- + +## Task 13: CHANGELOG entry + +**Files:** +- Modify: `CHANGELOG.md` (Unreleased → `### Engineering`) + +- [ ] **Step 1: Add the entry** + +Add a new bullet at the top of the `### Engineering` list under `## Unreleased` (describe shipped behavior, not plan language — per closeout roadmap §11): + +```markdown +- **platform-admin:** `/admin/ai-ops` now offers an OWNER/OPERATOR `Retry commit` action on jobs stuck in `COMMITTING`. It recovers the job to `SUCCEEDED` (reusing the commit service's existing recovery transition) so the host can re-commit, without admin writing any session content and without deleting the result snapshot. The action is audit-logged (`RETRY_COMMIT`, COMMITTING→SUCCEEDED) and SUPPORT is denied. No new generation state or transition semantics were introduced. +``` + +- [ ] **Step 2: Sanity-check the changelog renders** + +Run: `git diff --check -- CHANGELOG.md` +Expected: no whitespace errors. + +- [ ] **Step 3: Commit** + +```bash +git add CHANGELOG.md +git commit -m "docs: record admin ai-ops retry-commit in changelog" +``` + +--- + +## Task 14: Full slice regression + +- [ ] **Step 1: Server** + +Run: `./server/gradlew -p server unitTest` +Expected: PASS. + +- [ ] **Step 2: Frontend lint + unit + build** + +Run: `pnpm --dir front lint && pnpm --dir front test && pnpm --dir front build` +Expected: PASS. + +- [ ] **Step 3: E2E (auth/BFF/admin-route surface touched)** + +Run: `pnpm --dir front test:e2e -- platform-admin-ai-ops` +Expected: PASS. + +- [ ] **Step 4: Browser smoke QA (manual)** + +Start the dev stack, log in as the platform OWNER dev fixture, open `/admin/ai-ops`. Confirm: a `COMMITTING` job shows both `Force cancel` and `Retry commit`; clicking `Retry commit` flips the job to `SUCCEEDED` and the list refetches; SUPPORT sees neither button. If the environment cannot produce a `COMMITTING` job, report that the manual step was skipped and why (the e2e mock covers the affordance). + +- [ ] **Step 5: Public-safety scan of changed files** + +Confirm no raw provider error, transcript, generated result JSON, member email, secret, OCID, or local path was added to any changed file (UI copy, fixtures, e2e mocks, CHANGELOG). + +--- + +## Cross-cutting hardening checklist (spec §7, touched surface only) + +- [ ] **Consistency:** `Retry commit` button uses the same `btn btn-quiet btn-sm` tone as `Force cancel`; the two share a `platform-admin-ai-ops__job-actions` container. +- [ ] **Accessibility:** both buttons are real ` + ))} +
+ + + {error ?

{error}

: null} + {loading && !overview ?

분석 데이터를 불러오는 중…

: null} + + {overview ? ( + <> +
    + {overview.kpis.map((card) => ( + + ))} +
+ + + ) : null} + + ); +} + +function AdminAnalyticsKpiTile({ card }: { card: AdminAnalyticsKpiCard }) { + const unavailable = card.availability === "NOT_ENOUGH_DATA"; + return ( +
  • + {labelKpi(card.key)} + {formatKpiValue(card)} + {deltaLabel(card)} +
  • + ); +} + +function AdminAnalyticsBenchmarkTable({ + benchmark, +}: { + benchmark: AdminAnalyticsOverview["clubBenchmark"]; +}) { + if (benchmark.availability === "NOT_ENOUGH_DATA" || benchmark.rows.length === 0) { + return

    클럽 비교에 충분한 데이터가 없습니다.

    ; + } + return ( + + + + + + + + + + + + + {benchmark.rows.map((row) => ( + + ))} + +
    클럽활성 멤버세션 완료율RSVP 응답률AI 비용알림 도달률
    + ); +} + +function AdminAnalyticsBenchmarkRowView({ row }: { row: AdminAnalyticsBenchmarkRow }) { + return ( + + {row.name} + {row.activeMembers} + {percentOrDash(row.sessionCompletionRate)} + {percentOrDash(row.rsvpRate)} + ${row.aiCostUsd} + {percentOrDash(row.notificationDeliveryRate)} + + ); +} + +function percentOrDash(value: number | null): string { + return value === null ? "—" : `${value}%`; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm --dir front test admin-analytics-overview` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add front/features/platform-admin/ui/admin-analytics-overview.tsx \ + front/features/platform-admin/ui/admin-analytics-overview.test.tsx +git commit -m "feat: render admin analytics KPI cards and club benchmark" +``` + +### Task 12: Analytics route component + +**Files:** +- Create: `front/features/platform-admin/route/admin-analytics-route.tsx` +- Test: `front/features/platform-admin/route/admin-analytics-route.test.tsx` + +- [ ] **Step 1: Write the failing route test** + +```tsx +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { describe, expect, it } from "vitest"; +import { platformAdminAnalyticsOverviewQuery } from "@/features/platform-admin/queries/platform-admin-analytics-queries"; +import { AdminAnalyticsRoute } from "./admin-analytics-route"; + +function renderRoute(initialEntry = "/admin/analytics?window=7d") { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }); + queryClient.setQueryData(platformAdminAnalyticsOverviewQuery("7d").queryKey, { + schema: "admin.analytics_overview.v1", + generatedAt: "2026-05-30T00:00:00Z", + window: "7d", + kpis: [ + { key: "SESSION_COMPLETION", unit: "PERCENT", availability: "AVAILABLE", current: 75, prior: 60, deltaDirection: "UP" }, + ], + clubBenchmark: { availability: "NOT_ENOUGH_DATA", rows: [] }, + }); + + return render( + + + + + , + ); +} + +describe("AdminAnalyticsRoute", () => { + it("renders the cached analytics overview from the URL window", () => { + renderRoute(); + expect(screen.getByRole("heading", { name: "분석" })).toBeInTheDocument(); + expect(screen.getByText("75%")).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --dir front test admin-analytics-route` +Expected: FAIL — module not found. + +- [ ] **Step 3: Write the route component** + +```tsx +import { useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useSearchParams } from "react-router-dom"; +import { + analyticsSearchFromWindow, + analyticsWindowFromSearchParams, + type AnalyticsWindow, +} from "@/features/platform-admin/model/platform-admin-analytics-model"; +import { platformAdminAnalyticsOverviewQuery } from "@/features/platform-admin/queries/platform-admin-analytics-queries"; +import { AdminAnalyticsOverviewView } from "@/features/platform-admin/ui/admin-analytics-overview"; + +const GENERIC_ERROR = "분석 데이터를 처리하지 못했습니다. 다시 시도해 주세요."; + +export function AdminAnalyticsRoute() { + const [searchParams, setSearchParams] = useSearchParams(); + const window = useMemo(() => analyticsWindowFromSearchParams(searchParams), [searchParams]); + const query = useQuery(platformAdminAnalyticsOverviewQuery(window)); + + function changeWindow(next: AnalyticsWindow) { + setSearchParams(analyticsSearchFromWindow(next)); + } + + return ( + + ); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm --dir front test admin-analytics-route` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add front/features/platform-admin/route/admin-analytics-route.tsx \ + front/features/platform-admin/route/admin-analytics-route.test.tsx +git commit -m "feat: wire admin analytics route to window URL state" +``` + +--- + +### Task 13: Wire analytics route into catalog + router + styles + +Flip the `analytics` catalog entry from `coming_soon` to `ready`, wire the +lazy route into `admin.tsx`, and add the presentation styles the view uses. + +**Files:** +- Modify: `front/features/platform-admin/model/admin-route-catalog.ts:90-109` +- Modify: `front/features/platform-admin/model/admin-route-catalog.test.ts:37-64` +- Modify: `front/src/app/routes/admin.tsx:134-148` +- Modify: `front/src/styles/globals.css` (append `admin-analytics-*` block) + +- [ ] **Step 1: Update the catalog test to expect analytics ready** + +In `front/features/platform-admin/model/admin-route-catalog.test.ts`, change the +"requires no comingSoon block when status is ready" expectation to include +`"analytics"` in sorted position: + +```ts + it("requires no comingSoon block when status is ready", () => { + const ready = ADMIN_ROUTES.filter((route) => route.status === "ready"); + expect(ready.map((route) => route.path).sort()).toEqual([ + "ai-ops", + "analytics", + "audit", + "clubs", + "health", + "notifications", + "support", + "today", + ]); + }); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --dir front test admin-route-catalog` +Expected: FAIL — `analytics` is still `coming_soon`, so it is absent from the ready list. + +- [ ] **Step 3: Flip the catalog entry to ready** + +In `front/features/platform-admin/model/admin-route-catalog.ts`, replace the +`analytics` descriptor (lines 90-109) with the ready form (drop the `comingSoon` block): + +```ts + { + path: "analytics", + label: "분석", + group: "review", + groupLabel: "감사/분석", + slice: "S8", + status: "ready", + requiredCapability: "view_analytics", + }, +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm --dir front test admin-route-catalog` +Expected: PASS. + +- [ ] **Step 5: Wire the ready route in the router** + +In `front/src/app/routes/admin.tsx`, add a `case "analytics"` to the `readyChild` +switch, immediately after the `case "audit"` block (before `default:` at line 146): + +```tsx + case "analytics": + return { + path: "analytics", + hydrateFallbackElement: adminChildHydrateFallback, + lazy: async () => { + const [{ AdminAnalyticsRoute }, { adminAnalyticsLoaderFactory }] = await Promise.all([ + import("@/features/platform-admin/route/admin-analytics-route"), + import("@/features/platform-admin/route/admin-analytics-data"), + ]); + return { Component: AdminAnalyticsRoute, loader: adminAnalyticsLoaderFactory(queryClient) }; + }, + }; +``` + +- [ ] **Step 6: Add the presentation styles** + +Append to `front/src/styles/globals.css` (after the existing `.admin-audit*` block): + +```css +.admin-analytics { + display: grid; + gap: 24px; + max-width: 1160px; +} + +.admin-analytics__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; +} + +.admin-analytics__windows { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.admin-analytics__window { + border: 1px solid var(--line); + border-radius: var(--r-2); + padding: 6px 14px; + background: transparent; + color: inherit; + cursor: pointer; +} + +.admin-analytics__window[aria-pressed="true"] { + border-color: var(--accent); + background: var(--bg-sub); +} + +.admin-analytics__kpis { + list-style: none; + margin: 0; + padding: 0; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 16px; +} + +.admin-analytics__kpi { + border: 1px solid var(--line); + border-radius: var(--r-3); + background: var(--bg); + padding: 16px; + display: grid; + gap: 6px; +} + +.admin-analytics__kpi--empty { + background: var(--bg-sub); +} + +.admin-analytics__kpi-label { + color: var(--muted); + font-size: 13px; +} + +.admin-analytics__kpi-value { + font-size: 24px; + font-weight: 600; +} + +.admin-analytics__kpi-delta { + font-size: 13px; + color: var(--muted); +} + +.admin-analytics__benchmark { + width: 100%; + border-collapse: collapse; + border: 1px solid var(--line); + border-radius: var(--r-3); + background: var(--bg); + overflow: hidden; +} + +.admin-analytics__benchmark th, +.admin-analytics__benchmark td { + padding: 12px 16px; + border-bottom: 1px solid var(--line); + text-align: left; +} + +.admin-analytics__benchmark-empty, +.admin-analytics__error, +.admin-analytics__loading { + border: 1px solid var(--line); + border-radius: var(--r-2); + margin: 0; + padding: 14px 16px; + background: var(--bg-sub); +} + +.admin-analytics__error { + border-color: var(--danger); + color: var(--danger); + background: transparent; +} + +@media (max-width: 720px) { + .admin-analytics__benchmark { + display: block; + overflow-x: auto; + } +} +``` + +- [ ] **Step 7: Run the frontend unit + build checks** + +Run: `pnpm --dir front test admin-route-catalog admin-analytics` +Expected: PASS. + +Run: `pnpm --dir front build` +Expected: build succeeds (analytics chunk emitted, no unresolved import). + +- [ ] **Step 8: Commit** + +```bash +git add front/features/platform-admin/model/admin-route-catalog.ts \ + front/features/platform-admin/model/admin-route-catalog.test.ts \ + front/src/app/routes/admin.tsx \ + front/src/styles/globals.css +git commit -m "feat: flip admin analytics route to ready and wire router" +``` + +--- +### Task 14: End-to-end analytics route + +Mirror `front/tests/e2e/admin-audit.spec.ts`: stub the platform-admin shell +(auth/me, summary, clubs) plus the analytics overview endpoint, then assert the +route renders KPI values, switches windows via URL, and leaks no private fields. + +**Files:** +- Create: `front/tests/e2e/admin-analytics.spec.ts` + +- [ ] **Step 1: Write the e2e spec** + +```ts +import { expect, test, type Page, type Route } from "@playwright/test"; +import type { PlatformAdminRole } from "@/features/platform-admin/api/platform-admin-contracts"; +import type { AuthMeResponse } from "@/shared/auth/auth-contracts"; + +function platformAdminAuth(role: PlatformAdminRole): AuthMeResponse { + const email = `${role.toLowerCase()}@example.com`; + return { + authenticated: true, + userId: `platform-${role.toLowerCase()}-user`, + membershipId: null, + clubId: null, + email, + displayName: `${role} admin`, + accountName: `${role} admin`, + role: null, + membershipStatus: null, + approvalState: "INACTIVE", + currentMembership: null, + joinedClubs: [], + platformAdmin: { userId: `platform-${role.toLowerCase()}-user`, email, role }, + recommendedAppEntryUrl: "/admin", + }; +} + +async function json(route: Route, status: number, body: unknown): Promise { + await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) }); +} + +async function routePlatformAdminShell(page: Page, role: PlatformAdminRole): Promise { + await page.route("**/api/bff/api/auth/me**", async (route) => { + await json(route, 200, platformAdminAuth(role)); + }); + await page.route("**/api/bff/api/admin/summary", async (route) => { + await json(route, 200, { + platformRole: role, + activeClubCount: 1, + domainActionRequiredCount: 0, + domains: [], + domainsRequiringAction: [], + }); + }); + await page.route("**/api/bff/api/admin/clubs", async (route) => { + await json(route, 200, { items: [] }); + }); +} + +function overview(windowValue: "7d" | "30d" | "90d") { + return { + schema: "admin.analytics_overview.v1", + generatedAt: "2026-05-30T00:00:00Z", + window: windowValue, + kpis: [ + { key: "SESSION_COMPLETION", unit: "PERCENT", availability: "AVAILABLE", current: windowValue === "7d" ? 70 : 80, prior: 50, deltaDirection: "UP" }, + { key: "RSVP_RATE", unit: "PERCENT", availability: "NOT_ENOUGH_DATA", current: null, prior: null, deltaDirection: "NONE" }, + { key: "ACTIVE_MEMBERS", unit: "COUNT", availability: "AVAILABLE", current: 12, prior: 9, deltaDirection: "UP" }, + { key: "AI_COST_PER_SESSION", unit: "USD", availability: "AVAILABLE", current: 1.5, prior: 1.2, deltaDirection: "UP" }, + { key: "NOTIFICATION_DELIVERY", unit: "PERCENT", availability: "AVAILABLE", current: 95, prior: 95, deltaDirection: "FLAT" }, + ], + clubBenchmark: { availability: "NOT_ENOUGH_DATA", rows: [] }, + }; +} + +async function routeAnalytics(page: Page): Promise { + await page.route("**/api/bff/api/admin/analytics/overview**", async (route) => { + const url = new URL(route.request().url()); + const windowParam = (url.searchParams.get("window") ?? "30d") as "7d" | "30d" | "90d"; + await json(route, 200, overview(windowParam)); + }); +} + +test("owner reviews admin analytics overview and switches window", async ({ page }) => { + await routePlatformAdminShell(page, "OWNER"); + await routeAnalytics(page); + + await page.goto("/admin/analytics"); + + await expect(page.getByRole("heading", { name: "분석" })).toBeVisible(); + await expect(page.getByText("80%")).toBeVisible(); + await expect(page.getByText("클럽 비교에 충분한 데이터가 없습니다.")).toBeVisible(); + + await page.getByRole("button", { name: "최근 7일" }).click(); + + await expect(page).toHaveURL(/window=7d/); + await expect(page.getByText("70%")).toBeVisible(); + + await expect(page.getByText("member1@example.com")).toHaveCount(0); + await expect(page.getByText("{\"")).toHaveCount(0); +}); +``` + +- [ ] **Step 2: Run the e2e spec** + +Run: `pnpm --dir front test:e2e admin-analytics` +Expected: PASS — heading, KPI values, benchmark empty state, window switch, and no private fields. + +- [ ] **Step 3: Commit** + +```bash +git add front/tests/e2e/admin-analytics.spec.ts +git commit -m "test: cover admin analytics overview e2e flow" +``` + +--- +### Task 15: CHANGELOG + docs + +Record the new analytics surface in `CHANGELOG.md` (Unreleased) so the release-tag +guard sees a concrete entry, and note in the closeout roadmap that S8 is the +first delivered slice. + +**Files:** +- Modify: `CHANGELOG.md` (Unreleased → Highlights + Engineering) +- Modify: `docs/superpowers/specs/2026-05-30-readmates-admin-vnext-closeout-roadmap-design.md` (Slice Order: mark S8 delivered) + +- [ ] **Step 1: Add the Highlights entry** + +In `CHANGELOG.md`, under `## Unreleased` → `### Highlights`, append after the +last `/admin/clubs/:clubId` bullet: + +```markdown +- **Admin vNext S8 분석/리포팅 lite**: `/admin/analytics`를 마지막 COMING-SOON 라우트에서 READY로 전환했습니다. 7/30/90일 윈도우 선택(URL state)으로 활성 멤버·세션 완료율·RSVP 응답률·AI 비용/세션·알림 도달률을 현재-대비-직전 윈도우 델타로 보여주고, 클럽 간 비교(cross-club benchmark)를 제공합니다. 분모가 0인 지표는 차트를 지어내지 않고 "데이터 부족" empty state로 정직하게 표기합니다. 새 read-only 서버 슬라이스 `admin.analytics`(controller → service → JDBC adapter)가 클럽 전반의 원시 카운트를 집계하고, 비율·델타·가용성 파생은 순수 application service에서 단위 테스트로 검증합니다. +``` + +- [ ] **Step 2: Add the Engineering entry** + +In `CHANGELOG.md`, under `## Unreleased` → `### Engineering`, append: + +```markdown +- **platform-admin:** add the read-only `admin.analytics` slice and `/admin/analytics` overview. + `GET /api/admin/analytics/overview?window=7d|30d|90d` aggregates raw counts across clubs over + current/prior windows; the application service derives rate/delta/availability (pure, unit-tested) + while the JDBC adapter returns only counts. Metric contract pinned as `admin.analytics_overview.v1`: + ACTIVE_MEMBERS, SESSION_COMPLETION, RSVP_RATE, AI_COST_PER_SESSION, NOTIFICATION_DELIVERY, with + `NOT_ENOUGH_DATA` when a denominator is 0 and numeric `deltaDirection` (UP/DOWN/FLAT/NONE) leaving + good/bad coloring to the UI. No charting library added — trend is current-vs-prior delta. Registered + in `ServerArchitectureBoundaryTest` and covered by service/adapter/controller and e2e tests asserting + no `@example.com` or raw JSON bodies leak. +``` + +- [ ] **Step 3: Mark S8 delivered in the closeout roadmap** + +In `docs/superpowers/specs/2026-05-30-readmates-admin-vnext-closeout-roadmap-design.md`, in the +Slice Order section, annotate the S8 row/heading to reflect that it is the first delivered slice +(e.g., append `— delivered 2026-05-30 (`admin.analytics` slice, plan `2026-05-30-admin-s8-analytics-reporting-lite.md`)` to the S8 entry). Leave S6 and S9 unchanged as `pending`. + +- [ ] **Step 4: Verify the release guard accepts the entries** + +Run: `git diff --check -- CHANGELOG.md docs/superpowers/specs/2026-05-30-readmates-admin-vnext-closeout-roadmap-design.md` +Expected: no whitespace errors. + +Run: `READMATES_PRE_PUSH_RELEASE=true ./scripts/pre-push-check.sh` (or the documented `--release` form) +Expected: CHANGELOG Unreleased guard passes (concrete category headers, feature-style bold markers, no placeholder-only bullets). + +- [ ] **Step 5: Commit** + +```bash +git add CHANGELOG.md docs/superpowers/specs/2026-05-30-readmates-admin-vnext-closeout-roadmap-design.md +git commit -m "docs: record admin analytics slice in changelog and roadmap" +``` + +--- + +## Final verification (run before opening the PR) + +After all tasks, run the full gate set named in `front/AGENTS.md` and the server guide: + +```bash +pnpm --dir front lint +pnpm --dir front test +pnpm --dir front build +pnpm --dir front test:e2e +./server/gradlew -p server clean test +``` + +Expected: all pass. If any check is skipped, report the exact command and reason in the PR description. + +Release-readiness (per `AGENTS.md`): confirm the CHANGELOG Unreleased entry, the +ArchUnit slice registration, and the public-release safety scan (`@example.com` / +raw JSON absent from serialized bodies) before merge. diff --git a/docs/superpowers/plans/2026-05-30-readmates-admin-club-detail-trend-completion.md b/docs/superpowers/plans/2026-05-30-readmates-admin-club-detail-trend-completion.md new file mode 100644 index 00000000..e5ac45f2 --- /dev/null +++ b/docs/superpowers/plans/2026-05-30-readmates-admin-club-detail-trend-completion.md @@ -0,0 +1,775 @@ +# Admin Club Detail Trend Completion Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Close the S3+ slice by giving `/admin/clubs/:clubId` a 7-day failure trend (delta), next-action links on every red signal, and a platform-owned vs host-owned UI split — reusing the same 7-day window the club list already ships. + +**Architecture:** Additive fields on the existing `AdminClubOperationsSnapshot` model (schema literal stays `admin.club_operations_snapshot.v1`). The JDBC adapter gains windowed scalar aggregations reusing existing indexes. The frontend adds pure delta/route-mapping helpers in the model module and renders them in the operations page. No controller change (it serializes the model via `Any`). + +**Tech Stack:** Kotlin + Spring JDBC (server), Vite + React + TanStack Query + Vitest + Playwright (front), MySQL (Testcontainers integration). + +**Spec:** `docs/superpowers/specs/2026-05-30-readmates-admin-club-detail-trend-completion-design.md` + +--- + +## File Structure + +Server: +- Modify `server/src/main/kotlin/com/readmates/club/application/model/AdminClubOperationsModels.kt` — add 3 fields (defaults `0`). +- Modify `server/src/main/kotlin/com/readmates/club/adapter/out/persistence/JdbcAdminClubOperationsAdapter.kt` — windowed aggregations. +- Create `server/src/test/kotlin/com/readmates/club/adapter/out/persistence/JdbcAdminClubOperationsTrendTest.kt` — integration test. + +Frontend: +- Modify `front/features/platform-admin/model/platform-admin-club-operations-model.ts` — type fields + pure helpers. +- Create `front/features/platform-admin/model/platform-admin-club-operations-model.test.ts` — helper unit tests. +- Modify `front/features/platform-admin/ui/admin-club-operations-page.tsx` — metric swap, delta, blocker links, section split. +- Modify `front/features/platform-admin/ui/admin-club-operations-page.test.tsx` — UI assertions. +- Modify `front/features/platform-admin/route/admin-club-detail-route.test.tsx` — snapshot fixture fields. +- Modify `front/tests/e2e/admin-club-operations.spec.ts` — mock fields + trend assertion. + +Docs: +- Modify `CHANGELOG.md` — `Unreleased` entry. + +--- + +## Task 1: Server model — additive trend fields + +**Files:** +- Modify: `server/src/main/kotlin/com/readmates/club/application/model/AdminClubOperationsModels.kt:47-66` + +- [ ] **Step 1: Add fields to the two data classes** + +In `AdminClubNotificationHealth`, add two trailing fields with defaults so existing positional constructors (service test) keep compiling: + +```kotlin +data class AdminClubNotificationHealth( + val pending: Int, + val failed: Int, + val dead: Int, + val lastSuccessAt: OffsetDateTime?, + val failureClusters: List, + val recentFailed7d: Int = 0, + val priorFailed7d: Int = 0, +) +``` + +In `AdminClubAiUsage`, add one trailing field with default: + +```kotlin +data class AdminClubAiUsage( + val activeJobs: Int, + val failedRecentJobs: Int, + val staleCandidates: Int, + val costEstimateUsd: String, + val state: String, + val priorFailedJobs7d: Int = 0, +) +``` + +- [ ] **Step 2: Compile to verify no breakage** + +Run: `./server/gradlew -p server compileKotlin compileTestKotlin -q` +Expected: BUILD SUCCESSFUL (defaults keep `AdminClubOperationsServiceTest` positional constructors valid). + +- [ ] **Step 3: Commit** + +```bash +git add server/src/main/kotlin/com/readmates/club/application/model/AdminClubOperationsModels.kt +git commit -m "feat: add club operations trend fields to snapshot model" +``` + +--- + +## Task 2: Server adapter — windowed aggregation + integration test + +**Files:** +- Create: `server/src/test/kotlin/com/readmates/club/adapter/out/persistence/JdbcAdminClubOperationsTrendTest.kt` +- Modify: `server/src/main/kotlin/com/readmates/club/adapter/out/persistence/JdbcAdminClubOperationsAdapter.kt:149-248` + +- [ ] **Step 1: Write the failing integration test** + +Mirror the seeding pattern from `JdbcPlatformAdminClubFailureCountsTest.kt`. Create the file: + +```kotlin +package com.readmates.club.adapter.out.persistence + +import com.readmates.support.ReadmatesMySqlIntegrationTestSupport +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Tag +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.test.context.jdbc.Sql +import java.time.Clock +import java.util.UUID + +private const val CLUB_ID = "00000000-0000-0000-0000-0000000fd001" +private const val USER_ID = "00000000-0000-0000-0000-0000000fd002" +private const val MEMBERSHIP_ID = "00000000-0000-0000-0000-0000000fd003" +private const val EVENT_ID = "00000000-0000-0000-0000-0000000fd101" + +private const val CLEANUP_SQL = """ + delete from ai_generation_audit_log where club_id = '$CLUB_ID'; + delete from notification_deliveries where club_id = '$CLUB_ID'; + delete from notification_event_outbox where club_id = '$CLUB_ID'; + delete from memberships where id = '$MEMBERSHIP_ID'; + delete from users where id = '$USER_ID'; + delete from clubs where id = '$CLUB_ID'; +""" + +@SpringBootTest(properties = ["spring.flyway.locations=classpath:db/mysql/migration,classpath:db/mysql/dev"]) +@Sql(statements = [CLEANUP_SQL], executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) +@Sql(statements = [CLEANUP_SQL], executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD) +@Tag("integration") +class JdbcAdminClubOperationsTrendTest( + @param:Autowired private val jdbcTemplate: JdbcTemplate, +) : ReadmatesMySqlIntegrationTestSupport() { + private val adapter by lazy { JdbcAdminClubOperationsAdapter(jdbcTemplate, Clock.systemUTC()) } + + @Test + fun `windows recent and prior failure counts for notifications and ai`() { + seedClubWithMember() + // notifications: recent 7d window (DEAD@1d, FAILED@3d) = 2; prior window (FAILED@10d) = 1; 20d excluded. + insertOutbox(EVENT_ID) + insertDelivery("d-recent-dead", "DEAD", daysAgo = 1) + insertDelivery("d-recent-failed", "FAILED", daysAgo = 3) + insertDelivery("d-prior-failed", "FAILED", daysAgo = 10) + insertDelivery("d-ancient-dead", "DEAD", daysAgo = 20) + insertDelivery("d-sent", "SENT", daysAgo = 1) + // ai: recent FAILED@2d = 1; prior FAILED@9d = 1; 20d excluded; SUCCESS excluded. + insertAiAudit("FAILED", daysAgo = 2) + insertAiAudit("FAILED", daysAgo = 9) + insertAiAudit("FAILED", daysAgo = 20) + insertAiAudit("SUCCESS", daysAgo = 1) + + val snapshot = adapter.loadSnapshot(UUID.fromString(CLUB_ID)) + + assertThat(snapshot).isNotNull + assertThat(snapshot!!.notificationHealth.recentFailed7d).isEqualTo(2) + assertThat(snapshot.notificationHealth.priorFailed7d).isEqualTo(1) + assertThat(snapshot.aiUsage.failedRecentJobs).isEqualTo(1) + assertThat(snapshot.aiUsage.priorFailedJobs7d).isEqualTo(1) + } + + @Test + fun `failure clusters only include the recent 7 day window`() { + seedClubWithMember() + insertOutbox(EVENT_ID) + insertDelivery("d-recent", "FAILED", daysAgo = 2) + insertDelivery("d-old", "FAILED", daysAgo = 12) + + val snapshot = adapter.loadSnapshot(UUID.fromString(CLUB_ID)) + + val total = snapshot!!.notificationHealth.failureClusters.sumOf { it.count } + assertThat(total).isEqualTo(1) + } + + @Test + fun `clean club reports zero trend counts`() { + seedClubWithMember() + + val snapshot = adapter.loadSnapshot(UUID.fromString(CLUB_ID)) + + assertThat(snapshot!!.notificationHealth.recentFailed7d).isEqualTo(0) + assertThat(snapshot.notificationHealth.priorFailed7d).isEqualTo(0) + assertThat(snapshot.aiUsage.priorFailedJobs7d).isEqualTo(0) + } + + private fun seedClubWithMember() { + jdbcTemplate.update( + "insert into clubs (id, slug, name, tagline, about, status, public_visibility) " + + "values (?, 'ops-trend-club', 'Ops Trend Club', '', '', 'ACTIVE', 'PRIVATE')", + CLUB_ID, + ) + jdbcTemplate.update( + "insert into users (id, google_subject_id, email, name, short_name, auth_provider) " + + "values (?, 'ops-trend-user', 'ops-trend@example.com', 'Ops Trend', 'OT', 'GOOGLE')", + USER_ID, + ) + jdbcTemplate.update( + "insert into memberships (id, club_id, user_id, role, status, joined_at, short_name) " + + "values (?, ?, ?, 'HOST', 'ACTIVE', utc_timestamp(6), 'OT')", + MEMBERSHIP_ID, + CLUB_ID, + USER_ID, + ) + } + + private fun insertOutbox(id: String) { + jdbcTemplate.update( + """ + insert into notification_event_outbox ( + id, club_id, event_type, aggregate_type, aggregate_id, payload_json, status, + kafka_key, attempt_count, last_error, dedupe_key, created_at, updated_at + ) + values (?, ?, 'SESSION_REMINDER_DUE', 'SESSION', ?, json_object('sessionId', ?), 'PUBLISHED', + ?, 1, null, ?, utc_timestamp(6), utc_timestamp(6)) + """.trimIndent(), + id, CLUB_ID, CLUB_ID, CLUB_ID, CLUB_ID, "ops-trend-outbox-$id", + ) + } + + private fun insertDelivery( + id: String, + status: String, + daysAgo: Long, + ) { + jdbcTemplate.update( + """ + insert into notification_deliveries ( + id, event_id, club_id, recipient_membership_id, channel, status, dedupe_key, + attempt_count, last_error, created_at, updated_at + ) + values (?, ?, ?, ?, 'EMAIL', ?, ?, 1, 'smtp timeout', + utc_timestamp(6) - interval ? day, utc_timestamp(6) - interval ? day) + """.trimIndent(), + id, EVENT_ID, CLUB_ID, MEMBERSHIP_ID, status, "ops-trend-delivery-$id", daysAgo, daysAgo, + ) + } + + private fun insertAiAudit( + status: String, + daysAgo: Long, + ) { + jdbcTemplate.update( + """ + insert into ai_generation_audit_log ( + job_id, session_id, club_id, host_user_id, kind, provider, model, status, + input_tokens, cached_input_tokens, output_tokens, cost_estimate_usd, latency_ms, created_at + ) + values (?, ?, ?, ?, 'SESSION_RECORD', 'ANTHROPIC', 'claude-x', ?, + 0, 0, 0, 0, 0, utc_timestamp(6) - interval ? day) + """.trimIndent(), + UUID.randomUUID().toString(), CLUB_ID, CLUB_ID, USER_ID, status, daysAgo, + ) + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `./server/gradlew -p server test --tests "com.readmates.club.adapter.out.persistence.JdbcAdminClubOperationsTrendTest" -q` +Expected: FAIL — `recentFailed7d`/`priorFailed7d`/`priorFailedJobs7d` are `0` (adapter not yet computing them) and the cluster window assertion sees `2`. + +- [ ] **Step 3: Add windowed counts to `notificationHealth(...)`** + +In `JdbcAdminClubOperationsAdapter.kt`, replace the `AdminClubNotificationHealth(...)` construction (lines ~150-189) so it passes the two new fields. Add these two `scalarInt` arguments after `failureClusters = failureClusters(clubId),`: + +```kotlin + failureClusters = failureClusters(clubId), + recentFailed7d = + scalarInt( + """ + select count(*) + from notification_deliveries + where club_id = ? and status in ('FAILED', 'DEAD') + and updated_at >= utc_timestamp(6) - interval 7 day + """.trimIndent(), + clubId, + ), + priorFailed7d = + scalarInt( + """ + select count(*) + from notification_deliveries + where club_id = ? and status in ('FAILED', 'DEAD') + and updated_at >= utc_timestamp(6) - interval 14 day + and updated_at < utc_timestamp(6) - interval 7 day + """.trimIndent(), + clubId, + ), + ) +``` + +- [ ] **Step 4: Window the failure clusters query** + +In `failureClusters(clubId)` (lines ~191-217), add the recent-window predicate to the `where` clause: + +```kotlin + where club_id = ? + and status in ('FAILED', 'DEAD') + and updated_at >= utc_timestamp(6) - interval 7 day + group by safe_error_code +``` + +- [ ] **Step 5: Add the prior-window AI count** + +In `aiUsage(clubId)` (lines ~219-248), add a `prior_failed_jobs_7d` column to the select and read it. Add this `sum(...)` after the `failed_recent_jobs` line: + +```kotlin + sum(case when status = 'FAILED' and created_at >= timestampadd(day, -14, utc_timestamp(6)) and created_at < timestampadd(day, -7, utc_timestamp(6)) then 1 else 0 end) as prior_failed_jobs_7d, +``` + +Then in the row mapper, read it and pass to the constructor: + +```kotlin + val priorFailedJobs7d = rs.getInt("prior_failed_jobs_7d") + AdminClubAiUsage( + activeJobs = activeJobs, + failedRecentJobs = failedRecentJobs, + staleCandidates = staleCandidates, + costEstimateUsd = rs.getBigDecimal("cost_estimate_usd").formatCost(), + state = + if (activeJobs + failedRecentJobs + staleCandidates == 0) { + "NO_RECENT_USAGE" + } else { + "HAS_ACTIVITY" + }, + priorFailedJobs7d = priorFailedJobs7d, + ) +``` + +Also update the `?:` fallback (line ~248) to include the new field: + +```kotlin + ) ?: AdminClubAiUsage(0, 0, 0, "0.0000", "NO_RECENT_USAGE", 0) +``` + +- [ ] **Step 6: Run the integration test to verify it passes** + +Run: `./server/gradlew -p server test --tests "com.readmates.club.adapter.out.persistence.JdbcAdminClubOperationsTrendTest" -q` +Expected: PASS (3 tests green). + +- [ ] **Step 7: Run server unit + architecture tests for regressions** + +Run: `./server/gradlew -p server unitTest architectureTest -q` +Expected: BUILD SUCCESSFUL. + +- [ ] **Step 8: Commit** + +```bash +git add server/src/main/kotlin/com/readmates/club/adapter/out/persistence/JdbcAdminClubOperationsAdapter.kt server/src/test/kotlin/com/readmates/club/adapter/out/persistence/JdbcAdminClubOperationsTrendTest.kt +git commit -m "feat: aggregate windowed failure trend in club operations snapshot" +``` + +--- + +## Task 3: Frontend model — types + pure delta/route helpers + +**Files:** +- Modify: `front/features/platform-admin/model/platform-admin-club-operations-model.ts:29-42` +- Create: `front/features/platform-admin/model/platform-admin-club-operations-model.test.ts` + +- [ ] **Step 1: Add the new type fields** + +In `platform-admin-club-operations-model.ts`, add to `notificationHealth` (after `failureClusters`): + +```ts + failureClusters: Array<{ safeErrorCode: string; count: number }>; + recentFailed7d: number; + priorFailed7d: number; + }; +``` + +And to `aiUsage` (after `state`): + +```ts + state: string; + priorFailedJobs7d: number; + }; +``` + +- [ ] **Step 2: Add pure helpers at the bottom of the same file** + +```ts +export function notificationFailureDelta(snapshot: AdminClubOperationsSnapshot): number { + return snapshot.notificationHealth.recentFailed7d - snapshot.notificationHealth.priorFailed7d; +} + +export function aiFailureDelta(snapshot: AdminClubOperationsSnapshot): number { + return snapshot.aiUsage.failedRecentJobs - snapshot.aiUsage.priorFailedJobs7d; +} + +export type ClubNextAction = { label: string; href: string; kind: "ADMIN_ROUTE" | "HOST_ROUTE" }; + +export function blockerNextAction(code: string, slug: string): ClubNextAction | null { + switch (code) { + case "HOST_REQUIRED": + return { label: "호스트 지정", href: `/clubs/${slug}/app`, kind: "HOST_ROUTE" }; + case "DOMAIN_ACTION_REQUIRED": + return { label: "도메인 조치", href: `/clubs/${slug}/app`, kind: "HOST_ROUTE" }; + case "CLUB_NOT_ACTIVE": + return { label: "클럽 상태 확인", href: `/clubs/${slug}/app`, kind: "HOST_ROUTE" }; + default: + return null; + } +} +``` + +- [ ] **Step 3: Write the failing helper unit test** + +Create `platform-admin-club-operations-model.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; +import { + aiFailureDelta, + blockerNextAction, + notificationFailureDelta, + type AdminClubOperationsSnapshot, +} from "./platform-admin-club-operations-model"; + +function snapshot(overrides: Partial = {}): AdminClubOperationsSnapshot { + return { + schema: "admin.club_operations_snapshot.v1", + generatedAt: "2026-05-30T00:00:00Z", + club: { clubId: "c1", slug: "alpha", name: "Alpha", status: "ACTIVE", publicVisibility: "PUBLIC" }, + readiness: { state: "READY", blockingReasons: [], nextAction: null }, + memberActivity: { activeCount: 0, dormantCount: 0, pendingViewerCount: 0, hostCount: 0 }, + sessionProgress: { upcomingCount: 0, currentOpenCount: 0, closedCount: 0, publishedRecordCount: 0, incompleteRecordCount: 0 }, + notificationHealth: { pending: 0, failed: 0, dead: 0, lastSuccessAt: null, failureClusters: [], recentFailed7d: 0, priorFailed7d: 0 }, + aiUsage: { activeJobs: 0, failedRecentJobs: 0, staleCandidates: 0, costEstimateUsd: "0.0000", state: "NO_RECENT_USAGE", priorFailedJobs7d: 0 }, + safeLinks: [], + ...overrides, + }; +} + +describe("club operations trend helpers", () => { + it("computes a rising notification delta", () => { + const s = snapshot({ notificationHealth: { pending: 0, failed: 0, dead: 0, lastSuccessAt: null, failureClusters: [], recentFailed7d: 5, priorFailed7d: 2 } }); + expect(notificationFailureDelta(s)).toBe(3); + }); + + it("computes a falling ai delta as negative", () => { + const s = snapshot({ aiUsage: { activeJobs: 0, failedRecentJobs: 1, staleCandidates: 0, costEstimateUsd: "0.0000", state: "HAS_ACTIVITY", priorFailedJobs7d: 4 } }); + expect(aiFailureDelta(s)).toBe(-3); + }); + + it("returns zero delta when both windows are empty", () => { + expect(notificationFailureDelta(snapshot())).toBe(0); + expect(aiFailureDelta(snapshot())).toBe(0); + }); + + it("maps known blocker codes to host next actions", () => { + expect(blockerNextAction("HOST_REQUIRED", "alpha")).toEqual({ label: "호스트 지정", href: "/clubs/alpha/app", kind: "HOST_ROUTE" }); + expect(blockerNextAction("DOMAIN_ACTION_REQUIRED", "alpha")?.href).toBe("/clubs/alpha/app"); + expect(blockerNextAction("CLUB_NOT_ACTIVE", "alpha")?.label).toBe("클럽 상태 확인"); + }); + + it("returns null for an unknown blocker code", () => { + expect(blockerNextAction("MYSTERY_CODE", "alpha")).toBeNull(); + }); +}); +``` + +- [ ] **Step 4: Run the test** + +Run: `pnpm --dir front test platform-admin-club-operations-model` +Expected: PASS (5 tests). + +- [ ] **Step 5: Commit** + +```bash +git add front/features/platform-admin/model/platform-admin-club-operations-model.ts front/features/platform-admin/model/platform-admin-club-operations-model.test.ts +git commit -m "feat: add club operations trend and blocker-route model helpers" +``` + +--- + +## Task 4: Frontend UI — 7-day metric, deltas, blocker links, section split + +**Files:** +- Modify: `front/features/platform-admin/ui/admin-club-operations-page.tsx` +- Modify: `front/features/platform-admin/ui/admin-club-operations-page.test.tsx` + +- [ ] **Step 1: Write the failing UI assertions** + +In `admin-club-operations-page.test.tsx`, extend the `snapshot` fixture (lines 14-15) to include the new fields, and add tests. Replace the `notificationHealth`/`aiUsage` lines with: + +```ts + notificationHealth: { pending: 1, failed: 1, dead: 0, lastSuccessAt: null, failureClusters: [], recentFailed7d: 5, priorFailed7d: 2 }, + aiUsage: { activeJobs: 0, failedRecentJobs: 1, staleCandidates: 0, costEstimateUsd: "0.1200", state: "HAS_ACTIVITY", priorFailedJobs7d: 3 }, +``` + +Then add these tests inside the `describe` block: + +```ts + it("shows the 7-day notification failure count with a trend delta", () => { + render( + + + , + ); + expect(screen.getByText("알림 실패 (7일)")).toBeInTheDocument(); + expect(screen.getAllByText("5").length).toBeGreaterThan(0); + expect(screen.getAllByText(/지난 7일 대비/).length).toBeGreaterThan(0); + }); + + it("links readiness blockers to a next action", () => { + const blocked: AdminClubOperationsSnapshot = { + ...snapshot, + readiness: { state: "NEEDS_ATTENTION", blockingReasons: ["HOST_REQUIRED"], nextAction: "HOST_REQUIRED" }, + }; + render( + + + , + ); + expect(screen.getByRole("link", { name: "호스트 지정" })).toHaveAttribute("href", "/clubs/reading-sai/app"); + }); + + it("separates platform-owned and host-owned sections", () => { + render( + + + , + ); + expect(screen.getByRole("region", { name: "플랫폼 운영" })).toBeInTheDocument(); + expect(screen.getByRole("region", { name: "호스트 운영" })).toBeInTheDocument(); + }); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `pnpm --dir front test admin-club-operations-page` +Expected: FAIL — "알림 실패 (7일)", the delta text, the "호스트 지정" link, and the two regions are not yet rendered. + +- [ ] **Step 3: Rewrite the operations page** + +Replace the full contents of `admin-club-operations-page.tsx` with: + +```tsx +import { Link } from "react-router-dom"; +import type { ReactNode } from "react"; +import { + aiFailureDelta, + blockerNextAction, + notificationFailureDelta, + type AdminClubOperationsSnapshot, +} from "@/features/platform-admin/model/platform-admin-club-operations-model"; + +type AdminClubOperationsPageProps = { + snapshot: AdminClubOperationsSnapshot; + supportGrantCount: number; +}; + +export function AdminClubOperationsPage({ snapshot, supportGrantCount }: AdminClubOperationsPageProps) { + const notifDelta = notificationFailureDelta(snapshot); + const aiDelta = aiFailureDelta(snapshot); + + return ( +
    +
    +
    +

    Operations snapshot

    +

    + {snapshot.club.name} 운영 스냅샷 +

    +
    + {snapshot.readiness.state} +
    + +
    + + + + + +
    + +
    +

    플랫폼 운영

    + {snapshot.readiness.blockingReasons.length > 0 ? ( +
      + {snapshot.readiness.blockingReasons.map((reason) => { + const action = blockerNextAction(reason, snapshot.club.slug); + return ( +
    • + {reason} + {action ? ( + + {action.label} + + ) : null} +
    • + ); + })} +
    + ) : ( +

    차단 신호 없음

    + )} + +
    + + + + + + + + 알림 ledger + + + + + + + + + + AI Ops + + +
    +
    + +
    +

    호스트 운영

    +
    + + + + + + + + + + + +
    +
    + +
    + {snapshot.safeLinks.map((link) => ( + + {link.label} + + ))} +
    +
    + ); +} + +function formatDelta(delta: number): string { + if (delta > 0) return `↑ ${delta} (지난 7일 대비)`; + if (delta < 0) return `↓ ${Math.abs(delta)} (지난 7일 대비)`; + return `→ 0 (지난 7일 대비)`; +} + +function Metric({ label, value, delta }: { label: string; value: number; delta?: number }) { + return ( +
    +

    {label}

    + {value} + {delta !== undefined ?

    {formatDelta(delta)}

    : null} +
    + ); +} + +function Panel({ title, children }: { title: string; children: ReactNode }) { + return ( +
    +

    {title}

    +
    {children}
    +
    + ); +} + +function Stat({ label, value }: { label: string; value: number | string }) { + return ( +
    + {label} + {value} +
    + ); +} +``` + +Note: the summary metric and the "최근 7일 실패" panel stat both render the value `5`; the existing `getByText("5")` assertion uses a non-exact match that resolves the first match, which is fine. If a future test needs uniqueness, use `getAllByText`. + +- [ ] **Step 4: Run the UI test** + +Run: `pnpm --dir front test admin-club-operations-page` +Expected: PASS. + +- [ ] **Step 5: Run lint + build** + +Run: `pnpm --dir front lint && pnpm --dir front build` +Expected: both succeed. + +- [ ] **Step 6: Commit** + +```bash +git add front/features/platform-admin/ui/admin-club-operations-page.tsx front/features/platform-admin/ui/admin-club-operations-page.test.tsx +git commit -m "feat: surface club detail failure trend and next-action links" +``` + +--- + +## Task 5: Fixtures, E2E, CHANGELOG, public-safety + +**Files:** +- Modify: `front/features/platform-admin/route/admin-club-detail-route.test.tsx:31-32` +- Modify: `front/tests/e2e/admin-club-operations.spec.ts:73-74` +- Modify: `CHANGELOG.md` + +- [ ] **Step 1: Add new fields to the route-test snapshot fixture** + +In `admin-club-detail-route.test.tsx`, replace the `notificationHealth`/`aiUsage` lines (31-32) with: + +```ts + notificationHealth: { pending: 0, failed: 0, dead: 0, lastSuccessAt: null, failureClusters: [], recentFailed7d: 0, priorFailed7d: 0 }, + aiUsage: { activeJobs: 0, failedRecentJobs: 0, staleCandidates: 0, costEstimateUsd: "0.0000", state: "NO_RECENT_USAGE", priorFailedJobs7d: 0 }, +``` + +- [ ] **Step 2: Update the E2E mock and add a trend assertion** + +In `admin-club-operations.spec.ts`, replace the mock `notificationHealth`/`aiUsage` lines (73-74) with: + +```ts + notificationHealth: { pending: 1, failed: 1, dead: 0, lastSuccessAt: null, failureClusters: [], recentFailed7d: 4, priorFailed7d: 1 }, + aiUsage: { activeJobs: 0, failedRecentJobs: 1, staleCandidates: 0, costEstimateUsd: "0.1200", state: "HAS_ACTIVITY", priorFailedJobs7d: 0 }, +``` + +Then add an assertion inside the existing `test(...)` block, after the "알림 ledger" link check: + +```ts + await expect(page.getByText("알림 실패 (7일)")).toBeVisible(); + await expect(page.getByText(/지난 7일 대비/).first()).toBeVisible(); +``` + +- [ ] **Step 3: Run frontend unit + e2e** + +Run: `pnpm --dir front test admin-club-detail-route` +Expected: PASS. + +Run: `pnpm --dir front test:e2e admin-club-operations` +Expected: PASS (owner views aggregate club operations with trend). + +- [ ] **Step 4: Add the CHANGELOG entry** + +In `CHANGELOG.md`, under the `Unreleased` section, add a bullet describing shipped behavior: + +```markdown +- 플랫폼 admin 클럽 상세 운영 스냅샷에 최근 7일 알림/AI 실패 추이(지난 7일 대비 델타)와 readiness 차단 신호별 next-action 링크를 추가하고, 플랫폼 운영/호스트 운영 섹션을 구분했습니다. +``` + +- [ ] **Step 5: Public-safety scan on changed files** + +Run: `git diff --check && git status --short` +Expected: no whitespace errors. Manually confirm no real member data, secrets, raw provider errors, transcript bodies, or token-shaped strings were added to any changed file (counts and safe labels only). + +- [ ] **Step 6: Full regression gate** + +Run: `pnpm --dir front lint && pnpm --dir front test && pnpm --dir front build` +Expected: all green. + +Run: `./server/gradlew -p server unitTest -q` +Expected: BUILD SUCCESSFUL. + +- [ ] **Step 7: Commit** + +```bash +git add front/features/platform-admin/route/admin-club-detail-route.test.tsx front/tests/e2e/admin-club-operations.spec.ts CHANGELOG.md +git commit -m "test: cover club detail trend e2e and note changelog" +``` + +--- + +## Self-Review Notes + +- **Spec coverage:** recentFailed7d/priorFailed7d/priorFailedJobs7d (Task 1-2), windowed clusters (Task 2 Step 4), 7-day metric swap (Task 4 Step 3), delta lines (Task 4), blocker next-action links (Task 3 helper + Task 4), platform/host section split (Task 4), additive v1 schema (Task 1 defaults, no schema literal change), fixtures default 0 (Task 5), tests + CHANGELOG + public-safety (all tasks + Task 5). All S3+ detail Gate items mapped. +- **Type consistency:** `notificationFailureDelta`/`aiFailureDelta`/`blockerNextAction` defined in Task 3 and consumed identically in Task 4. Field names `recentFailed7d`/`priorFailed7d`/`priorFailedJobs7d` consistent across server model, frontend type, and all fixtures. +- **Open item for reviewer:** all three readiness blockers currently map to the host app route (`/clubs/:slug/app`) because no dedicated admin domains route exists; adjust labels/destinations in `blockerNextAction` if a better target lands. diff --git a/docs/superpowers/plans/2026-05-30-readmates-admin-club-triage-failure-counts.md b/docs/superpowers/plans/2026-05-30-readmates-admin-club-triage-failure-counts.md new file mode 100644 index 00000000..9e300bc1 --- /dev/null +++ b/docs/superpowers/plans/2026-05-30-readmates-admin-club-triage-failure-counts.md @@ -0,0 +1,736 @@ +# Admin Club Triage Failure Counts Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add per-club notification-failure and AI-failure counts (last 7 days) to the `/admin/clubs` triage list so a club with any recent delivery or AI-generation failure sorts to the top as `긴급`(critical) with a human reason, closing the S3+ list gate. + +**Architecture:** Server-side set-based aggregation extends the existing `CLUB_BASE_SQL` in `JdbcPlatformAdminClubAdapter` with two `left join (… group by club_id)` subqueries over `notification_deliveries` (status FAILED/DEAD, `updated_at` within 7 days) and `ai_generation_audit_log` (status FAILED, `created_at` within 7 days). Two new integer fields flow through the model (`PlatformAdminClubListItem`), the response DTO (`PlatformAdminClubResponse`), and the frontend type (`PlatformAdminClub`). The pure frontend triage model folds `failureCount > 0 ⇒ critical` and prepends Korean reasons. No new screen, no new query path — `listClubs` and `loadClub` both reuse `CLUB_BASE_SQL`. + +**Tech Stack:** Kotlin, Spring Boot, JDBC, MySQL (Testcontainers, `@Tag("integration")`), Flyway dev seed; React 19, TypeScript, Vite, `@tanstack/react-query`, vitest + `@testing-library/react`, Playwright. + +**Scope source:** `docs/superpowers/specs/2026-05-30-readmates-admin-club-triage-failure-counts-design.md`. + +--- + +## File Structure + +- Modify: `server/src/main/kotlin/com/readmates/club/application/model/PlatformAdminModels.kt` — add `notificationFailureCount`/`aiFailureCount` to `PlatformAdminClubListItem`. +- Modify: `server/src/main/kotlin/com/readmates/club/adapter/out/persistence/JdbcPlatformAdminClubAdapter.kt` — extend `CLUB_BASE_SQL` with the two aggregation joins and read the columns in `mapPlatformAdminClub`. +- Create: `server/src/test/kotlin/com/readmates/club/adapter/out/persistence/JdbcPlatformAdminClubFailureCountsTest.kt` — integration test seeding an isolated club + FK chain, asserting 7-day window and FAILED/DEAD-only counting. +- Modify: `server/src/main/kotlin/com/readmates/club/adapter/in/web/PlatformAdminClubController.kt` — add the two fields to `PlatformAdminClubResponse` and `from`. +- Create: `server/src/test/kotlin/com/readmates/club/adapter/in/web/PlatformAdminClubResponseTest.kt` — unit test for the response mapping. +- Modify: `front/features/platform-admin/model/platform-admin-domain-types.ts` — add the two fields to `PlatformAdminClub`. +- Modify: `front/features/platform-admin/model/platform-admin-club-triage-model.ts` — fold failure counts into severity and reasons. +- Modify: `front/features/platform-admin/model/platform-admin-club-triage-model.test.ts` — add severity + reason tests; update the `club()` factory. +- Modify: `front/features/platform-admin/route/admin-clubs-route.test.tsx` — update the inline factory type/defaults; add a failure-reason render test. +- Modify: `front/tests/e2e/admin-clubs-triage.spec.ts` — add the two fields to mock items; assert a failure reason renders. +- Modify: `CHANGELOG.md` — `Unreleased` entry. + +--- + +## Task 1: Server aggregation — model, SQL, mapping (integration-tested) + +**Files:** +- Create: `server/src/test/kotlin/com/readmates/club/adapter/out/persistence/JdbcPlatformAdminClubFailureCountsTest.kt` +- Modify: `server/src/main/kotlin/com/readmates/club/application/model/PlatformAdminModels.kt:72-83` +- Modify: `server/src/main/kotlin/com/readmates/club/adapter/out/persistence/JdbcPlatformAdminClubAdapter.kt` + +- [ ] **Step 1: Write the failing integration test** + +Create `server/src/test/kotlin/com/readmates/club/adapter/out/persistence/JdbcPlatformAdminClubFailureCountsTest.kt`: + +```kotlin +package com.readmates.club.adapter.out.persistence + +import com.readmates.support.ReadmatesMySqlIntegrationTestSupport +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Tag +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.test.context.jdbc.Sql +import java.util.UUID + +private const val CLUB_ID = "00000000-0000-0000-0000-0000000fc001" +private const val USER_ID = "00000000-0000-0000-0000-0000000fc002" +private const val MEMBERSHIP_ID = "00000000-0000-0000-0000-0000000fc003" +private const val RECENT_EVENT_ID = "00000000-0000-0000-0000-0000000fc101" +private const val OLD_EVENT_ID = "00000000-0000-0000-0000-0000000fc102" +private const val RECENT_DEAD_DELIVERY_ID = "00000000-0000-0000-0000-0000000fc201" +private const val RECENT_FAILED_DELIVERY_ID = "00000000-0000-0000-0000-0000000fc202" +private const val OLD_DEAD_DELIVERY_ID = "00000000-0000-0000-0000-0000000fc203" +private const val SENT_DELIVERY_ID = "00000000-0000-0000-0000-0000000fc204" + +private const val CLEANUP_SQL = """ + delete from ai_generation_audit_log where club_id = '$CLUB_ID'; + delete from notification_deliveries where club_id = '$CLUB_ID'; + delete from notification_event_outbox where club_id = '$CLUB_ID'; + delete from memberships where id = '$MEMBERSHIP_ID'; + delete from users where id = '$USER_ID'; + delete from clubs where id = '$CLUB_ID'; +""" + +@SpringBootTest(properties = ["spring.flyway.locations=classpath:db/mysql/migration,classpath:db/mysql/dev"]) +@Sql(statements = [CLEANUP_SQL], executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) +@Sql(statements = [CLEANUP_SQL], executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD) +@Tag("integration") +class JdbcPlatformAdminClubFailureCountsTest( + @param:Autowired private val jdbcTemplate: JdbcTemplate, +) : ReadmatesMySqlIntegrationTestSupport() { + private val adapter by lazy { JdbcPlatformAdminClubAdapter(jdbcTemplate) } + + @Test + fun `counts only recent failed notification deliveries and failed ai generations`() { + seedClubWithMember() + seedNotificationDeliveries() + seedAiAuditRows() + + val club = adapter.loadClub(UUID.fromString(CLUB_ID)) + + assertThat(club).isNotNull + // Recent DEAD + recent FAILED = 2; old DEAD and SENT excluded. + assertThat(club!!.notificationFailureCount).isEqualTo(2) + // 1 recent FAILED ai row; old FAILED and recent SUCCESS excluded. + assertThat(club.aiFailureCount).isEqualTo(1) + } + + @Test + fun `reports zero failures for a clean club`() { + seedClubWithMember() + + val club = adapter.loadClub(UUID.fromString(CLUB_ID)) + + assertThat(club!!.notificationFailureCount).isEqualTo(0) + assertThat(club.aiFailureCount).isEqualTo(0) + } + + private fun seedClubWithMember() { + jdbcTemplate.update( + "insert into clubs (id, slug, name, tagline, about, status, public_visibility) " + + "values (?, 'failure-count-club', 'Failure Count Club', '', '', 'ACTIVE', 'PRIVATE')", + CLUB_ID, + ) + jdbcTemplate.update( + "insert into users (id, google_subject_id, email, name, short_name, auth_provider) " + + "values (?, 'failure-count-user', 'failure-count@example.com', 'Failure Count', 'FC', 'GOOGLE')", + USER_ID, + ) + jdbcTemplate.update( + "insert into memberships (id, club_id, user_id, role, status, joined_at, short_name) " + + "values (?, ?, ?, 'MEMBER', 'ACTIVE', utc_timestamp(6), 'FC')", + MEMBERSHIP_ID, + CLUB_ID, + USER_ID, + ) + } + + private fun seedNotificationDeliveries() { + insertOutbox(RECENT_EVENT_ID) + insertOutbox(OLD_EVENT_ID) + insertDelivery(RECENT_DEAD_DELIVERY_ID, RECENT_EVENT_ID, "DEAD", daysAgo = 1) + insertDelivery(RECENT_FAILED_DELIVERY_ID, RECENT_EVENT_ID, "FAILED", daysAgo = 3) + insertDelivery(OLD_DEAD_DELIVERY_ID, OLD_EVENT_ID, "DEAD", daysAgo = 10) + insertDelivery(SENT_DELIVERY_ID, RECENT_EVENT_ID, "SENT", daysAgo = 1) + } + + private fun seedAiAuditRows() { + insertAiAudit("aigen-recent-failed", "FAILED", daysAgo = 2) + insertAiAudit("aigen-old-failed", "FAILED", daysAgo = 9) + insertAiAudit("aigen-recent-ok", "SUCCESS", daysAgo = 1) + } + + private fun insertOutbox(id: String) { + jdbcTemplate.update( + """ + insert into notification_event_outbox ( + id, club_id, event_type, aggregate_type, aggregate_id, payload_json, status, + kafka_key, attempt_count, last_error, dedupe_key, created_at, updated_at + ) + values (?, ?, 'SESSION_REMINDER_DUE', 'SESSION', ?, json_object('sessionId', ?), 'PUBLISHED', + ?, 1, null, ?, utc_timestamp(6), utc_timestamp(6)) + """.trimIndent(), + id, CLUB_ID, CLUB_ID, CLUB_ID, CLUB_ID, "failure-count-outbox-$id", + ) + } + + private fun insertDelivery(id: String, eventId: String, status: String, daysAgo: Long) { + jdbcTemplate.update( + """ + insert into notification_deliveries ( + id, event_id, club_id, recipient_membership_id, channel, status, dedupe_key, + attempt_count, last_error, created_at, updated_at + ) + values (?, ?, ?, ?, 'EMAIL', ?, ?, 1, null, + utc_timestamp(6) - interval ? day, utc_timestamp(6) - interval ? day) + """.trimIndent(), + id, eventId, CLUB_ID, MEMBERSHIP_ID, status, "failure-count-delivery-$id", daysAgo, daysAgo, + ) + } + + private fun insertAiAudit(jobSuffix: String, status: String, daysAgo: Long) { + jdbcTemplate.update( + """ + insert into ai_generation_audit_log ( + job_id, session_id, club_id, host_user_id, kind, provider, model, status, + input_tokens, cached_input_tokens, output_tokens, cost_estimate_usd, latency_ms, created_at + ) + values (?, ?, ?, ?, 'SESSION_RECORD', 'ANTHROPIC', 'claude-x', ?, + 0, 0, 0, 0, 0, utc_timestamp(6) - interval ? day) + """.trimIndent(), + UUID.randomUUID().toString(), CLUB_ID, CLUB_ID, USER_ID, status, daysAgo, + ) + } +} +``` + +Note: `notification_deliveries.updated_at` has `on update current_timestamp(6)`, but an explicit value supplied at INSERT is honored (the trigger only fires on UPDATE). `ai_generation_audit_log` has no FK, so its `club_id`/`host_user_id` need not reference real rows — but we reuse the seeded ids for clarity. The `status` strings (`DEAD`/`FAILED`/`SENT`, `FAILED`/`SUCCESS`) match the values used elsewhere in the codebase (the `ai_generation_audit_log.status` column stores `'SUCCESS'` for the `JobStatus.SUCCEEDED` domain state). + +- [ ] **Step 2: Run the test to verify it fails to compile** + +Run: `./server/gradlew -p server compileTestKotlin` +Expected: FAIL — `notificationFailureCount` / `aiFailureCount` are unresolved references on `PlatformAdminClubListItem`. + +- [ ] **Step 3: Add the two fields to the model** + +In `server/src/main/kotlin/com/readmates/club/application/model/PlatformAdminModels.kt`, edit `PlatformAdminClubListItem` (currently lines 72-83) to add the two fields after `domainActionRequiredCount`: + +```kotlin +data class PlatformAdminClubListItem( + val clubId: UUID, + val slug: String, + val name: String, + val tagline: String, + val about: String, + val status: ClubStatus, + val publicVisibility: ClubPublicVisibility, + val domainCount: Int, + val domainActionRequiredCount: Int, + val notificationFailureCount: Int, + val aiFailureCount: Int, + val firstHostOnboardingState: FirstHostOnboardingState, +) +``` + +- [ ] **Step 4: Read the new columns in the row mapper** + +In `server/src/main/kotlin/com/readmates/club/adapter/out/persistence/JdbcPlatformAdminClubAdapter.kt`, update `mapPlatformAdminClub` (lines 246-261) to populate the two fields: + +```kotlin +private fun mapPlatformAdminClub( + resultSet: ResultSet, + @Suppress("UNUSED_PARAMETER") rowNumber: Int, +): PlatformAdminClubListItem = + PlatformAdminClubListItem( + clubId = resultSet.uuid("id"), + slug = resultSet.getString("slug"), + name = resultSet.getString("name"), + tagline = resultSet.getString("tagline"), + about = resultSet.getString("about"), + status = ClubStatus.valueOf(resultSet.getString("status")), + publicVisibility = ClubPublicVisibility.valueOf(resultSet.getString("public_visibility")), + domainCount = resultSet.getInt("domain_count"), + domainActionRequiredCount = resultSet.getInt("domain_action_required_count"), + notificationFailureCount = resultSet.getInt("notification_failure_count"), + aiFailureCount = resultSet.getInt("ai_failure_count"), + firstHostOnboardingState = FirstHostOnboardingState.valueOf(resultSet.getString("first_host_state")), + ) +``` + +- [ ] **Step 5: Run the test to verify it fails on the missing columns** + +Run: `./server/gradlew -p server test --tests "com.readmates.club.adapter.out.persistence.JdbcPlatformAdminClubFailureCountsTest"` +Expected: FAIL — SQL error / `notification_failure_count` column not found (the SELECT does not yet produce it). + +- [ ] **Step 6: Extend `CLUB_BASE_SQL` with the two aggregation joins** + +In the same adapter file, replace the `CLUB_BASE_SQL` constant (lines 200-236) with this version. It adds the two SELECT columns and two `left join` subqueries; everything else is unchanged: + +```kotlin + private const val CLUB_BASE_SQL = """ + select + clubs.id, + clubs.slug, + clubs.name, + clubs.tagline, + clubs.about, + clubs.status, + clubs.public_visibility, + coalesce(domain_counts.domain_count, 0) as domain_count, + coalesce(domain_counts.action_required_count, 0) as domain_action_required_count, + coalesce(notification_failures.failure_count, 0) as notification_failure_count, + coalesce(ai_failures.failure_count, 0) as ai_failure_count, + case + when exists ( + select 1 from memberships + where memberships.club_id = clubs.id + and memberships.role = 'HOST' + and memberships.status = 'ACTIVE' + ) then 'ASSIGNED' + when exists ( + select 1 from invitations + where invitations.club_id = clubs.id + and invitations.role = 'HOST' + and invitations.status = 'PENDING' + and invitations.expires_at >= utc_timestamp(6) + ) then 'INVITED' + else 'MISSING' + end as first_host_state + from clubs + left join ( + select + club_id, + count(*) as domain_count, + sum(case when status = 'ACTION_REQUIRED' then 1 else 0 end) as action_required_count + from club_domains + group by club_id + ) domain_counts on domain_counts.club_id = clubs.id + left join ( + select club_id, count(*) as failure_count + from notification_deliveries + where status in ('FAILED', 'DEAD') + and updated_at >= utc_timestamp(6) - interval 7 day + group by club_id + ) notification_failures on notification_failures.club_id = clubs.id + left join ( + select club_id, count(*) as failure_count + from ai_generation_audit_log + where status = 'FAILED' + and created_at >= utc_timestamp(6) - interval 7 day + group by club_id + ) ai_failures on ai_failures.club_id = clubs.id + """ +``` + +- [ ] **Step 7: Run the test to verify it passes** + +Run: `./server/gradlew -p server test --tests "com.readmates.club.adapter.out.persistence.JdbcPlatformAdminClubFailureCountsTest"` +Expected: PASS — both cases green (2 notification failures, 1 AI failure; clean club is 0/0). + +- [ ] **Step 8: Commit** + +```bash +git add server/src/main/kotlin/com/readmates/club/application/model/PlatformAdminModels.kt server/src/main/kotlin/com/readmates/club/adapter/out/persistence/JdbcPlatformAdminClubAdapter.kt server/src/test/kotlin/com/readmates/club/adapter/out/persistence/JdbcPlatformAdminClubFailureCountsTest.kt +git commit -m "feat: aggregate recent club notification and ai failures" +``` + +--- + +## Task 2: Expose the counts in the response DTO + +**Files:** +- Modify: `server/src/main/kotlin/com/readmates/club/adapter/in/web/PlatformAdminClubController.kt:100-120` +- Create: `server/src/test/kotlin/com/readmates/club/adapter/in/web/PlatformAdminClubResponseTest.kt` + +- [ ] **Step 1: Write the failing mapping test** + +Create `server/src/test/kotlin/com/readmates/club/adapter/in/web/PlatformAdminClubResponseTest.kt`: + +```kotlin +package com.readmates.club.adapter.in.web + +import com.readmates.club.application.model.FirstHostOnboardingState +import com.readmates.club.application.model.PlatformAdminClubListItem +import com.readmates.club.domain.ClubPublicVisibility +import com.readmates.club.domain.ClubStatus +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import java.util.UUID + +class PlatformAdminClubResponseTest { + @Test + fun `maps notification and ai failure counts`() { + val item = PlatformAdminClubListItem( + clubId = UUID.fromString("00000000-0000-0000-0000-0000000fc001"), + slug = "failure-count-club", + name = "Failure Count Club", + tagline = "", + about = "", + status = ClubStatus.ACTIVE, + publicVisibility = ClubPublicVisibility.PRIVATE, + domainCount = 1, + domainActionRequiredCount = 0, + notificationFailureCount = 2, + aiFailureCount = 1, + firstHostOnboardingState = FirstHostOnboardingState.ASSIGNED, + ) + + val response = PlatformAdminClubResponse.from(item) + + assertThat(response.notificationFailureCount).isEqualTo(2) + assertThat(response.aiFailureCount).isEqualTo(1) + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `./server/gradlew -p server test --tests "com.readmates.club.adapter.in.web.PlatformAdminClubResponseTest"` +Expected: FAIL — `notificationFailureCount` / `aiFailureCount` are unresolved on `PlatformAdminClubResponse`. + +- [ ] **Step 3: Add the fields to the response DTO and `from`** + +In `server/src/main/kotlin/com/readmates/club/adapter/in/web/PlatformAdminClubController.kt`, update `PlatformAdminClubResponse` (lines 100-120). Add the two fields after `domainActionRequiredCount` in both the constructor and the `from` mapping: + +```kotlin +data class PlatformAdminClubResponse( + val clubId: String, + val slug: String, + val name: String, + val tagline: String, + val about: String, + val status: String, + val publicVisibility: String, + val domainCount: Int, + val domainActionRequiredCount: Int, + val notificationFailureCount: Int, + val aiFailureCount: Int, + val firstHostOnboardingState: String, +) { + companion object { + fun from(item: PlatformAdminClubListItem): PlatformAdminClubResponse = + PlatformAdminClubResponse( + clubId = item.clubId.toString(), + slug = item.slug, + name = item.name, + tagline = item.tagline, + about = item.about, + status = item.status.name, +``` + +Then in the same `from` body, add the two new fields after the existing `domainActionRequiredCount = ...` line and before `firstHostOnboardingState = ...`: + +```kotlin + domainActionRequiredCount = item.domainActionRequiredCount, + notificationFailureCount = item.notificationFailureCount, + aiFailureCount = item.aiFailureCount, + firstHostOnboardingState = item.firstHostOnboardingState.name, +``` + +(Keep the rest of the existing `from` body — `publicVisibility`, `domainCount`, the closing `)` — exactly as it is.) + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `./server/gradlew -p server test --tests "com.readmates.club.adapter.in.web.PlatformAdminClubResponseTest"` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add server/src/main/kotlin/com/readmates/club/adapter/in/web/PlatformAdminClubController.kt server/src/test/kotlin/com/readmates/club/adapter/in/web/PlatformAdminClubResponseTest.kt +git commit -m "feat: expose club failure counts in admin clubs response" +``` + +--- + +## Task 3: Frontend type + triage model + +**Files:** +- Modify: `front/features/platform-admin/model/platform-admin-domain-types.ts:68-79` +- Modify: `front/features/platform-admin/model/platform-admin-club-triage-model.ts` +- Modify: `front/features/platform-admin/model/platform-admin-club-triage-model.test.ts` + +- [ ] **Step 1: Update the model test factory and add failing tests** + +In `front/features/platform-admin/model/platform-admin-club-triage-model.test.ts`, add the two fields to the `club()` factory defaults (after `domainActionRequiredCount: 0,`): + +```ts + domainActionRequiredCount: 0, + notificationFailureCount: 0, + aiFailureCount: 0, + firstHostOnboardingState: "ASSIGNED", +``` + +Then add these tests inside the existing `describe("clubTriageSeverity", ...)` block: + +```ts + it("is critical when there are recent notification failures", () => { + expect(clubTriageSeverity(club({ notificationFailureCount: 1 }))).toBe("critical"); + }); + + it("is critical when there are recent ai failures", () => { + expect(clubTriageSeverity(club({ aiFailureCount: 2 }))).toBe("critical"); + }); +``` + +And add these inside the existing `describe("clubTriageReasons", ...)` block: + +```ts + it("lists failure counts first, ahead of domain and host reasons", () => { + expect( + clubTriageReasons( + club({ notificationFailureCount: 3, aiFailureCount: 1, domainActionRequiredCount: 1 }), + ), + ).toEqual(["알림 실패 3건", "AI 실패 1건", "도메인 조치 필요"]); + }); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `pnpm --dir front test platform-admin-club-triage-model` +Expected: FAIL — severity returns `"ok"` for the failure-count clubs, and reasons omit the failure strings. (TypeScript may also flag `notificationFailureCount` as unknown on the factory once the type is updated — that resolves in Step 3/4.) + +- [ ] **Step 3: Add the two fields to the `PlatformAdminClub` type** + +In `front/features/platform-admin/model/platform-admin-domain-types.ts`, edit the `PlatformAdminClub` type (lines 68-79) to add the two fields after `domainActionRequiredCount`: + +```ts +export type PlatformAdminClub = { + clubId: string; + slug: string; + name: string; + tagline: string; + about: string; + status: PlatformAdminClubStatus; + publicVisibility: PlatformAdminClubPublicVisibility; + domainCount: number; + domainActionRequiredCount: number; + notificationFailureCount: number; + aiFailureCount: number; + firstHostOnboardingState: FirstHostOnboardingState; +}; +``` + +- [ ] **Step 4: Fold failure counts into severity and reasons** + +In `front/features/platform-admin/model/platform-admin-club-triage-model.ts`: + +Replace the `clubTriageReasons` function so failure counts are prepended (most actionable first): + +```ts +export function clubTriageReasons(club: PlatformAdminClub): string[] { + const reasons: string[] = []; + if (club.notificationFailureCount > 0) { + reasons.push(`알림 실패 ${club.notificationFailureCount}건`); + } + if (club.aiFailureCount > 0) { + reasons.push(`AI 실패 ${club.aiFailureCount}건`); + } + if (club.domainActionRequiredCount > 0) { + reasons.push("도메인 조치 필요"); + } + if (club.firstHostOnboardingState === "MISSING") { + reasons.push("호스트 없음"); + } else if (club.firstHostOnboardingState === "INVITED") { + reasons.push("호스트 초대 대기"); + } + if (club.status === "SUSPENDED") { + reasons.push("정지됨"); + } else if (club.status === "ARCHIVED") { + reasons.push("보관됨"); + } else if (club.status === "SETUP_REQUIRED") { + reasons.push("설정 미완료"); + } + return reasons; +} +``` + +Replace the `clubTriageSeverity` function to treat any recent failure as critical: + +```ts +export function clubTriageSeverity(club: PlatformAdminClub): ClubTriageSeverity { + if ( + club.notificationFailureCount > 0 || + club.aiFailureCount > 0 || + club.domainActionRequiredCount > 0 || + club.status === "SUSPENDED" || + club.status === "ARCHIVED" + ) { + return "critical"; + } + if (club.status === "SETUP_REQUIRED" || club.firstHostOnboardingState !== "ASSIGNED") { + return "attention"; + } + return "ok"; +} +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `pnpm --dir front test platform-admin-club-triage-model` +Expected: PASS — new severity and reason-order cases green, existing cases unchanged. + +- [ ] **Step 6: Commit** + +```bash +git add front/features/platform-admin/model/platform-admin-domain-types.ts front/features/platform-admin/model/platform-admin-club-triage-model.ts front/features/platform-admin/model/platform-admin-club-triage-model.test.ts +git commit -m "feat: fold club failure counts into triage severity" +``` + +--- + +## Task 4: Clubs route test — failure reason render + factory defaults + +**Files:** +- Modify: `front/features/platform-admin/route/admin-clubs-route.test.tsx:8-14` (factory type) and the `describe` body +- Modify: `front/features/platform-admin/model/admin-status-strip-model.test.ts` (club factory/literal — add `notificationFailureCount: 0, aiFailureCount: 0`) +- Modify: `front/features/platform-admin/queries/platform-admin-queries.test.tsx` (club literal ~line 45 — add the two fields) +- Modify: `front/features/platform-admin/route/admin-club-detail-route.test.tsx` (club literal ~line 21 — add the two fields) +- Modify: `front/features/platform-admin/route/admin-today-route.test.tsx` (club literal ~line 36 — add the two fields) + +> **Scope note (orchestrator, 2026-05-30):** Task 3's `PlatformAdminClub` type change added two required fields. `tsc --noEmit` confirms FIVE test files construct `PlatformAdminClub`-typed literals/factories and now fail to compile: `admin-clubs-route.test.tsx`, `admin-status-strip-model.test.ts`, `platform-admin-queries.test.tsx`, `admin-club-detail-route.test.tsx`, `admin-today-route.test.tsx`. The original plan only listed the first. All five must get `notificationFailureCount: 0, aiFailureCount: 0` safe defaults here so Task 5's full `pnpm --dir front test && build` regression passes. These are pure compile-fix defaults (value 0) — no behavioral change to those tests. + +- [ ] **Step 0: Add safe-default fields to the four sibling type-break test files** + +For each of these four files, locate the `PlatformAdminClub` object literal(s) or factory defaults and add `notificationFailureCount: 0, aiFailureCount: 0` next to `domainActionRequiredCount` (value 0, no behavior change): +- `front/features/platform-admin/model/admin-status-strip-model.test.ts` +- `front/features/platform-admin/queries/platform-admin-queries.test.tsx` +- `front/features/platform-admin/route/admin-club-detail-route.test.tsx` +- `front/features/platform-admin/route/admin-today-route.test.tsx` + +After editing, run `pnpm --dir front exec tsc --noEmit` and confirm none of these four files report `notificationFailureCount`/`aiFailureCount` missing-property errors. (The `admin-clubs-route.test.tsx` fix is handled in Step 1 below.) + +- [ ] **Step 1: Update the inline factory type and existing item literals** + +In `front/features/platform-admin/route/admin-clubs-route.test.tsx`, update the `renderRoute` parameter type (lines 8-14) to add the two fields: + +```ts +function renderRoute(items: Array<{ + clubId: string; slug: string; name: string; + status: "ACTIVE" | "SETUP_REQUIRED" | "SUSPENDED" | "ARCHIVED"; + publicVisibility: "PRIVATE" | "PUBLIC"; + domainCount: number; domainActionRequiredCount: number; + notificationFailureCount: number; aiFailureCount: number; + firstHostOnboardingState: "MISSING" | "INVITED" | "ASSIGNED"; + tagline: string; about: string; +}>) { +``` + +Every existing item literal in this file must now include `notificationFailureCount: 0, aiFailureCount: 0,`. Add those two keys to each item object already present (the `c-1`/`alpha`, `ok-1`/`healthy`, `crit-1`/`broken` literals), placing them next to `domainActionRequiredCount`. + +- [ ] **Step 2: Add a failing test for the failure reason** + +Append inside `describe("AdminClubsRoute", ...)`: + +```ts + it("shows a notification-failure reason and ranks the club critical", () => { + renderRoute([ + { + clubId: "ok-1", slug: "healthy", name: "Healthy", status: "ACTIVE", + publicVisibility: "PUBLIC", domainCount: 1, domainActionRequiredCount: 0, + notificationFailureCount: 0, aiFailureCount: 0, + firstHostOnboardingState: "ASSIGNED", tagline: "", about: "", + }, + { + clubId: "fail-1", slug: "failing", name: "Failing", status: "ACTIVE", + publicVisibility: "PRIVATE", domainCount: 1, domainActionRequiredCount: 0, + notificationFailureCount: 4, aiFailureCount: 0, + firstHostOnboardingState: "ASSIGNED", tagline: "", about: "", + }, + ]); + const rows = screen.getAllByRole("row").slice(1); + expect(within(rows[0]).getByText("Failing")).toBeInTheDocument(); + expect(screen.getByText("알림 실패 4건")).toBeInTheDocument(); + }); +``` + +- [ ] **Step 3: Run the tests to verify they pass** + +Run: `pnpm --dir front test admin-clubs-route` +Expected: PASS — the new reason renders and the failing club sorts above the healthy one. No change to `admin-clubs-route.tsx` is needed (it already renders `clubTriageReasons` output and sorts by `rankClubsByTriage`). If TypeScript flags missing fields on any pre-existing item literal, add `notificationFailureCount: 0, aiFailureCount: 0` to it. + +- [ ] **Step 4: Commit** + +```bash +git add front/features/platform-admin/route/admin-clubs-route.test.tsx \ + front/features/platform-admin/model/admin-status-strip-model.test.ts \ + front/features/platform-admin/queries/platform-admin-queries.test.tsx \ + front/features/platform-admin/route/admin-club-detail-route.test.tsx \ + front/features/platform-admin/route/admin-today-route.test.tsx +git commit -m "test: cover club failure-count triage reason in clubs route" +``` + +--- + +## Task 5: E2E mock + CHANGELOG + full regression + +**Files:** +- Modify: `front/tests/e2e/admin-clubs-triage.spec.ts` +- Modify: `CHANGELOG.md` + +- [ ] **Step 1: Add the new fields to the E2E mock items and assert the reason** + +In `front/tests/e2e/admin-clubs-triage.spec.ts`, in the `**/api/bff/api/admin/clubs` route handler, add `notificationFailureCount`/`aiFailureCount` to both mock items. Give the `crit-club`/`broken` item a non-zero notification failure so the reason renders: + +```ts + { + clubId: "ok-club", slug: "healthy", name: "Healthy Club", + tagline: "", about: "", status: "ACTIVE", publicVisibility: "PUBLIC", + domainCount: 1, domainActionRequiredCount: 0, + notificationFailureCount: 0, aiFailureCount: 0, + firstHostOnboardingState: "ASSIGNED", + }, + { + clubId: "crit-club", slug: "broken", name: "Broken Club", + tagline: "", about: "", status: "ACTIVE", publicVisibility: "PRIVATE", + domainCount: 1, domainActionRequiredCount: 2, + notificationFailureCount: 2, aiFailureCount: 0, + firstHostOnboardingState: "ASSIGNED", + }, +``` + +Then, in the test body (after navigating to `/admin/clubs` and before/after the existing filter assertions), add an assertion that the failure reason renders: + +```ts +await expect(page.getByText("알림 실패 2건")).toBeVisible(); +``` + +Place this assertion where the page is on the default `전체` filter so the `crit-club` row is visible (the broken club is critical, so it is shown under `전체` and `긴급`). + +- [ ] **Step 2: Run the E2E test to verify it passes** + +Run: `pnpm --dir front test:e2e admin-clubs-triage` +Expected: PASS — triage toolbar, filter, drill-in, and the new failure-reason assertion all green. + +- [ ] **Step 3: Update CHANGELOG** + +In `CHANGELOG.md`, under `Unreleased`, add a bullet describing shipped behavior: + +```markdown +- `/admin/clubs`: triage now counts each club's recent (7-day) notification-delivery and AI-generation failures, ranks any club with a failure as 긴급, and shows `알림 실패 N건` / `AI 실패 N건` as the leading reasons so operators see member-impacting failures first. +``` + +- [ ] **Step 4: Run the full frontend regression suite** + +Run: `pnpm --dir front lint && pnpm --dir front test && pnpm --dir front build` +Expected: PASS — all green. + +- [ ] **Step 5: Run the server suite** + +Run: `./server/gradlew -p server unitTest && ./server/gradlew -p server test --tests "com.readmates.club.*"` +Expected: PASS — unit tests plus the new club integration and response tests green. (No package boundary moved, so `architectureTest` is unchanged; run it if the gradle config couples it to club changes.) + +- [ ] **Step 6: Commit** + +```bash +git add front/tests/e2e/admin-clubs-triage.spec.ts CHANGELOG.md +git commit -m "test: cover club failure-count triage e2e and note changelog" +``` + +--- + +## Verification Gates (whole plan) + +- [ ] `./server/gradlew -p server test --tests "com.readmates.club.adapter.out.persistence.JdbcPlatformAdminClubFailureCountsTest"` — window + FAILED/DEAD-only counting verified. +- [ ] `./server/gradlew -p server test --tests "com.readmates.club.adapter.in.web.PlatformAdminClubResponseTest"` — DTO mapping verified. +- [ ] `pnpm --dir front test platform-admin-club-triage-model` — severity + reason model tests pass. +- [ ] `pnpm --dir front test admin-clubs-route` — failure-reason render + ordering pass. +- [ ] `pnpm --dir front lint` — no lint errors. +- [ ] `pnpm --dir front build` — production build succeeds. +- [ ] `pnpm --dir front test:e2e admin-clubs-triage` — triage happy path + failure reason pass. +- [ ] `git diff --check` — no whitespace/conflict markers in changed files. +- [ ] Manual browser smoke: dev-login as platform admin, seed (or use dev data with) a failed delivery / failed AI job, open `/admin/clubs`, confirm the club ranks 긴급 with `알림 실패 N건` / `AI 실패 N건`. + +## Public Safety + +- Only integer counts are added to the UI, response, fixtures, and tests. No provider raw errors, transcript bodies, AI result JSON, member data, private message bodies, secrets, private domains, or local paths are introduced. The 7-day window is computed server-side; the client receives only the two integers. + +## Spec Coverage Check + +- Spec §5 server aggregation (two joins, 7-day window, coalesce) → Task 1 Step 6. +- Spec §5 model + response fields → Task 1 Steps 3-4, Task 2. +- Spec §5 frontend type + severity + reasons → Task 3. +- Spec §3 decisions (7-day window; failure > 0 ⇒ critical) → Task 1 Step 6 SQL, Task 3 Step 4 severity. +- Spec §7 data contract (field names/shape consistent across server/front/fixtures) → Tasks 1-5 use `notificationFailureCount`/`aiFailureCount` everywhere. +- Spec §9 testing gates (integration FK seeding, window boundary, model/route tests, E2E regression, CHANGELOG, public-safety) → Tasks 1, 3, 4, 5. +- Spec §6 non-goals (no write, no detail redesign, no host commands, no raw errors) → respected; only counts added. diff --git a/docs/superpowers/plans/2026-05-31-admin-hardening-baseline-sweep.md b/docs/superpowers/plans/2026-05-31-admin-hardening-baseline-sweep.md new file mode 100644 index 00000000..5e9f0c4c --- /dev/null +++ b/docs/superpowers/plans/2026-05-31-admin-hardening-baseline-sweep.md @@ -0,0 +1,555 @@ +# Admin 하드닝 베이스라인 스윕 (H 슬라이스) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 이미 shipped된 admin 전 라우트(+host dashboard)에 접근성·empty/에러·일관성 베이스라인을 일관 적용하고, 그 기준을 후속 슬라이스(A/M/P)가 재사용할 문서화된 체크리스트 + 자동 가드레일로 고정한다. + +**Architecture:** 신규 a11y 의존성(axe 등)을 추가하지 않는다. 프로젝트의 기존 testing-library 역할/랜드마크/이름 기반 검증 컨벤션을 따른다. 의존성 없는 공유 헬퍼(`findUnnamedInteractiveElements`)로 "이름 없는 상호작용 요소" 회귀를 막고, 각 라우트의 기존 테스트 파일에 a11y 어서션을 co-locate로 추가한다. 색 대비·모바일 레이아웃처럼 단위 테스트로 검증 불가한 항목은 문서화된 수동 게이트로 둔다. + +**Tech Stack:** React + Vite, TanStack Query, react-router-dom, Vitest + @testing-library/react, react-router `MemoryRouter`. + +**Scope note:** 이 plan은 엄브렐러(`docs/superpowers/specs/2026-05-31-post-admin-vnext-enhancement-umbrella-design.md`)의 **H 슬라이스 한정**이다. A/M/P는 H 완료 후 자기 plan을 따로 갖는다. + +--- + +## File Structure + +생성: + +- `docs/development/admin-hardening-baseline.md` — 베이스라인 체크리스트(게이트 SSOT 산출물). +- `front/shared/testing/accessibility-checks.ts` — 의존성 없는 a11y 가드 헬퍼. +- `front/shared/testing/accessibility-checks.test.ts` — 헬퍼 단위 테스트. + +수정: + +- `front/features/platform-admin/route/admin-shell-layout.tsx` — skip-link + 랜드마크 라벨 보강. +- `front/features/platform-admin/route/admin-shell-layout.test.tsx` — 랜드마크/skip-link 어서션. +- 각 admin 라우트의 **기존** 테스트 파일(아래 Task 5~13)에 a11y 어서션 추가. +- `front/features/host/ui/host-dashboard.tsx` 의 테스트(없으면 신규 co-located 테스트 생성). + +--- + +## Task 1: 베이스라인 체크리스트 문서 + +**Files:** +- Create: `docs/development/admin-hardening-baseline.md` + +- [ ] **Step 1: 체크리스트 문서 작성** + +아래 내용을 그대로 생성한다. + +````markdown +# Admin 하드닝 베이스라인 체크리스트 + +이 문서는 Post–Admin vNext 고도화 엄브렐러의 H 슬라이스 산출물이며, +A/M/P 슬라이스의 공통 게이트로 재사용된다. + +각 admin 라우트(+host dashboard)는 아래를 만족해야 한다. + +## 1. 접근성 (자동 검증 가능) +- [ ] 라우트 본문에 heading이 1개 이상 존재한다 (`getAllByRole("heading")`). +- [ ] 모든 상호작용 요소(`button`, `a[href]`, `[role=button]`, `[role=link]`)가 + 접근 가능한 이름(가시 텍스트 / `aria-label` / `aria-labelledby` / `title`)을 가진다. + → `findUnnamedInteractiveElements(container)` 가 빈 배열. +- [ ] error/empty 상태가 `role="status"` 또는 `role="alert"` 영역으로 노출된다. + +## 2. 접근성 (수동 검증) +- [ ] 키보드 Tab 순서가 시각 순서와 일치하고, 포커스 링이 보인다. +- [ ] admin shell 진입 시 본문으로 건너뛰는 skip-link가 동작한다. +- [ ] 텍스트/배경 색 대비가 WCAG AA(본문 4.5:1, 큰 텍스트 3:1)를 만족한다. + +## 3. 모바일 (수동 검증) +- [ ] 360px 폭에서 nav·테이블·카드가 가로 스크롤 없이 사용 가능하다. +- [ ] 터치 타깃이 충분한 크기를 가진다. + +## 4. Empty / 에러 카피 안전성 +- [ ] 데이터가 얇을 때 정직한 empty state를 보여준다(가짜 데이터 금지). +- [ ] 실패 카피가 provider raw error / private data / token-shaped 예시를 노출하지 않는다. + +## 5. 일관성 +- [ ] 카드·테이블·필터·badge 톤이 admin shell의 calm operating-ledger 톤과 일치한다. + +## 적용 대상 라우트 +today · health · clubs · clubs/:clubId · notifications · ai-ops · support · audit · analytics · (host dashboard) +```` + +- [ ] **Step 2: 커밋** + +```bash +git add docs/development/admin-hardening-baseline.md +git commit -m "docs: add admin hardening baseline checklist (H slice gate)" +``` + +--- + +## Task 2: 공유 a11y 가드 헬퍼 + +**Files:** +- Create: `front/shared/testing/accessibility-checks.ts` +- Test: `front/shared/testing/accessibility-checks.test.ts` + +- [ ] **Step 1: 실패하는 테스트 작성** + +`front/shared/testing/accessibility-checks.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; +import { findUnnamedInteractiveElements } from "./accessibility-checks"; + +function makeContainer(html: string): HTMLElement { + const el = document.createElement("div"); + el.innerHTML = html; + return el; +} + +describe("findUnnamedInteractiveElements", () => { + it("returns elements that have no accessible name", () => { + const container = makeContainer(` + + + 링크 + + `); + const unnamed = findUnnamedInteractiveElements(container); + expect(unnamed).toHaveLength(2); + expect(unnamed.every((el) => el.classList.contains("icon-only"))).toBe(true); + }); + + it("treats aria-label, aria-labelledby, and title as accessible names", () => { + const container = makeContainer(` + + + 재시도 + + `); + expect(findUnnamedInteractiveElements(container)).toEqual([]); + }); + + it("returns an empty array when there are no interactive elements", () => { + expect(findUnnamedInteractiveElements(makeContainer(`

    본문

    `))).toEqual([]); + }); +}); +``` + +- [ ] **Step 2: 테스트 실행해서 실패 확인** + +Run: `pnpm --dir front exec vitest run shared/testing/accessibility-checks.test.ts` +Expected: FAIL — `findUnnamedInteractiveElements` is not defined / module not found. + +- [ ] **Step 3: 최소 구현 작성** + +`front/shared/testing/accessibility-checks.ts`: + +```ts +const INTERACTIVE_SELECTOR = "button, a[href], [role='button'], [role='link']"; + +export function findUnnamedInteractiveElements(container: HTMLElement): HTMLElement[] { + const elements = Array.from( + container.querySelectorAll(INTERACTIVE_SELECTOR), + ); + return elements.filter((el) => { + const text = (el.textContent ?? "").trim(); + const ariaLabel = el.getAttribute("aria-label")?.trim(); + const labelledBy = el.getAttribute("aria-labelledby")?.trim(); + const title = el.getAttribute("title")?.trim(); + return !text && !ariaLabel && !labelledBy && !title; + }); +} +``` + +- [ ] **Step 4: 테스트 실행해서 통과 확인** + +Run: `pnpm --dir front exec vitest run shared/testing/accessibility-checks.test.ts` +Expected: PASS (3 tests). + +- [ ] **Step 5: 커밋** + +```bash +git add front/shared/testing/accessibility-checks.ts front/shared/testing/accessibility-checks.test.ts +git commit -m "test: add dependency-free unnamed-interactive-element a11y guard" +``` + +--- + +## Task 3: Admin shell 랜드마크 + skip-link 보강 + +**Files:** +- Modify: `front/features/platform-admin/route/admin-shell-layout.tsx` +- Test: `front/features/platform-admin/route/admin-shell-layout.test.tsx` + +- [ ] **Step 1: 실패하는 테스트 추가** + +`admin-shell-layout.test.tsx` 의 `describe` 블록 안에 아래 테스트를 추가한다(파일의 기존 render 헬퍼를 재사용; 없으면 기존 테스트가 쓰는 렌더 방식을 그대로 사용). + +```ts +it("exposes navigation and main landmarks with a skip link to main content", () => { + const { container } = renderShell(); // 파일의 기존 렌더 헬퍼명 사용 + expect(screen.getByRole("navigation", { name: "Admin 콘솔" })).toBeInTheDocument(); + const main = screen.getByRole("main"); + expect(main).toHaveAttribute("id", "admin-main"); + const skipLink = screen.getByRole("link", { name: "본문으로 건너뛰기" }); + expect(skipLink).toHaveAttribute("href", "#admin-main"); + expect(findUnnamedInteractiveElements(container)).toEqual([]); +}); +``` + +파일 상단 import에 추가: + +```ts +import { findUnnamedInteractiveElements } from "@/shared/testing/accessibility-checks"; +``` + +- [ ] **Step 2: 테스트 실행해서 실패 확인** + +Run: `pnpm --dir front exec vitest run features/platform-admin/route/admin-shell-layout.test.tsx` +Expected: FAIL — skip link / main id / nav accessible name not found. + +- [ ] **Step 3: shell 구현 수정** + +`admin-shell-layout.tsx` 의 `AdminShellLayoutInner` return을 아래처럼 보강한다(skip-link, main id, nav aria-label 추가). 변경 부분만 표시: + +```tsx + return ( +
    + + 본문으로 건너뛰기 + +
    + {/* ...기존 header 내용 그대로... */} +
    + +
    + +
    + +
    +
    + {/* ...onboarding modal 그대로... */} +
    + ); +``` + +주의: `AdminLayoutNav` 가 이미 내부에서 `