From b37509863101209a9517b3797a7078918dfd56c4 Mon Sep 17 00:00:00 2001 From: kws Date: Wed, 27 May 2026 09:22:54 +0900 Subject: [PATCH 001/161] docs: design admin operating console expansion --- ...next-operating-console-expansion-design.md | 578 ++++++++++++++++++ 1 file changed, 578 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-27-readmates-admin-vnext-operating-console-expansion-design.md diff --git a/docs/superpowers/specs/2026-05-27-readmates-admin-vnext-operating-console-expansion-design.md b/docs/superpowers/specs/2026-05-27-readmates-admin-vnext-operating-console-expansion-design.md new file mode 100644 index 00000000..979064cd --- /dev/null +++ b/docs/superpowers/specs/2026-05-27-readmates-admin-vnext-operating-console-expansion-design.md @@ -0,0 +1,578 @@ +# ReadMates Admin vNext Operating Console Expansion + +작성일: 2026-05-27 +상태: APPROVED DESIGN SPEC + +## 1. Context / Current State + +`/admin/health` is now the first product-grade admin operating entry point. It exposes a seven-card platform health snapshot and drills outbox and notification cards toward `/admin/notifications`. The current gap is the next operating step: an operator can detect outbox or notification risk, but `/admin/notifications` is still a coming-soon route. + +The current admin route catalog also has two other partially mature surfaces: + +- `/admin/clubs/:id` exists, but it is still a lightweight club metadata detail route rather than a per-club operating console. +- `/admin/support` exists, but it only directs operators to the club detail support tab. The existing support grant panel still depends on raw `granteeUserId` input in the selected-club context. + +This spec expands the admin operating console across those three surfaces in one product design. It does not replace the existing S1/S2 roadmap records. It turns the next operating run into one cohesive spec with independent phase gates. + +Source documents: + +- Current roadmap reset: `docs/superpowers/specs/2026-05-26-readmates-admin-vnext-operating-roadmap-reset-design.md` +- Historical umbrella roadmap: `docs/superpowers/specs/2026-05-25-readmates-admin-vnext-roadmap-design.md` +- Platform admin productization design: `docs/superpowers/specs/2026-05-20-readmates-platform-admin-productization-design.md` +- Architecture source of truth: `docs/development/architecture.md` +- Frontend guide: `docs/agents/front.md` +- Server guide: `docs/agents/server.md` +- Design guide: `docs/agents/design.md` +- Documentation guide: `docs/agents/docs.md` + +## 2. Unified Goal + +The goal is to make `/admin` feel like a real operating console after health detection: + +```text +상태 감지 +→ 원인 확인 +→ 조치 또는 지원 +→ 기록 준비 +``` + +The operator should be able to: + +- follow a red `/admin/health` outbox or notification card into an actionable route; +- inspect notification and outbox failure shape without seeing private message content; +- inspect a club's operational health without entering host-owned workflows; +- resolve a support target before granting temporary support access; +- leave action records that a later audit ledger can consume. + +Success is not measured by adding route count. Success means the admin surfaces answer the operator's next question without SSH, database inspection, or unsafe raw identifiers. + +## 3. Phase Order + +This is one design spec with three independent implementation phases. + +### Phase 1: S5 Notification / Outbox Operations + +Do this first. `/admin/health` already drills to `/admin/notifications`, so leaving that route as a placeholder breaks the operating flow. + +Phase 1 flips `/admin/notifications` to READY and adds an admin notification operations view backed by admin-scoped notification/outbox read models and replay actions. + +### Phase 2: S3 Club Operations Console + +Do this second. The club operations route should reuse notification health concepts from Phase 1 when presenting club-level delivery health. + +Phase 2 deepens `/admin/clubs/:id` into a read-mostly per-club operating view. It remains platform-admin owned and does not absorb host session or member management commands. + +### Phase 3: S4 Support Workbench + +Do this third. Support grants are already present, but the operator experience is still raw-id oriented. + +Phase 3 replaces the light `/admin/support` shell with a searchable support workbench. Grant creation requires selecting a resolved safe result and passing application validation. + +Each phase can ship separately with its own tests and CHANGELOG entry. Later implementation plans should keep this ordering unless current code proves a dependency has changed. + +## 4. Architecture + +### Frontend Boundary + +Keep the existing route-first dependency direction: + +```text +front/src/app -> front/src/pages -> front/features -> front/shared +``` + +All new admin work stays under `front/features/platform-admin` and follows the existing feature layout: + +```text +features/platform-admin/api +features/platform-admin/model +features/platform-admin/queries +features/platform-admin/route +features/platform-admin/ui +``` + +Rules: + +- Route modules own loader behavior, URL state, Query seeding, mutation coordination, and UI prop assembly. +- API modules own BFF calls and request/response contracts. +- Model modules own pure calculations and response-to-view transformations. +- UI modules render from props and callbacks only. +- READY route toggles stay in `front/features/platform-admin/model/admin-route-catalog.ts`. +- Lazy route wiring stays in `front/src/app/routes/admin.tsx`. + +Expected frontend surfaces: + +- `platform-admin-notifications-*` modules for `/admin/notifications`. +- `platform-admin-club-operations-*` modules for the operations snapshot behind `/admin/clubs/:id`. +- `platform-admin-support-*` modules for `/admin/support`. + +Shared widgets may be extracted only when a real cross-surface contract appears, such as masked identity chips, status chips, cursor ledgers, or audit-ready action summaries. + +### Server Boundary + +Do not create a generic admin monolith package for all three phases. Keep domain ownership clear. + +- Notification/outbox admin operations live in the existing `notification` feature with admin inbound adapters, application ports, application services, and outbound persistence adapters where needed. +- Club operations snapshot uses a feature-local read aggregation. It may coordinate existing club, notification, session, and AI read models, but it must not move host-owned commands into admin. +- Support search and grant validation build on the existing support grant flow in the `club` feature and platform admin authorization model. + +Application services must not throw Spring Web/HTTP types. Controllers map request/response shape only. Persistence stays behind outbound ports/adapters. Any admin write action introduced here must be audit-ready from its originating phase, even before `/admin/audit` ships. + +### Cross-Surface Data Ownership + +- S5 owns admin notification/outbox operation contracts. +- S3 owns `AdminClubOperationsSnapshot` and the KPI field naming that S8/S9 can later reuse. +- S4 owns support search, support grant ledger, and support action metadata contracts. +- S7 audit will later consume action records from S5 and S4 rather than retrofitting them. + +## 5. Phase 1: S5 Notification / Outbox Operations + +### Purpose + +Make the first health drill-down actionable. If `/admin/health` reports an outbox backlog, failed notification dispatch, or dead delivery risk, `/admin/notifications` should show the likely cause and safe next action. + +### Route UX + +`/admin/notifications` becomes a READY route with: + +- top summary strip for pending, failed, dead, publishing/sending, and recent success signals; +- tabs or segmented filters for outbox events, channel deliveries, failure clusters, manual dispatch audit, and club health; +- incoming drill-down context from `/admin/health`, such as `?focus=outbox_backlog` or `?focus=notification_dispatch_success`; +- cursor-based ledgers for large lists; +- dry-run replay preview panel; +- confirm replay panel with explicit reason and impact summary. + +The page should be dense and factual. It should not look like a marketing dashboard and should not over-explain normal UI behavior. + +### Data Contract + +Introduce admin-scoped response types around the existing notification pipeline: + +- `AdminNotificationOperationsSnapshot` + - `generatedAt` + - `outboxSummary` + - `deliverySummary` + - `relaySummary` + - `failureClusters` + - `clubHealth` + - `recentManualDispatches` +- `AdminNotificationOutboxEvent` + - `eventId` + - `club` + - `eventType` + - `source` + - `status` + - `attemptCount` + - `nextAttemptAt` + - `createdAt` + - `updatedAt` + - `safeErrorCode` + - `manualDispatch` +- `AdminNotificationDelivery` + - `deliveryId` + - `eventId` + - `club` + - `channel` + - `status` + - `maskedRecipient` + - `attemptCount` + - `createdAt` + - `updatedAt` + - `safeErrorCode` +- `AdminNotificationReplayPreview` + - `previewId` + - `selectionHash` + - `matchedCount` + - `excludedCount` + - `estimatedByStatus` + - `warnings` + - `expiresAt` + +Use cursor page shape for ledgers: `{ "items": [...], "nextCursor": string | null }`. + +### Actions + +Replay must be two-step: + +1. `POST /api/admin/notifications/replay-preview` + - validates filters, role, and replayable statuses; + - returns a short-lived preview id and selection hash; + - performs no state mutation beyond preview/audit preparation. +2. `POST /api/admin/notifications/replay-confirm` + - requires `previewId`, `selectionHash`, and non-blank reason; + - revalidates role, preview expiry, replayable statuses, and selection stability; + - records an audit-ready action; + - updates only rows eligible for replay under the existing notification state machine. + +No one-step replay action is allowed. + +### Permissions + +- OWNER: read all admin notification operations and perform replay. +- OPERATOR: read all admin notification operations and perform replay. Replay is an operational recovery action, not a support grant. +- SUPPORT: read safe summaries and ledgers, no replay. +- Member/guest: no access. + +If the team later wants replay to become OWNER-only, that is a product policy change and needs a spec amendment before implementation. + +### Public Safety + +Never expose: + +- full email body; +- raw SMTP/provider error; +- raw recipient lists; +- private member notes or feedback content; +- tokens, credentials, domains, deployment identifiers, or internal hostnames. + +Allowed examples and UI values must use sanitized club names and placeholder identities. + +### Acceptance Gate + +S5 is ready when: + +- `/admin/notifications` is READY and the route catalog/nav reflect that status. +- A health card drill-down lands on the relevant notification/outbox view state. +- Failure clusters explain cause categories without raw provider details. +- Replay cannot run without dry-run preview, confirm, reason, permission checks, and audit-ready metadata. +- Unit, controller, route, and E2E tests cover the happy path and unsafe request rejection. + +## 6. Phase 2: S3 Club Operations Console + +### Purpose + +Make `/admin/clubs/:id` answer: "What is happening with this club, what needs attention, and where should I go next?" + +The page should be a platform-admin operations snapshot. It must not become a host dashboard clone. + +### Route UX + +Expand `/admin/clubs/:id` into: + +- club identity and readiness header; +- operations snapshot sections; +- member activity aggregate; +- session lifecycle aggregate; +- notification health summary; +- AI usage/cost summary; +- platform-owned public readiness and domain status; +- host-app deep links for host-owned work. + +The page may retain existing public metadata and readiness affordances, but host-owned commands stay in the host app. Admin can diagnose and coordinate, not perform attendance, RSVP, note, session editing, or publication-body operations. + +### Data Contract + +Introduce `AdminClubOperationsSnapshot`: + +- `schema` +- `generatedAt` +- `club` + - `clubId` + - `slug` + - `name` + - `status` + - `publicVisibility` +- `readiness` + - checklist items + - blocking reasons + - next action +- `memberActivity` + - active count + - dormant count + - pending/viewer count + - host count + - aggregate only by default +- `sessionProgress` + - upcoming count + - current/open count + - closed count + - published record count + - stale or incomplete record count +- `notificationHealth` + - pending/failed/dead delivery counts + - last successful dispatch at + - failure cluster summary + - link to `/admin/notifications?clubId=...` +- `aiUsage` + - active jobs + - failed jobs in recent window + - stale candidates + - cost estimate aggregate + - link to `/admin/ai-ops?clubId=...` +- `safeLinks` + - host app links where applicable + +KPI field naming should be stable enough for S8 analytics and S9 host-surface reuse. If a metric is not meaningful with local seed data, return an honest empty or insufficient-data state rather than a mock value. + +### Permissions + +- OWNER/OPERATOR/SUPPORT may read safe aggregate operations snapshots. +- OWNER/OPERATOR retain existing platform-owned mutation affordances where current policy allows them. +- SUPPORT remains read-mostly and must not receive new mutation authority from this phase. + +Any sensitive reveal beyond aggregate counts is out of scope for this phase. + +### Public Safety + +Default to aggregate and masked output. Do not expose: + +- member private notes; +- feedback document body; +- RSVP individual details; +- raw email lists; +- generated AI result JSON; +- provider raw response or transcript. + +### Acceptance Gate + +S3 is ready when: + +- `/admin/clubs/:id` renders a complete operations snapshot for seeded/local-safe data. +- Empty and insufficient-data states are explicit and honest. +- Host-owned operations are represented as links or guidance, not duplicated admin commands. +- `AdminClubOperationsSnapshot` has contract tests on server and matching frontend types. +- Route tests cover selected club, missing club, permission posture, loading, and panel failure states. + +## 7. Phase 3: S4 Support Workbench + +### Purpose + +Replace raw UUID support grant work with a safe operator workflow: + +```text +search +→ resolve safe result +→ choose club/scope/expiry/reason +→ create grant +→ revoke/history +``` + +This improves OWNER usability and reduces support mistakes without turning admin into a general member-management console. + +### Route UX + +`/admin/support` becomes a READY workbench: + +- search input accepts email, display-name text, or UUID; +- results show safe identity rows; +- selecting a result opens grant context; +- grant creation form is unavailable until a result is selected; +- active grants and recent grant history are visible; +- revoke shows inline state and safe failure copy. + +If the operator came from a club page, `/admin/support?clubId=...` may preselect the club context. The workbench should still require a resolved grantee result before grant creation. + +### Data Contract + +Introduce support workbench contracts: + +- `AdminSupportSearchResult` + - `subjectId` + - `displayName` + - `maskedEmail` + - `kind` + - `platformAdminRole` + - `platformAdminStatus` + - `clubMembershipSummary` + - `grantEligible` + - `grantBlockedReason` +- `AdminSupportGrantLedgerItem` + - `grantId` + - `club` + - `grantee` + - `scope` + - `reason` + - `expiresAt` + - `createdAt` + - `revokedAt` + - `status` + - `createdByRole` +- `AdminSupportGrantRequest` + - `clubId` + - `granteeSubjectId` + - `scope` + - `reason` + - `expiresAt` + +Search should be exact or bounded enough to avoid a broad member directory. If fuzzy name search is implemented, it must have tight result limits and masked output. + +### Application Validation + +Grant creation must validate: + +- actor is OWNER; +- selected grantee exists; +- grantee is an active platform admin; +- selected club exists and is in a grant-eligible state; +- scope is supported; +- reason is non-blank; +- expiry is in the future; +- expiry does not exceed the configured maximum duration; +- duplicate active grant behavior is explicit, either rejected or updated through a documented action. + +Revoke must validate: + +- actor is OWNER; +- grant exists; +- grant is active; +- action writes audit-ready metadata. + +### Permissions + +- OWNER: search, create, revoke, view ledger. +- OPERATOR: search and view the safe ledger, no create/revoke. +- SUPPORT: view limited safe support context for assigned work, no create/revoke. +- Member/guest: no access. + +If the team later wants support search to become OWNER-only, that is a product policy change and needs a spec amendment before implementation. + +### Public Safety + +Search output must be masked and purpose-bound. Do not expose: + +- raw email unless the current authenticated role already has a documented reason to see it; +- member private data; +- support transcripts; +- broad member directory exports; +- private operational notes. + +### Acceptance Gate + +S4 is ready when: + +- raw UUID-only grant creation is gone from the primary UI. +- grant creation cannot proceed without selecting a resolved search result. +- each validation rule has server tests. +- frontend tests prove search, selection, disabled grant state, create, revoke, and safe errors. +- E2E covers search -> grant -> revoke with public-safe fixture data. + +## 8. Shared Permission And Public-Safety Rules + +Role posture: + +| Surface | OWNER | OPERATOR | SUPPORT | +| --- | --- | --- | --- | +| `/admin/notifications` read | allowed | allowed | safe read | +| `/admin/notifications` replay | allowed | allowed | denied | +| `/admin/clubs/:id` operations snapshot | allowed | allowed | safe read | +| `/admin/clubs/:id` platform mutations | existing policy | existing policy | denied | +| `/admin/support` search | allowed | allowed | limited safe context | +| `/admin/support` create/revoke grant | allowed | denied | denied | + +UI affordances are not authorization. Server-side application services must enforce each write boundary. + +Public repository safety applies to all implementation artifacts: + +- no real member data; +- no private domains; +- no deployment state; +- no local absolute paths; +- no OCIDs; +- no secrets or token-shaped examples; +- no raw provider, SMTP, SQL, or stack-trace details. + +Fixtures should use sanitized names and placeholder addresses such as `host@example.com` only where an email-shaped value is necessary. + +## 9. Error Handling + +Separate failure surfaces: + +- Route loader failure: route-level error boundary. +- Snapshot panel failure: panel-level fallback while the rest of the route stays usable. +- Replay preview failure: preview panel inline error. +- Replay confirm failure: confirm panel inline error and no optimistic success. +- Club operations partial failure: section-level unavailable states. +- Support search failure: search panel inline error. +- Support grant create/revoke failure: grant panel inline error and current ledger remains visible. + +Safe error copy must avoid raw backend details. It should state what failed, whether the operator can retry, and what role or input condition blocks the action. + +Invalid or stale timestamps must not render `NaN` text. Empty states should be explicit, especially for local fixture data. + +## 10. Testing And Release Gates + +### Phase 1: S5 + +Minimum checks: + +- server service tests for filters, dry-run replay, confirm replay, failure clustering, permission denial, and unsafe request rejection; +- controller tests for masked response shape and validation errors; +- frontend model/query/route/UI tests for filter state, drill context, disabled/enabled replay, confirm state, and safe failure copy; +- Playwright E2E from `/admin/health` outbox card to `/admin/notifications`; +- `pnpm --dir front lint`; +- `pnpm --dir front test`; +- `pnpm --dir front build`; +- targeted server unit tests or `./server/gradlew -p server unitTest`; +- `git diff --check`. + +### Phase 2: S3 + +Minimum checks: + +- server contract tests for `AdminClubOperationsSnapshot`; +- service tests for aggregate calculations and masking rules; +- frontend route/UI tests for selected club, missing club, permission posture, loading, empty, and partial error states; +- Playwright E2E for club operations snapshot; +- frontend lint/test/build; +- targeted server unit tests; +- `architectureTest` if new cross-feature dependencies are introduced; +- `git diff --check`. + +### Phase 3: S4 + +Minimum checks: + +- server tests for lookup behavior and every grant validation rule; +- controller tests for masked search results, create, revoke, permission denial, and safe errors; +- frontend tests proving raw UUID-only grant flow is gone; +- frontend tests for search -> select -> create -> revoke; +- Playwright E2E for search -> grant -> revoke; +- frontend lint/test/build; +- targeted server unit tests; +- `git diff --check`. + +### Integrated Release Readiness + +Before shipping all three phases together, run: + +- `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` if boundaries changed +- `./scripts/build-public-release-candidate.sh` +- `./scripts/public-release-check.sh .tmp/public-release-candidate` + +Each phase should add a concrete CHANGELOG `Unreleased` entry when behavior ships. + +## 11. Non-Goals + +- Do not build `/admin/audit` in this spec. Only produce audit-ready action metadata for S5 and S4. +- Do not build `/admin/analytics` in this spec. S3 should produce reusable KPI contracts, but S8 remains separate. +- Do not move host session editing, attendance, RSVP, note, publication-body editing, or manual host workflows into `/admin`. +- Do not expose full email bodies, raw recipient lists, raw SMTP/provider errors, transcripts, generated AI result JSON, private notes, or feedback document bodies. +- Do not add provider key management, Grafana embedding, Alertmanager webhook management, or external incident-tool integration. +- Do not publish raw Graphify output or local operational artifacts. + +## 12. Risks And Mitigations + +| Risk | Phase | Mitigation | +| --- | --- | --- | +| S5 replay changes notification state incorrectly | S5 | Two-step preview/confirm, selection hash, status revalidation, service tests | +| Operator sees private notification content | S5 | Masked DTOs, no body fields, controller tests for response shape | +| Club operations becomes a host dashboard clone | S3 | Read-mostly snapshot, host-owned commands remain deep links | +| KPI fields drift before S8/S9 reuse | S3 | Contract tests and explicit `AdminClubOperationsSnapshot` owner | +| Support search becomes a member directory | S4 | Bounded search, masked output, purpose-bound UI | +| Raw UUID grant flow survives in another primary path | S4 | UI tests asserting search-result selection is required | +| Audit route arrives later and cannot explain old actions | S5/S4 | Originating phases write audit-ready metadata immediately | +| Public repo safety regresses through fixtures or docs | All | Use sanitized examples and targeted public-safety scans | + +## 13. Plan Handoff + +After this spec is reviewed, write one implementation plan for the integrated operating console expansion. The plan should keep phase boundaries explicit and executable: + +1. S5 `/admin/notifications` READY route and admin notification/outbox operations. +2. S3 `/admin/clubs/:id` operations snapshot. +3. S4 `/admin/support` searchable support workbench. + +The plan may split phases into separate worker tasks or commits, but it should preserve this spec as the single design source for the next admin vNext expansion. From 4febfab51c602de7f5a957b82588550fba33139a Mon Sep 17 00:00:00 2001 From: kws Date: Wed, 27 May 2026 09:36:44 +0900 Subject: [PATCH 002/161] docs: plan admin operating console expansion --- ...admin-vnext-operating-console-expansion.md | 1968 +++++++++++++++++ 1 file changed, 1968 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-27-readmates-admin-vnext-operating-console-expansion.md 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. From 89b36404845fe22f005a7ca8dc8ffb79e45a6e7a Mon Sep 17 00:00:00 2001 From: kws Date: Wed, 27 May 2026 10:45:55 +0900 Subject: [PATCH 003/161] feat: expand admin operating console --- CHANGELOG.md | 12 + docs/development/architecture.md | 10 +- docs/development/server-state-migration.md | 8 +- .../api/platform-admin-club-operations-api.ts | 10 + .../api/platform-admin-contracts.ts | 22 + .../api/platform-admin-notifications-api.ts | 63 ++ .../api/platform-admin-support-api.ts | 45 ++ .../model/admin-route-catalog.test.ts | 1 + .../model/admin-route-catalog.ts | 13 +- .../platform-admin-club-operations-model.ts | 48 ++ .../platform-admin-notifications-model.ts | 117 ++++ .../model/platform-admin-support-model.ts | 35 + .../platform-admin-club-operations-queries.ts | 14 + .../platform-admin-notifications-queries.ts | 67 ++ .../queries/platform-admin-support-queries.ts | 46 ++ .../route/admin-club-detail-data.ts | 2 + .../route/admin-club-detail-route.test.tsx | 15 +- .../route/admin-club-detail-route.tsx | 15 +- .../route/admin-health-route.test.tsx | 4 +- .../route/admin-notifications-data.ts | 17 + .../route/admin-notifications-route.test.tsx | 50 ++ .../route/admin-notifications-route.tsx | 95 +++ .../route/admin-support-data.ts | 16 + .../route/admin-support-route.test.tsx | 29 +- .../route/admin-support-route.tsx | 97 ++- .../ui/admin-breadcrumb.test.tsx | 2 +- .../ui/admin-club-operations-page.test.tsx | 48 ++ .../ui/admin-club-operations-page.tsx | 102 +++ .../ui/admin-health-card.test.tsx | 7 +- .../ui/admin-health-grid.test.tsx | 4 +- .../ui/admin-layout-nav.test.tsx | 4 +- .../ui/admin-notifications-page.test.tsx | 121 ++++ .../ui/admin-notifications-page.tsx | 188 ++++++ .../ui/admin-support-workbench.test.tsx | 95 +++ .../ui/admin-support-workbench.tsx | 141 ++++ front/src/app/routes/admin.tsx | 24 +- front/src/styles/globals.css | 305 +++++++++ front/tests/e2e/admin-club-operations.spec.ts | 96 +++ front/tests/e2e/admin-health.spec.ts | 27 +- front/tests/e2e/admin-notifications.spec.ts | 143 ++++ front/tests/e2e/admin-shell.spec.ts | 6 +- front/tests/e2e/admin-support.spec.ts | 119 ++++ ...NotificationDispatchSuccessCardProvider.kt | 2 +- .../OutboxBacklogHealthCardProvider.kt | 2 +- .../infrastructure/security/SecurityConfig.kt | 4 + .../PlatformAdminClubOperationsController.kt | 52 ++ .../in/web/PlatformAdminErrorHandler.kt | 8 + ...PlatformAdminSupportWorkbenchController.kt | 144 ++++ .../JdbcAdminClubOperationsAdapter.kt | 207 ++++++ .../JdbcAdminSupportSearchAdapter.kt | 101 +++ .../JdbcSupportAccessGrantAdapter.kt | 97 ++- .../application/PlatformAdminException.kt | 6 + .../model/AdminClubOperationsModels.kt | 72 ++ .../application/model/AdminSupportModels.kt | 41 ++ .../port/in/PlatformAdminUseCases.kt | 8 + .../port/in/SupportAccessGrantUseCases.kt | 16 + .../out/AdminClubOperationsSnapshotPort.kt | 8 + .../application/port/out/AdminSupportPorts.kt | 30 + .../service/AdminClubOperationsService.kt | 22 + .../service/AdminSupportWorkbenchService.kt | 38 ++ .../service/SupportAccessGrantService.kt | 18 + .../in/web/NotificationErrorHandler.kt | 8 +- .../PlatformAdminNotificationController.kt | 95 +++ .../web/PlatformAdminNotificationWebDtos.kt | 216 ++++++ .../JdbcAdminNotificationOperationsAdapter.kt | 620 ++++++++++++++++++ .../NotificationApplicationException.kt | 4 + .../AdminNotificationOperationsModels.kt | 138 ++++ .../port/in/NotificationUseCases.kt | 35 + .../AdminNotificationOperationsReadPort.kt | 22 + .../port/out/AdminNotificationReplayPort.kt | 41 ++ .../AdminNotificationOperationsService.kt | 206 ++++++ ...35__admin_notification_replay_previews.sql | 16 + ...ficationDispatchSuccessCardProviderTest.kt | 2 +- .../OutboxBacklogHealthCardProviderTest.kt | 2 +- ...atformAdminClubOperationsControllerTest.kt | 100 +++ ...formAdminSupportWorkbenchControllerTest.kt | 137 ++++ .../api/SupportAccessGrantControllerTest.kt | 32 +- .../service/AdminClubOperationsServiceTest.kt | 72 ++ .../AdminSupportWorkbenchServiceTest.kt | 74 +++ .../service/SupportAccessGrantServiceTest.kt | 160 +++++ ...cAdminNotificationOperationsAdapterTest.kt | 179 +++++ ...PlatformAdminNotificationControllerTest.kt | 205 ++++++ .../AdminNotificationOperationsServiceTest.kt | 308 +++++++++ 83 files changed, 5762 insertions(+), 69 deletions(-) create mode 100644 front/features/platform-admin/api/platform-admin-club-operations-api.ts create mode 100644 front/features/platform-admin/api/platform-admin-notifications-api.ts create mode 100644 front/features/platform-admin/api/platform-admin-support-api.ts create mode 100644 front/features/platform-admin/model/platform-admin-club-operations-model.ts create mode 100644 front/features/platform-admin/model/platform-admin-notifications-model.ts create mode 100644 front/features/platform-admin/model/platform-admin-support-model.ts create mode 100644 front/features/platform-admin/queries/platform-admin-club-operations-queries.ts create mode 100644 front/features/platform-admin/queries/platform-admin-notifications-queries.ts create mode 100644 front/features/platform-admin/queries/platform-admin-support-queries.ts create mode 100644 front/features/platform-admin/route/admin-notifications-data.ts create mode 100644 front/features/platform-admin/route/admin-notifications-route.test.tsx create mode 100644 front/features/platform-admin/route/admin-notifications-route.tsx create mode 100644 front/features/platform-admin/route/admin-support-data.ts create mode 100644 front/features/platform-admin/ui/admin-club-operations-page.test.tsx create mode 100644 front/features/platform-admin/ui/admin-club-operations-page.tsx create mode 100644 front/features/platform-admin/ui/admin-notifications-page.test.tsx create mode 100644 front/features/platform-admin/ui/admin-notifications-page.tsx create mode 100644 front/features/platform-admin/ui/admin-support-workbench.test.tsx create mode 100644 front/features/platform-admin/ui/admin-support-workbench.tsx create mode 100644 front/tests/e2e/admin-club-operations.spec.ts create mode 100644 front/tests/e2e/admin-notifications.spec.ts create mode 100644 front/tests/e2e/admin-support.spec.ts create mode 100644 server/src/main/kotlin/com/readmates/club/adapter/in/web/PlatformAdminClubOperationsController.kt create mode 100644 server/src/main/kotlin/com/readmates/club/adapter/in/web/PlatformAdminSupportWorkbenchController.kt create mode 100644 server/src/main/kotlin/com/readmates/club/adapter/out/persistence/JdbcAdminClubOperationsAdapter.kt create mode 100644 server/src/main/kotlin/com/readmates/club/adapter/out/persistence/JdbcAdminSupportSearchAdapter.kt create mode 100644 server/src/main/kotlin/com/readmates/club/application/model/AdminClubOperationsModels.kt create mode 100644 server/src/main/kotlin/com/readmates/club/application/model/AdminSupportModels.kt create mode 100644 server/src/main/kotlin/com/readmates/club/application/port/out/AdminClubOperationsSnapshotPort.kt create mode 100644 server/src/main/kotlin/com/readmates/club/application/port/out/AdminSupportPorts.kt create mode 100644 server/src/main/kotlin/com/readmates/club/application/service/AdminClubOperationsService.kt create mode 100644 server/src/main/kotlin/com/readmates/club/application/service/AdminSupportWorkbenchService.kt create mode 100644 server/src/main/kotlin/com/readmates/notification/adapter/in/web/PlatformAdminNotificationController.kt create mode 100644 server/src/main/kotlin/com/readmates/notification/adapter/in/web/PlatformAdminNotificationWebDtos.kt create mode 100644 server/src/main/kotlin/com/readmates/notification/adapter/out/persistence/JdbcAdminNotificationOperationsAdapter.kt create mode 100644 server/src/main/kotlin/com/readmates/notification/application/model/AdminNotificationOperationsModels.kt create mode 100644 server/src/main/kotlin/com/readmates/notification/application/port/out/AdminNotificationOperationsReadPort.kt create mode 100644 server/src/main/kotlin/com/readmates/notification/application/port/out/AdminNotificationReplayPort.kt create mode 100644 server/src/main/kotlin/com/readmates/notification/application/service/AdminNotificationOperationsService.kt create mode 100644 server/src/main/resources/db/mysql/migration/V35__admin_notification_replay_previews.sql create mode 100644 server/src/test/kotlin/com/readmates/club/api/PlatformAdminClubOperationsControllerTest.kt create mode 100644 server/src/test/kotlin/com/readmates/club/api/PlatformAdminSupportWorkbenchControllerTest.kt create mode 100644 server/src/test/kotlin/com/readmates/club/application/service/AdminClubOperationsServiceTest.kt create mode 100644 server/src/test/kotlin/com/readmates/club/application/service/AdminSupportWorkbenchServiceTest.kt create mode 100644 server/src/test/kotlin/com/readmates/club/application/service/SupportAccessGrantServiceTest.kt create mode 100644 server/src/test/kotlin/com/readmates/notification/adapter/out/persistence/JdbcAdminNotificationOperationsAdapterTest.kt create mode 100644 server/src/test/kotlin/com/readmates/notification/api/PlatformAdminNotificationControllerTest.kt create mode 100644 server/src/test/kotlin/com/readmates/notification/application/service/AdminNotificationOperationsServiceTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index f53ad05f..4e15440d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ 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`로 토글하는 한 줄 변경으로 자기 자리를 채울 수 있습니다. +- **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 기반 코드베이스 탐색 워크플로를 도입했습니다. @@ -35,6 +36,17 @@ 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:** 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=...`. + +### Deployment Notes + +- **DB migration**: Flyway V35 (`admin_notification_replay_previews`) creates the admin notification replay preview/audit table. It is additive; rollback leaves unused rows/table until a later cleanup migration. +- **배포 순서**: server image를 먼저 배포해 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만 보이는지 확인합니다. ## v1.11.0 - 2026-05-18 diff --git a/docs/development/architecture.md b/docs/development/architecture.md index a7abad08..09ddbd3d 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/health`, `/admin/notifications`, `/admin/clubs/:clubId`, `/admin/support`, `/admin/ai-ops` | platform admin | 클럽 생성, 클럽 목록 확인, 공개/비공개 상태 관리, 공개 소개 정보 관리, 등록형 domain alias 요청과 상태 확인, 첫 호스트 온보딩 상태 확인, 운영 health와 알림 outbox/delivery 상태 확인, 클럽 운영 readiness 집계, 제한된 support access grant 관리, AI job 운영 조회. 세션/멤버/알림 발송 같은 클럽 내부 운영은 기본적으로 호스트 앱 책임이고, platform admin 표면은 aggregate/read-only 진단과 감사 가능한 복구 작업만 다룹니다. | ## 프런트엔드 route-first 경계 @@ -102,6 +102,8 @@ 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 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을 모두 만족해야 합니다. + Public cache, 멤버 알림 deep link, host 알림 운영 ledger는 club id 또는 club slug를 포함해 scope를 나눕니다. 공개 cache key는 club id 기준으로 분리하고, 알림 link는 `/clubs/:slug/app/**` canonical path를 사용해 로그인 후에도 원래 클럽 화면으로 복귀합니다. ## 서버 내부 구조 @@ -116,7 +118,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입니다. 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 +126,8 @@ 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입니다. + ```text notification adapter.in.web / adapter.in.scheduler / adapter.in.kafka @@ -296,7 +300,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`입니다. `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를 사용합니다. diff --git a/docs/development/server-state-migration.md b/docs/development/server-state-migration.md index 3adc442f..13f76823 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,9 @@ 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로 분리합니다. 각 migration은 UI 컴포넌트가 API를 직접 호출하지 않는다는 route-first 경계를 유지해야 합니다. @@ -27,6 +30,9 @@ 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 ## 패턴 - query: `features//queries/-queries.ts` 에 `queryOptions` + `useXxxMutation` export diff --git a/front/features/platform-admin/api/platform-admin-club-operations-api.ts b/front/features/platform-admin/api/platform-admin-club-operations-api.ts new file mode 100644 index 00000000..24738b02 --- /dev/null +++ b/front/features/platform-admin/api/platform-admin-club-operations-api.ts @@ -0,0 +1,10 @@ +import { readmatesFetch } from "@/shared/api/client"; +import type { AdminClubOperationsSnapshot } from "@/features/platform-admin/model/platform-admin-club-operations-model"; + +export function fetchAdminClubOperationsSnapshot(clubId: string) { + return readmatesFetch( + `/api/admin/clubs/${encodeURIComponent(clubId)}/operations`, + undefined, + { clubSlug: undefined }, + ); +} diff --git a/front/features/platform-admin/api/platform-admin-contracts.ts b/front/features/platform-admin/api/platform-admin-contracts.ts index 01fc5ec8..256fe48b 100644 --- a/front/features/platform-admin/api/platform-admin-contracts.ts +++ b/front/features/platform-admin/api/platform-admin-contracts.ts @@ -1,3 +1,25 @@ +export type { + AdminSupportGrantLedgerItem, + AdminSupportGrantRequest, + AdminSupportSearchResult, +} from "@/features/platform-admin/model/platform-admin-support-model"; + +export type { + AdminClubOperationsSnapshot, +} from "@/features/platform-admin/model/platform-admin-club-operations-model"; + +export type { + AdminNotificationDelivery, + AdminNotificationFilters, + AdminNotificationOperationsSnapshot, + AdminNotificationOutboxEvent, + AdminNotificationReplayConfirmRequest, + AdminNotificationReplayConfirmResult, + AdminNotificationReplayFilter, + AdminNotificationReplayPreview, + AdminNotificationStatusSummary, +} from "@/features/platform-admin/model/platform-admin-notifications-model"; + export type { PlatformAdminRole, SupportAccessGrantScope, diff --git a/front/features/platform-admin/api/platform-admin-notifications-api.ts b/front/features/platform-admin/api/platform-admin-notifications-api.ts new file mode 100644 index 00000000..af3ba285 --- /dev/null +++ b/front/features/platform-admin/api/platform-admin-notifications-api.ts @@ -0,0 +1,63 @@ +import { readmatesFetch } from "@/shared/api/client"; +import type { CursorPage } from "@/shared/query/cursor-pagination"; +import type { + AdminNotificationDelivery, + AdminNotificationFilters, + AdminNotificationOperationsSnapshot, + AdminNotificationOutboxEvent, + AdminNotificationReplayConfirmRequest, + AdminNotificationReplayConfirmResult, + AdminNotificationReplayFilter, + AdminNotificationReplayPreview, +} from "@/features/platform-admin/model/platform-admin-notifications-model"; + +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( + "/api/admin/notifications/replay-confirm", + { method: "POST", body: JSON.stringify(request) }, + { clubSlug: undefined }, + ); +} diff --git a/front/features/platform-admin/api/platform-admin-support-api.ts b/front/features/platform-admin/api/platform-admin-support-api.ts new file mode 100644 index 00000000..a7867324 --- /dev/null +++ b/front/features/platform-admin/api/platform-admin-support-api.ts @@ -0,0 +1,45 @@ +import { readmatesFetch } from "@/shared/api/client"; +import type { SupportAccessGrantResponse } from "@/features/platform-admin/api/platform-admin-contracts"; +import type { + AdminSupportGrantLedgerItem, + AdminSupportGrantRequest, + AdminSupportSearchResult, +} from "@/features/platform-admin/model/platform-admin-support-model"; + +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 }, + ); +} diff --git a/front/features/platform-admin/model/admin-route-catalog.test.ts b/front/features/platform-admin/model/admin-route-catalog.test.ts index 40aefea6..6cb8c09b 100644 --- a/front/features/platform-admin/model/admin-route-catalog.test.ts +++ b/front/features/platform-admin/model/admin-route-catalog.test.ts @@ -56,6 +56,7 @@ describe("ADMIN_ROUTES catalog", () => { "ai-ops", "clubs", "health", + "notifications", "support", "today", ]); diff --git a/front/features/platform-admin/model/admin-route-catalog.ts b/front/features/platform-admin/model/admin-route-catalog.ts index 7d11abeb..d0e86ee6 100644 --- a/front/features/platform-admin/model/admin-route-catalog.ts +++ b/front/features/platform-admin/model/admin-route-catalog.ts @@ -66,19 +66,8 @@ export const ADMIN_ROUTES: ReadonlyArray = [ group: "ops", groupLabel: "운영", slice: "S5", - status: "coming_soon", + status: "ready", requiredCapability: "view_notifications", - comingSoon: { - title: "알림/Outbox 운영", - summary: "relay lag, dead letter, replay, 실패 cluster, 클럽별 성공률을 한 화면에서 봅니다.", - bullets: [ - "Outbox state ledger와 dead letter 목록", - "수동 replay (dry-run → confirm 두 단계)", - "발송 실패 cluster (errorCode 그룹)", - "호스트 manual notification audit cross-cut", - ], - docHref: `${UMBRELLA_DOC}#s5--알림outbox-운영`, - }, }, { path: "ai-ops", diff --git a/front/features/platform-admin/model/platform-admin-club-operations-model.ts b/front/features/platform-admin/model/platform-admin-club-operations-model.ts new file mode 100644 index 00000000..91800c23 --- /dev/null +++ b/front/features/platform-admin/model/platform-admin-club-operations-model.ts @@ -0,0 +1,48 @@ +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"; + }>; +}; diff --git a/front/features/platform-admin/model/platform-admin-notifications-model.ts b/front/features/platform-admin/model/platform-admin-notifications-model.ts new file mode 100644 index 00000000..dfee8e7f --- /dev/null +++ b/front/features/platform-admin/model/platform-admin-notifications-model.ts @@ -0,0 +1,117 @@ +export type AdminNotificationStatusSummary = { + pending: number; + active: number; + failed: number; + dead: number; + sentOrPublishedLast24h: number; +}; + +export type AdminNotificationClubRef = { + clubId: string; + slug: string; + name: string; +}; + +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: AdminNotificationClubRef; + 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: AdminNotificationClubRef; + 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 type AdminNotificationReplayConfirmResult = { + replayedCount: number; + skippedCount: number; + selectionHash: string; +}; diff --git a/front/features/platform-admin/model/platform-admin-support-model.ts b/front/features/platform-admin/model/platform-admin-support-model.ts new file mode 100644 index 00000000..b1957bc9 --- /dev/null +++ b/front/features/platform-admin/model/platform-admin-support-model.ts @@ -0,0 +1,35 @@ +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; +}; diff --git a/front/features/platform-admin/queries/platform-admin-club-operations-queries.ts b/front/features/platform-admin/queries/platform-admin-club-operations-queries.ts new file mode 100644 index 00000000..2b119a8a --- /dev/null +++ b/front/features/platform-admin/queries/platform-admin-club-operations-queries.ts @@ -0,0 +1,14 @@ +import { queryOptions } from "@tanstack/react-query"; +import { fetchAdminClubOperationsSnapshot } from "@/features/platform-admin/api/platform-admin-club-operations-api"; + +export const platformAdminClubOperationsKeys = { + all: ["platform-admin", "club-operations"] as const, + snapshot: (clubId: string) => [...platformAdminClubOperationsKeys.all, clubId] as const, +} as const; + +export function platformAdminClubOperationsQuery(clubId: string) { + return queryOptions({ + queryKey: platformAdminClubOperationsKeys.snapshot(clubId), + queryFn: () => fetchAdminClubOperationsSnapshot(clubId), + }); +} diff --git a/front/features/platform-admin/queries/platform-admin-notifications-queries.ts b/front/features/platform-admin/queries/platform-admin-notifications-queries.ts new file mode 100644 index 00000000..b9f872d9 --- /dev/null +++ b/front/features/platform-admin/queries/platform-admin-notifications-queries.ts @@ -0,0 +1,67 @@ +import { queryOptions, useMutation, useQueryClient } from "@tanstack/react-query"; +import { + confirmAdminNotificationReplay, + fetchAdminNotificationDeliveries, + fetchAdminNotificationEvents, + fetchAdminNotificationSnapshot, + previewAdminNotificationReplay, +} from "@/features/platform-admin/api/platform-admin-notifications-api"; +import type { + AdminNotificationFilters, + AdminNotificationReplayConfirmRequest, + AdminNotificationReplayFilter, +} from "@/features/platform-admin/model/platform-admin-notifications-model"; + +function normalizeFilters(filters: AdminNotificationFilters = {}) { + return { + clubId: filters.clubId ?? null, + eventStatus: filters.eventStatus ?? null, + deliveryStatus: filters.deliveryStatus ?? null, + channel: filters.channel ?? null, + cursor: filters.cursor ?? null, + }; +} + +export const platformAdminNotificationsKeys = { + all: ["platform-admin", "notifications"] as const, + snapshot: () => [...platformAdminNotificationsKeys.all, "snapshot"] as const, + events: (filters?: AdminNotificationFilters) => + [...platformAdminNotificationsKeys.all, "events", normalizeFilters(filters)] as const, + deliveries: (filters?: AdminNotificationFilters) => + [...platformAdminNotificationsKeys.all, "deliveries", normalizeFilters(filters)] as const, +} as const; + +export function platformAdminNotificationSnapshotQuery() { + return queryOptions({ + queryKey: platformAdminNotificationsKeys.snapshot(), + queryFn: fetchAdminNotificationSnapshot, + }); +} + +export function platformAdminNotificationEventsQuery(filters?: AdminNotificationFilters) { + return queryOptions({ + queryKey: platformAdminNotificationsKeys.events(filters), + queryFn: () => fetchAdminNotificationEvents(filters), + }); +} + +export function platformAdminNotificationDeliveriesQuery(filters?: AdminNotificationFilters) { + return queryOptions({ + queryKey: platformAdminNotificationsKeys.deliveries(filters), + queryFn: () => fetchAdminNotificationDeliveries(filters), + }); +} + +export function usePreviewAdminNotificationReplayMutation() { + return useMutation({ + mutationFn: (filter: AdminNotificationReplayFilter = {}) => previewAdminNotificationReplay(filter), + }); +} + +export function useConfirmAdminNotificationReplayMutation() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (request: AdminNotificationReplayConfirmRequest) => confirmAdminNotificationReplay(request), + onSuccess: () => queryClient.invalidateQueries({ queryKey: platformAdminNotificationsKeys.all }), + }); +} diff --git a/front/features/platform-admin/queries/platform-admin-support-queries.ts b/front/features/platform-admin/queries/platform-admin-support-queries.ts new file mode 100644 index 00000000..0d189a4e --- /dev/null +++ b/front/features/platform-admin/queries/platform-admin-support-queries.ts @@ -0,0 +1,46 @@ +import { queryOptions, useMutation, useQueryClient } from "@tanstack/react-query"; +import { + createAdminSupportGrant, + fetchAdminSupportGrantLedger, + revokeAdminSupportGrant, + searchAdminSupportSubjects, +} from "@/features/platform-admin/api/platform-admin-support-api"; +import type { AdminSupportGrantRequest } from "@/features/platform-admin/model/platform-admin-support-model"; + +export const platformAdminSupportKeys = { + all: ["platform-admin", "support"] as const, + search: (query: string, clubId?: string) => [...platformAdminSupportKeys.all, "search", query, clubId ?? null] as const, + ledger: (filters: { clubId?: string; granteeUserId?: string } = {}) => + [...platformAdminSupportKeys.all, "ledger", filters.clubId ?? null, filters.granteeUserId ?? null] as const, +} as const; + +export function platformAdminSupportSearchQuery(query: string, clubId?: string) { + return queryOptions({ + queryKey: platformAdminSupportKeys.search(query, clubId), + queryFn: () => searchAdminSupportSubjects(query, clubId), + enabled: query.trim().length > 0, + }); +} + +export function platformAdminSupportLedgerQuery(filters: { clubId?: string; granteeUserId?: string } = {}) { + return queryOptions({ + queryKey: platformAdminSupportKeys.ledger(filters), + queryFn: () => fetchAdminSupportGrantLedger(filters), + }); +} + +export function useCreateAdminSupportGrantMutation() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (request: AdminSupportGrantRequest) => createAdminSupportGrant(request), + onSuccess: () => queryClient.invalidateQueries({ queryKey: platformAdminSupportKeys.all }), + }); +} + +export function useRevokeAdminSupportGrantMutation() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (grantId: string) => revokeAdminSupportGrant(grantId), + onSuccess: () => queryClient.invalidateQueries({ queryKey: platformAdminSupportKeys.all }), + }); +} diff --git a/front/features/platform-admin/route/admin-club-detail-data.ts b/front/features/platform-admin/route/admin-club-detail-data.ts index 26662b64..efbbfc8f 100644 --- a/front/features/platform-admin/route/admin-club-detail-data.ts +++ b/front/features/platform-admin/route/admin-club-detail-data.ts @@ -4,6 +4,7 @@ import { platformAdminClubsQuery, platformAdminSupportGrantsQuery, } from "@/features/platform-admin/queries/platform-admin-queries"; +import { platformAdminClubOperationsQuery } from "@/features/platform-admin/queries/platform-admin-club-operations-queries"; export function adminClubDetailLoaderFactory(queryClient: QueryClient) { return async function loadAdminClubDetail(args: LoaderFunctionArgs) { @@ -12,6 +13,7 @@ export function adminClubDetailLoaderFactory(queryClient: QueryClient) { await Promise.all([ queryClient.fetchQuery(platformAdminClubsQuery()), queryClient.fetchQuery(platformAdminSupportGrantsQuery(clubId)), + queryClient.fetchQuery(platformAdminClubOperationsQuery(clubId)), ]); return { clubId }; }; diff --git a/front/features/platform-admin/route/admin-club-detail-route.test.tsx b/front/features/platform-admin/route/admin-club-detail-route.test.tsx index 733e8fe2..2ec47d1e 100644 --- a/front/features/platform-admin/route/admin-club-detail-route.test.tsx +++ b/front/features/platform-admin/route/admin-club-detail-route.test.tsx @@ -6,6 +6,7 @@ import { platformAdminClubsQuery, platformAdminSupportGrantsQuery, } from "@/features/platform-admin/queries/platform-admin-queries"; +import { platformAdminClubOperationsQuery } from "@/features/platform-admin/queries/platform-admin-club-operations-queries"; import { AdminBreadcrumbProvider } from "./admin-breadcrumb-context"; import { AdminClubDetailRoute } from "./admin-club-detail-route"; @@ -16,9 +17,20 @@ function renderRoute(clubId: string, clubs: Array<{ domainCount: number; domainActionRequiredCount: number; firstHostOnboardingState: "MISSING" | "INVITED" | "ASSIGNED"; }>) { - const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } }); queryClient.setQueryData(platformAdminClubsQuery().queryKey, { items: clubs }); queryClient.setQueryData(platformAdminSupportGrantsQuery(clubId).queryKey, []); + queryClient.setQueryData(platformAdminClubOperationsQuery(clubId).queryKey, { + schema: "admin.club_operations_snapshot.v1", + generatedAt: "2026-05-27T00:00:00Z", + club: { clubId, slug: "alpha", name: "Alpha", status: "ACTIVE", publicVisibility: "PRIVATE" }, + readiness: { state: "READY", blockingReasons: [], nextAction: null }, + memberActivity: { activeCount: 1, dormantCount: 0, pendingViewerCount: 0, hostCount: 1 }, + sessionProgress: { upcomingCount: 0, currentOpenCount: 0, closedCount: 0, publishedRecordCount: 0, incompleteRecordCount: 0 }, + notificationHealth: { pending: 0, failed: 0, dead: 0, lastSuccessAt: null, failureClusters: [] }, + aiUsage: { activeJobs: 0, failedRecentJobs: 0, staleCandidates: 0, costEstimateUsd: "0.0000", state: "NO_RECENT_USAGE" }, + safeLinks: [], + }); // Bypass loader by injecting loader data via a wrapper route element function Wrapper() { return ; @@ -53,6 +65,7 @@ describe("AdminClubDetailRoute", () => { domainCount: 0, domainActionRequiredCount: 0, firstHostOnboardingState: "ASSIGNED", }]); expect(screen.getByRole("heading", { name: "Alpha" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Alpha 운영 스냅샷" })).toBeInTheDocument(); expect(screen.getByText(/alpha/)).toBeInTheDocument(); }); }); diff --git a/front/features/platform-admin/route/admin-club-detail-route.tsx b/front/features/platform-admin/route/admin-club-detail-route.tsx index 1cdef7f3..fb0a5926 100644 --- a/front/features/platform-admin/route/admin-club-detail-route.tsx +++ b/front/features/platform-admin/route/admin-club-detail-route.tsx @@ -1,12 +1,19 @@ import { useEffect } from "react"; import { useQuery } from "@tanstack/react-query"; import { Link, useParams } from "react-router-dom"; -import { platformAdminClubsQuery } from "@/features/platform-admin/queries/platform-admin-queries"; +import { + platformAdminClubsQuery, + platformAdminSupportGrantsQuery, +} from "@/features/platform-admin/queries/platform-admin-queries"; +import { platformAdminClubOperationsQuery } from "@/features/platform-admin/queries/platform-admin-club-operations-queries"; +import { AdminClubOperationsPage } from "@/features/platform-admin/ui/admin-club-operations-page"; import { useAdminBreadcrumbExtra } from "./admin-breadcrumb-hook"; export function AdminClubDetailRoute() { const { clubId = "" } = useParams<{ clubId: string }>(); const clubs = useQuery(platformAdminClubsQuery()).data!; + const supportGrantsQuery = useQuery(platformAdminSupportGrantsQuery(clubId)); + const operationsQuery = useQuery(platformAdminClubOperationsQuery(clubId)); const club = clubs.items.find((entry) => entry.clubId === clubId) ?? null; const { setExtra } = useAdminBreadcrumbExtra(); @@ -42,6 +49,12 @@ export function AdminClubDetailRoute() {

{club.about}

) : null} + {operationsQuery.data ? ( + + ) : null} ); } diff --git a/front/features/platform-admin/route/admin-health-route.test.tsx b/front/features/platform-admin/route/admin-health-route.test.tsx index 32e21d6c..3879d40a 100644 --- a/front/features/platform-admin/route/admin-health-route.test.tsx +++ b/front/features/platform-admin/route/admin-health-route.test.tsx @@ -18,7 +18,7 @@ const HEALTH_SNAPSHOT: PlatformHealthSnapshotResponse = { thresholds: { warn: 100, crit: 1000 }, lastCheckedAt: "2026-05-26T00:00:00Z", source: "IN_PROCESS", - drill: { kind: "ADMIN_ROUTE", target: "/admin/notifications" }, + drill: { kind: "ADMIN_ROUTE", target: "/admin/notifications?focus=outbox_backlog" }, reason: null, deployStrip: null, }, @@ -66,7 +66,7 @@ const HEALTH_SNAPSHOT: PlatformHealthSnapshotResponse = { thresholds: { warn: 0.95, crit: 0.9 }, lastCheckedAt: "2026-05-26T00:00:00Z", source: "PROMETHEUS", - drill: { kind: "ADMIN_ROUTE", target: "/admin/notifications" }, + drill: { kind: "ADMIN_ROUTE", target: "/admin/notifications?focus=notification_dispatch_success" }, reason: null, deployStrip: null, }, diff --git a/front/features/platform-admin/route/admin-notifications-data.ts b/front/features/platform-admin/route/admin-notifications-data.ts new file mode 100644 index 00000000..55cc6afb --- /dev/null +++ b/front/features/platform-admin/route/admin-notifications-data.ts @@ -0,0 +1,17 @@ +import type { QueryClient } from "@tanstack/react-query"; +import { + platformAdminNotificationDeliveriesQuery, + platformAdminNotificationEventsQuery, + platformAdminNotificationSnapshotQuery, +} from "@/features/platform-admin/queries/platform-admin-notifications-queries"; + +export function adminNotificationsLoaderFactory(queryClient: QueryClient) { + return async function loadAdminNotifications() { + await Promise.all([ + queryClient.fetchQuery(platformAdminNotificationSnapshotQuery()), + queryClient.fetchQuery(platformAdminNotificationEventsQuery()), + queryClient.fetchQuery(platformAdminNotificationDeliveriesQuery()), + ]); + return null; + }; +} diff --git a/front/features/platform-admin/route/admin-notifications-route.test.tsx b/front/features/platform-admin/route/admin-notifications-route.test.tsx new file mode 100644 index 00000000..0db670fa --- /dev/null +++ b/front/features/platform-admin/route/admin-notifications-route.test.tsx @@ -0,0 +1,50 @@ +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 { platformAdminSummaryQuery } from "@/features/platform-admin/queries/platform-admin-queries"; +import { + platformAdminNotificationDeliveriesQuery, + platformAdminNotificationEventsQuery, + platformAdminNotificationSnapshotQuery, +} from "@/features/platform-admin/queries/platform-admin-notifications-queries"; +import { AdminNotificationsRoute } from "@/features/platform-admin/route/admin-notifications-route"; + +function renderRoute(initialEntry = "/admin/notifications?focus=outbox_backlog") { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }); + queryClient.setQueryData(platformAdminSummaryQuery().queryKey, { + platformRole: "OWNER", + activeClubCount: 0, + domainActionRequiredCount: 0, + domainsRequiringAction: [], + }); + queryClient.setQueryData(platformAdminNotificationSnapshotQuery().queryKey, { + generatedAt: "2026-05-27T00:00:00Z", + outboxSummary: { pending: 1, active: 0, failed: 1, dead: 0, sentOrPublishedLast24h: 2 }, + deliverySummary: { pending: 0, active: 0, failed: 0, dead: 1, sentOrPublishedLast24h: 2 }, + relaySummary: { publishing: 0, sending: 0, stalePublishing: 0, staleSending: 0 }, + failureClusters: [], + clubHealth: [], + recentManualDispatches: [], + }); + queryClient.setQueryData(platformAdminNotificationEventsQuery().queryKey, { items: [], nextCursor: null }); + queryClient.setQueryData(platformAdminNotificationDeliveriesQuery().queryKey, { items: [], nextCursor: null }); + + return render( + + + + + , + ); +} + +describe("AdminNotificationsRoute", () => { + it("passes focus from URL into the page", () => { + renderRoute(); + + expect(screen.getByText(/Health outbox backlog/)).toBeInTheDocument(); + }); +}); diff --git a/front/features/platform-admin/route/admin-notifications-route.tsx b/front/features/platform-admin/route/admin-notifications-route.tsx new file mode 100644 index 00000000..cb5a4a14 --- /dev/null +++ b/front/features/platform-admin/route/admin-notifications-route.tsx @@ -0,0 +1,95 @@ +import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useSearchParams } from "react-router-dom"; +import { platformAdminSummaryQuery } from "@/features/platform-admin/queries/platform-admin-queries"; +import { + platformAdminNotificationDeliveriesQuery, + platformAdminNotificationEventsQuery, + platformAdminNotificationSnapshotQuery, + useConfirmAdminNotificationReplayMutation, + usePreviewAdminNotificationReplayMutation, +} from "@/features/platform-admin/queries/platform-admin-notifications-queries"; +import type { AdminNotificationReplayPreview } from "@/features/platform-admin/model/platform-admin-notifications-model"; +import { AdminNotificationsPage } from "@/features/platform-admin/ui/admin-notifications-page"; + +const GENERIC_ERROR = "알림 운영 정보를 처리하지 못했습니다. 다시 시도해 주세요."; + +export function AdminNotificationsRoute() { + const [searchParams] = useSearchParams(); + const focus = searchParams.get("focus"); + const clubId = searchParams.get("clubId") ?? undefined; + const [replayPreview, setReplayPreview] = useState(null); + const [replayReason, setReplayReason] = useState(""); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + + const summaryQuery = useQuery(platformAdminSummaryQuery()); + const snapshotQuery = useQuery(platformAdminNotificationSnapshotQuery()); + const eventsQuery = useQuery(platformAdminNotificationEventsQuery(clubId ? { clubId } : undefined)); + const deliveriesQuery = useQuery(platformAdminNotificationDeliveriesQuery(clubId ? { clubId } : undefined)); + const previewMutation = usePreviewAdminNotificationReplayMutation(); + const confirmMutation = useConfirmAdminNotificationReplayMutation(); + + const role = summaryQuery.data?.platformRole ?? "SUPPORT"; + const canReplay = role === "OWNER" || role === "OPERATOR"; + const busy = previewMutation.isPending || confirmMutation.isPending; + + async function previewReplay() { + if (!canReplay) { + setError("현재 역할은 재처리를 실행할 수 없습니다."); + return; + } + setError(null); + setSuccess(null); + try { + const preview = await previewMutation.mutateAsync({ clubId }); + setReplayPreview(preview); + } catch { + setError("재처리 대상을 확인하는 중입니다."); + } + } + + async function confirmReplay() { + if (!replayPreview || !replayReason.trim()) return; + setError(null); + setSuccess(null); + try { + const result = await confirmMutation.mutateAsync({ + previewId: replayPreview.previewId, + selectionHash: replayPreview.selectionHash, + reason: replayReason, + }); + setReplayPreview(null); + setReplayReason(""); + setSuccess(`${result.replayedCount}건 재처리를 기록했습니다.`); + } catch { + setError("재처리를 기록하는 중입니다."); + } + } + + if (snapshotQuery.isLoading || eventsQuery.isLoading || deliveriesQuery.isLoading) { + return

알림 운영 정보를 불러오는 중입니다.

; + } + + if (snapshotQuery.isError || !snapshotQuery.data) { + return

{GENERIC_ERROR}

; + } + + return ( + + ); +} diff --git a/front/features/platform-admin/route/admin-support-data.ts b/front/features/platform-admin/route/admin-support-data.ts new file mode 100644 index 00000000..151a21f5 --- /dev/null +++ b/front/features/platform-admin/route/admin-support-data.ts @@ -0,0 +1,16 @@ +import type { QueryClient } from "@tanstack/react-query"; +import type { LoaderFunctionArgs } from "react-router-dom"; +import { platformAdminClubsQuery } from "@/features/platform-admin/queries/platform-admin-queries"; +import { platformAdminSupportLedgerQuery } from "@/features/platform-admin/queries/platform-admin-support-queries"; + +export function adminSupportLoaderFactory(queryClient: QueryClient) { + return async function loadAdminSupport(args: LoaderFunctionArgs) { + const url = new URL(args.request.url); + const clubId = url.searchParams.get("clubId") ?? undefined; + await Promise.all([ + queryClient.fetchQuery(platformAdminClubsQuery()), + clubId ? queryClient.fetchQuery(platformAdminSupportLedgerQuery({ clubId })) : Promise.resolve(), + ]); + return null; + }; +} diff --git a/front/features/platform-admin/route/admin-support-route.test.tsx b/front/features/platform-admin/route/admin-support-route.test.tsx index bb5da367..45383bb4 100644 --- a/front/features/platform-admin/route/admin-support-route.test.tsx +++ b/front/features/platform-admin/route/admin-support-route.test.tsx @@ -1,11 +1,32 @@ 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 { platformAdminClubsQuery, platformAdminSummaryQuery } from "@/features/platform-admin/queries/platform-admin-queries"; +import { platformAdminSupportLedgerQuery } from "@/features/platform-admin/queries/platform-admin-support-queries"; import { AdminSupportRoute } from "./admin-support-route"; describe("AdminSupportRoute", () => { - it("renders a light shell directing to club detail support tab", () => { - render(); - expect(screen.getByRole("heading", { name: /지원/ })).toBeInTheDocument(); - expect(screen.getByText(/Support access 탭에서 관리합니다/)).toBeInTheDocument(); + it("renders the support workbench shell", () => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } }); + client.setQueryData(platformAdminSummaryQuery().queryKey, { + platformRole: "OWNER", + activeClubCount: 1, + domainActionRequiredCount: 0, + domains: [], + domainsRequiringAction: [], + }); + client.setQueryData(platformAdminClubsQuery().queryKey, { items: [] }); + client.setQueryData(platformAdminSupportLedgerQuery().queryKey, []); + + render( + + + + + , + ); + expect(screen.getByRole("heading", { name: "지원", level: 1 })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "지원 대상 검색" })).toBeInTheDocument(); }); }); diff --git a/front/features/platform-admin/route/admin-support-route.tsx b/front/features/platform-admin/route/admin-support-route.tsx index 7c74956b..534e37c6 100644 --- a/front/features/platform-admin/route/admin-support-route.tsx +++ b/front/features/platform-admin/route/admin-support-route.tsx @@ -1,12 +1,93 @@ +import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useSearchParams } from "react-router-dom"; +import { platformAdminClubsQuery, platformAdminSummaryQuery } from "@/features/platform-admin/queries/platform-admin-queries"; +import { + platformAdminSupportLedgerQuery, + platformAdminSupportSearchQuery, + useCreateAdminSupportGrantMutation, + useRevokeAdminSupportGrantMutation, +} from "@/features/platform-admin/queries/platform-admin-support-queries"; +import type { AdminSupportSearchResult } from "@/features/platform-admin/model/platform-admin-support-model"; +import { AdminSupportWorkbench } from "@/features/platform-admin/ui/admin-support-workbench"; + +function defaultExpiresAt(): string { + const d = new Date(Date.now() + 60 * 60 * 1000); + const pad = (n: number) => String(n).padStart(2, "0"); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; +} + export function AdminSupportRoute() { + const [searchParams, setSearchParams] = useSearchParams(); + const [query, setQuery] = useState(""); + const [submittedQuery, setSubmittedQuery] = useState(""); + const [selectedResult, setSelectedResult] = useState(null); + const [reason, setReason] = useState(""); + const [expiresAt, setExpiresAt] = useState(defaultExpiresAt); + const [error, setError] = useState(null); + const selectedClubId = searchParams.get("clubId"); + const role = useQuery(platformAdminSummaryQuery()).data?.platformRole ?? "SUPPORT"; + const clubsQuery = useQuery(platformAdminClubsQuery()); + const searchQuery = useQuery(platformAdminSupportSearchQuery(submittedQuery, selectedClubId ?? undefined)); + const ledgerQuery = useQuery(platformAdminSupportLedgerQuery(selectedClubId ? { clubId: selectedClubId } : {})); + const createGrant = useCreateAdminSupportGrantMutation(); + const revokeGrant = useRevokeAdminSupportGrantMutation(); + const canCreateGrant = role === "OWNER"; + + async function search() { + setError(null); + setSelectedResult(null); + setSubmittedQuery(query.trim()); + } + + async function create() { + if (!selectedResult || !selectedClubId) return; + setError(null); + try { + await createGrant.mutateAsync({ + clubId: selectedClubId, + granteeSubjectId: selectedResult.subjectId, + scope: "HOST_SUPPORT_READ", + reason, + expiresAt: new Date(expiresAt).toISOString(), + }); + setReason(""); + setSelectedResult(null); + } catch { + setError("지원 접근 권한을 발급하지 못했습니다."); + } + } + + async function revoke(grantId: string) { + setError(null); + try { + await revokeGrant.mutateAsync(grantId); + } catch { + setError("지원 접근 권한을 취소하지 못했습니다."); + } + } + return ( -
-
-

지원

-

- 지원 grant는 각 클럽 상세 페이지의 Support access 탭에서 관리합니다. 클럽을 선택해서 들어가세요. -

-
-
+ setSearchParams(clubId ? { clubId } : {})} + onReasonChange={setReason} + onExpiresAtChange={setExpiresAt} + onCreateGrant={create} + onRevokeGrant={revoke} + /> ); } diff --git a/front/features/platform-admin/ui/admin-breadcrumb.test.tsx b/front/features/platform-admin/ui/admin-breadcrumb.test.tsx index 6266409b..598386c7 100644 --- a/front/features/platform-admin/ui/admin-breadcrumb.test.tsx +++ b/front/features/platform-admin/ui/admin-breadcrumb.test.tsx @@ -20,7 +20,7 @@ describe("AdminBreadcrumb", () => { }); it("renders coming-soon route with '준비 중' suffix", () => { - render(); + render(); expect(screen.getByText(/준비 중/)).toBeInTheDocument(); }); }); diff --git a/front/features/platform-admin/ui/admin-club-operations-page.test.tsx b/front/features/platform-admin/ui/admin-club-operations-page.test.tsx new file mode 100644 index 00000000..ce0d7f08 --- /dev/null +++ b/front/features/platform-admin/ui/admin-club-operations-page.test.tsx @@ -0,0 +1,48 @@ +import { render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { describe, expect, it } from "vitest"; +import { AdminClubOperationsPage } from "@/features/platform-admin/ui/admin-club-operations-page"; +import type { AdminClubOperationsSnapshot } from "@/features/platform-admin/model/platform-admin-club-operations-model"; + +const snapshot: AdminClubOperationsSnapshot = { + schema: "admin.club_operations_snapshot.v1", + generatedAt: "2026-05-27T00:00:00Z", + club: { clubId: "club-1", slug: "reading-sai", name: "읽는사이", status: "ACTIVE", publicVisibility: "PUBLIC" }, + readiness: { state: "READY", blockingReasons: [], nextAction: null }, + memberActivity: { activeCount: 8, dormantCount: 1, pendingViewerCount: 2, hostCount: 1 }, + sessionProgress: { upcomingCount: 2, currentOpenCount: 1, closedCount: 5, publishedRecordCount: 4, incompleteRecordCount: 1 }, + notificationHealth: { pending: 1, failed: 1, dead: 0, lastSuccessAt: null, failureClusters: [] }, + aiUsage: { activeJobs: 0, failedRecentJobs: 1, staleCandidates: 0, costEstimateUsd: "0.1200", state: "HAS_ACTIVITY" }, + safeLinks: [ + { label: "Host app", href: "/clubs/reading-sai/app", kind: "HOST_ROUTE" }, + { label: "알림 운영", href: "/admin/notifications?clubId=club-1", kind: "ADMIN_ROUTE" }, + ], +}; + +describe("AdminClubOperationsPage", () => { + it("renders snapshot heading and support grant count", () => { + render( + + + , + ); + + expect(screen.getByRole("heading", { name: "읽는사이 운영 스냅샷" })).toBeInTheDocument(); + expect(screen.getByText("지원 grant")).toBeInTheDocument(); + expect(screen.getByText("3")).toBeInTheDocument(); + }); + + it("links notification health to the selected club and avoids host commands", () => { + render( + + + , + ); + + expect(screen.getByRole("link", { name: "알림 ledger" })).toHaveAttribute( + "href", + "/admin/notifications?clubId=club-1", + ); + expect(screen.queryByRole("button", { name: /RSVP|출석|세션 편집|발행/ })).not.toBeInTheDocument(); + }); +}); diff --git a/front/features/platform-admin/ui/admin-club-operations-page.tsx b/front/features/platform-admin/ui/admin-club-operations-page.tsx new file mode 100644 index 00000000..e4ab73b0 --- /dev/null +++ b/front/features/platform-admin/ui/admin-club-operations-page.tsx @@ -0,0 +1,102 @@ +import { Link } from "react-router-dom"; +import type { ReactNode } from "react"; +import type { AdminClubOperationsSnapshot } from "@/features/platform-admin/model/platform-admin-club-operations-model"; + +type AdminClubOperationsPageProps = { + snapshot: AdminClubOperationsSnapshot; + supportGrantCount: number; +}; + +export function AdminClubOperationsPage({ snapshot, supportGrantCount }: AdminClubOperationsPageProps) { + return ( +
+
+
+

Operations snapshot

+

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

+
+ {snapshot.readiness.state} +
+ +
+ + + + + +
+ + {snapshot.readiness.blockingReasons.length > 0 ? ( +
+ {snapshot.readiness.blockingReasons.map((reason) => ( + {reason} + ))} +
+ ) : null} + +
+ + + + + + + + + + + + + 알림 ledger + + + + + + + + + AI Ops + + +
+ +
+ {snapshot.safeLinks.map((link) => ( + + {link.label} + + ))} +
+
+ ); +} + +function Metric({ label, value }: { label: string; value: number }) { + return ( +
+

{label}

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

{title}

+
{children}
+
+ ); +} + +function Stat({ label, value }: { label: string; value: number | string }) { + return ( +
+ {label} + {value} +
+ ); +} diff --git a/front/features/platform-admin/ui/admin-health-card.test.tsx b/front/features/platform-admin/ui/admin-health-card.test.tsx index c072d03e..8053ba72 100644 --- a/front/features/platform-admin/ui/admin-health-card.test.tsx +++ b/front/features/platform-admin/ui/admin-health-card.test.tsx @@ -13,7 +13,7 @@ function card(overrides: Partial = {}): HealthCard { thresholds: { warn: 100, crit: 1000 }, lastCheckedAt: "2026-05-26T00:00:00Z", source: "IN_PROCESS", - drill: { kind: "ADMIN_ROUTE", target: "/admin/notifications" }, + drill: { kind: "ADMIN_ROUTE", target: "/admin/notifications?focus=outbox_backlog" }, reason: null, deployStrip: null, ...overrides, @@ -29,7 +29,10 @@ describe("AdminHealthCard", () => { ); expect(screen.getByText("Outbox backlog")).toBeInTheDocument(); expect(screen.getByText(/42/)).toBeInTheDocument(); - expect(screen.getByRole("link", { name: /자세히/ })).toHaveAttribute("href", "/admin/notifications"); + expect(screen.getByRole("link", { name: /자세히/ })).toHaveAttribute( + "href", + "/admin/notifications?focus=outbox_backlog", + ); }); it("renders reason when status is UNKNOWN and metric is null", () => { diff --git a/front/features/platform-admin/ui/admin-health-grid.test.tsx b/front/features/platform-admin/ui/admin-health-grid.test.tsx index daa8ba73..ac710d08 100644 --- a/front/features/platform-admin/ui/admin-health-grid.test.tsx +++ b/front/features/platform-admin/ui/admin-health-grid.test.tsx @@ -24,7 +24,7 @@ const HEALTH_SNAPSHOT: PlatformHealthSnapshot = { thresholds: { warn: 100, crit: 1000 }, lastCheckedAt: "2026-05-26T00:00:00Z", source: "IN_PROCESS", - drill: { kind: "ADMIN_ROUTE", target: "/admin/notifications" }, + drill: { kind: "ADMIN_ROUTE", target: "/admin/notifications?focus=outbox_backlog" }, reason: null, deployStrip: null, }, @@ -72,7 +72,7 @@ const HEALTH_SNAPSHOT: PlatformHealthSnapshot = { thresholds: { warn: 0.95, crit: 0.9 }, lastCheckedAt: "2026-05-26T00:00:00Z", source: "PROMETHEUS", - drill: { kind: "ADMIN_ROUTE", target: "/admin/notifications" }, + drill: { kind: "ADMIN_ROUTE", target: "/admin/notifications?focus=notification_dispatch_success" }, reason: null, deployStrip: null, }, diff --git a/front/features/platform-admin/ui/admin-layout-nav.test.tsx b/front/features/platform-admin/ui/admin-layout-nav.test.tsx index c3ba4ab5..5191881c 100644 --- a/front/features/platform-admin/ui/admin-layout-nav.test.tsx +++ b/front/features/platform-admin/ui/admin-layout-nav.test.tsx @@ -26,10 +26,10 @@ describe("AdminLayoutNav", () => { } }); - it("shows a 준비 중 · S5 pill on the notifications item", () => { + it("does not show 준비 중 pill on the notifications item", () => { renderNav({}); const notificationsLink = screen.getByRole("link", { name: /알림/ }); - expect(notificationsLink.textContent).toContain("준비 중 · S5"); + expect(notificationsLink.textContent).not.toContain("준비 중"); }); it("does not show 준비 중 pill on ready routes", () => { diff --git a/front/features/platform-admin/ui/admin-notifications-page.test.tsx b/front/features/platform-admin/ui/admin-notifications-page.test.tsx new file mode 100644 index 00000000..270461e7 --- /dev/null +++ b/front/features/platform-admin/ui/admin-notifications-page.test.tsx @@ -0,0 +1,121 @@ +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { ComponentProps } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { AdminNotificationsPage } from "@/features/platform-admin/ui/admin-notifications-page"; +import type { + AdminNotificationDelivery, + AdminNotificationOperationsSnapshot, + AdminNotificationOutboxEvent, + AdminNotificationReplayPreview, +} from "@/features/platform-admin/model/platform-admin-notifications-model"; + +const snapshot: AdminNotificationOperationsSnapshot = { + generatedAt: "2026-05-27T00:00:00Z", + outboxSummary: { pending: 3, active: 1, failed: 2, dead: 1, sentOrPublishedLast24h: 9 }, + deliverySummary: { pending: 4, active: 0, failed: 1, dead: 1, sentOrPublishedLast24h: 12 }, + relaySummary: { publishing: 0, sending: 0, stalePublishing: 1, staleSending: 1 }, + failureClusters: [{ safeErrorCode: "mailbox_unavailable", status: "DEAD", count: 2, latestAt: "2026-05-27T00:00:00Z" }], + clubHealth: [], + recentManualDispatches: [], +}; + +const event: AdminNotificationOutboxEvent = { + eventId: "event-1", + club: { clubId: "club-1", slug: "reading-sai", name: "읽는사이" }, + eventType: "SESSION_REMINDER_DUE", + source: "AUTOMATIC", + status: "FAILED", + attemptCount: 2, + nextAttemptAt: null, + createdAt: "2026-05-27T00:00:00Z", + updatedAt: "2026-05-27T00:01:00Z", + safeErrorCode: "mailbox_unavailable", + manualDispatch: null, +}; + +const delivery: AdminNotificationDelivery = { + deliveryId: "delivery-1", + eventId: "event-1", + club: { clubId: "club-1", slug: "reading-sai", name: "읽는사이" }, + channel: "EMAIL", + status: "DEAD", + maskedRecipient: "m***@example.com", + attemptCount: 2, + createdAt: "2026-05-27T00:00:00Z", + updatedAt: "2026-05-27T00:01:00Z", + safeErrorCode: "mailbox_unavailable", +}; + +const replayPreview: AdminNotificationReplayPreview = { + previewId: "preview-1", + selectionHash: "hash", + matchedCount: 2, + excludedCount: 0, + estimatedByStatus: { DEAD: 2 }, + warnings: [], + expiresAt: "2026-05-27T00:10:00Z", +}; + +function renderPage(overrides: Partial> = {}) { + return render( + , + ); +} + +describe("AdminNotificationsPage", () => { + it("renders summary and failure clusters", () => { + renderPage(); + + expect(screen.getByRole("heading", { name: "알림 / Outbox 운영" })).toBeInTheDocument(); + expect(screen.getByText("Outbox pending")).toBeInTheDocument(); + expect(screen.getAllByText("mailbox_unavailable").length).toBeGreaterThan(0); + }); + + it("renders masked recipients without raw email fixture", () => { + const { container } = renderPage(); + const deliveryLedger = screen.getByRole("region", { name: "Delivery ledger" }); + + expect(within(deliveryLedger).getByText(/m\*\*\*@example.com/)).toBeInTheDocument(); + expect(container.textContent).not.toContain("member1@example.com"); + }); + + it("shows focus banner from health drill-down", () => { + renderPage({ focus: "outbox_backlog" }); + + expect(screen.getByText(/Health outbox backlog/)).toBeInTheDocument(); + }); + + it("keeps confirm disabled until preview and reason exist", async () => { + const onReasonChange = vi.fn(); + const user = userEvent.setup(); + renderPage({ replayPreview, onReplayReasonChange: onReasonChange }); + + expect(screen.getByRole("button", { name: "재처리 확정" })).toBeDisabled(); + await user.type(screen.getByLabelText("처리 사유"), "provider recovered"); + + expect(onReasonChange).toHaveBeenCalled(); + }); + + it("shows support role permission message", () => { + renderPage({ canReplay: false }); + + expect(screen.getByText("현재 역할은 재처리를 실행할 수 없습니다.")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "대상 확인" })).toBeDisabled(); + }); +}); diff --git a/front/features/platform-admin/ui/admin-notifications-page.tsx b/front/features/platform-admin/ui/admin-notifications-page.tsx new file mode 100644 index 00000000..ad74fa2b --- /dev/null +++ b/front/features/platform-admin/ui/admin-notifications-page.tsx @@ -0,0 +1,188 @@ +import type { ReactNode } from "react"; +import type { + AdminNotificationDelivery, + AdminNotificationOperationsSnapshot, + AdminNotificationOutboxEvent, + AdminNotificationReplayPreview, +} from "@/features/platform-admin/model/platform-admin-notifications-model"; + +export type AdminNotificationsPageProps = { + snapshot: AdminNotificationOperationsSnapshot; + events: AdminNotificationOutboxEvent[]; + deliveries: AdminNotificationDelivery[]; + focus: string | null; + replayPreview: AdminNotificationReplayPreview | null; + replayReason: string; + canReplay: boolean; + busy: boolean; + error: string | null; + success: string | null; + onPreviewReplay: () => Promise; + onConfirmReplay: () => Promise; + onReplayReasonChange: (value: string) => void; +}; + +export function AdminNotificationsPage({ + snapshot, + events, + deliveries, + focus, + replayPreview, + replayReason, + canReplay, + busy, + error, + success, + onPreviewReplay, + onConfirmReplay, + onReplayReasonChange, +}: AdminNotificationsPageProps) { + const confirmDisabled = !replayPreview || !replayReason.trim() || !canReplay || busy; + + return ( +
+
+
+

S5 Operations

+

+ 알림 / Outbox 운영 +

+
+

생성 {formatTimestamp(snapshot.generatedAt)}

+
+ + {focus ? : null} + {error ?

{error}

: null} + {success ?

{success}

: null} + +
+ + + + + +
+ +
+
+

Failure clusters

+ {snapshot.failureClusters.length > 0 ? ( +
    + {snapshot.failureClusters.map((cluster) => ( +
  • + {cluster.safeErrorCode} + {cluster.count} + {cluster.status} +
  • + ))} +
+ ) : ( +

집계된 실패 cluster가 없습니다.

+ )} +
+ +
+
+

Replay

+ {busy ? 처리 중 : null} +
+ {!canReplay ?

현재 역할은 재처리를 실행할 수 없습니다.

: null} + {replayPreview ? ( +
+

+ 대상 {replayPreview.matchedCount}건 · 제외 {replayPreview.excludedCount}건 +

+

만료 {formatTimestamp(replayPreview.expiresAt)}

+
+ ) : ( +

실패/Dead delivery를 확인한 뒤 사유를 남기고 재처리합니다.

+ )} +