From 5ca5c43c38e09361ba123fdafcb1ac4b6c4d3403 Mon Sep 17 00:00:00 2001 From: kws Date: Sun, 17 May 2026 20:57:33 +0900 Subject: [PATCH 01/30] feat(server): publish feedback events from session imports Task: task_0 Risk: mid Files: server/src/main/kotlin/com/readmates/sessionimport/application/port/out/SessionImportWritePort.kt, server/src/main/kotlin/com/readmates/sessionimport/adapter/out/persistence/JdbcSessionImportWriteAdapter.kt, server/src/main/kotlin/com/readmates/sessionimport/application/service/SessionImportService.kt, server/src/test/kotlin/com/readmates/sessionimport/application/service/SessionImportServiceCommitValidatedTest.kt, server/src/test/kotlin/com/readmates/sessionimport/api/HostSessionImportControllerDbTest.kt --- .../JdbcSessionImportWriteAdapter.kt | 1 + .../port/out/SessionImportWritePort.kt | 1 + .../service/SessionImportService.kt | 9 +++ .../api/HostSessionImportControllerDbTest.kt | 36 +++++++++++ ...SessionImportServiceCommitValidatedTest.kt | 64 ++++++++++++++++++- 5 files changed, 110 insertions(+), 1 deletion(-) diff --git a/server/src/main/kotlin/com/readmates/sessionimport/adapter/out/persistence/JdbcSessionImportWriteAdapter.kt b/server/src/main/kotlin/com/readmates/sessionimport/adapter/out/persistence/JdbcSessionImportWriteAdapter.kt index 71c9edc2..7688af75 100644 --- a/server/src/main/kotlin/com/readmates/sessionimport/adapter/out/persistence/JdbcSessionImportWriteAdapter.kt +++ b/server/src/main/kotlin/com/readmates/sessionimport/adapter/out/persistence/JdbcSessionImportWriteAdapter.kt @@ -239,6 +239,7 @@ class JdbcSessionImportWriteAdapter( fileName = fileName, title = command.feedbackTitle, uploadedAt = uploadedAt, + version = nextVersion, ) } diff --git a/server/src/main/kotlin/com/readmates/sessionimport/application/port/out/SessionImportWritePort.kt b/server/src/main/kotlin/com/readmates/sessionimport/application/port/out/SessionImportWritePort.kt index d51dfa9f..766ae6e6 100644 --- a/server/src/main/kotlin/com/readmates/sessionimport/application/port/out/SessionImportWritePort.kt +++ b/server/src/main/kotlin/com/readmates/sessionimport/application/port/out/SessionImportWritePort.kt @@ -31,4 +31,5 @@ data class SessionImportStoredFeedbackDocument( val fileName: String, val title: String, val uploadedAt: String?, + val version: Int, ) diff --git a/server/src/main/kotlin/com/readmates/sessionimport/application/service/SessionImportService.kt b/server/src/main/kotlin/com/readmates/sessionimport/application/service/SessionImportService.kt index 723a5372..9e1d7204 100644 --- a/server/src/main/kotlin/com/readmates/sessionimport/application/service/SessionImportService.kt +++ b/server/src/main/kotlin/com/readmates/sessionimport/application/service/SessionImportService.kt @@ -1,6 +1,7 @@ package com.readmates.sessionimport.application.service import com.readmates.feedback.application.FeedbackDocumentParser +import com.readmates.notification.application.port.`in`.RecordNotificationEventUseCase import com.readmates.session.application.HostSessionNotFoundException import com.readmates.session.application.SessionRecordVisibility import com.readmates.sessionimport.application.model.SessionImportCommand @@ -45,6 +46,7 @@ private fun requireHost(host: com.readmates.shared.security.CurrentMember) { @Service class SessionImportService( private val writePort: SessionImportWritePort, + private val recordNotificationEventUseCase: RecordNotificationEventUseCase, private val cacheInvalidation: ReadCacheInvalidationPort = ReadCacheInvalidationPort.Noop(), ) : PreviewSessionImportUseCase, CommitSessionImportUseCase, @@ -113,6 +115,13 @@ class SessionImportService( feedbackTitle = feedbackTitle, ), ) + recordNotificationEventUseCase.recordFeedbackDocumentPublished( + clubId = command.host.clubId, + sessionId = command.sessionId, + sessionNumber = command.session.number, + bookTitle = command.session.bookTitle, + documentVersion = storedFeedback.version, + ) cacheInvalidation.evictClubContentAfterCommit(command.host.clubId) return SessionImportCommitResult( diff --git a/server/src/test/kotlin/com/readmates/sessionimport/api/HostSessionImportControllerDbTest.kt b/server/src/test/kotlin/com/readmates/sessionimport/api/HostSessionImportControllerDbTest.kt index 1e277a51..783f0600 100644 --- a/server/src/test/kotlin/com/readmates/sessionimport/api/HostSessionImportControllerDbTest.kt +++ b/server/src/test/kotlin/com/readmates/sessionimport/api/HostSessionImportControllerDbTest.kt @@ -22,6 +22,7 @@ private const val HOST_MEMBERSHIP_ID = "00000000-0000-0000-0000-000000079521" private const val MEMBER_MEMBERSHIP_ID = "00000000-0000-0000-0000-000000079522" private const val CLEANUP_SQL = """ +delete from notification_event_outbox where aggregate_id = '$SESSION_ID'; delete from session_feedback_documents where session_id = '$SESSION_ID'; delete from public_session_publications where session_id = '$SESSION_ID'; delete from highlights where session_id = '$SESSION_ID'; @@ -163,6 +164,41 @@ class HostSessionImportControllerDbTest( } assertCommittedImportRecords() + assertFeedbackDocumentNotificationEvent() + } + + private fun assertFeedbackDocumentNotificationEvent() { + val documentVersion = + jdbcTemplate.queryForObject( + """ + select max(version) + from session_feedback_documents + where club_id = '$CLUB_ID' + and session_id = '$SESSION_ID' + """.trimIndent(), + Int::class.java, + ) + + val event = + jdbcTemplate.queryForMap( + """ + select + dedupe_key, + json_unquote(json_extract(payload_json, '$.sessionId')) as session_id, + cast(json_unquote(json_extract(payload_json, '$.sessionNumber')) as signed) as session_number, + json_unquote(json_extract(payload_json, '$.bookTitle')) as book_title, + cast(json_unquote(json_extract(payload_json, '$.documentVersion')) as signed) as document_version + from notification_event_outbox + where event_type = 'FEEDBACK_DOCUMENT_PUBLISHED' + and aggregate_id = '$SESSION_ID' + """.trimIndent(), + ) + + assertEquals("feedback-document:$SESSION_ID:$documentVersion", event["dedupe_key"]) + assertEquals(SESSION_ID, event["session_id"]) + assertEquals(7951, (event["session_number"] as Number).toInt()) + assertEquals("Import Test Book", event["book_title"]) + assertEquals(documentVersion, (event["document_version"] as Number).toInt()) } private fun assertCommittedImportRecords() { diff --git a/server/src/test/kotlin/com/readmates/sessionimport/application/service/SessionImportServiceCommitValidatedTest.kt b/server/src/test/kotlin/com/readmates/sessionimport/application/service/SessionImportServiceCommitValidatedTest.kt index c4a15ad4..fb200d1d 100644 --- a/server/src/test/kotlin/com/readmates/sessionimport/application/service/SessionImportServiceCommitValidatedTest.kt +++ b/server/src/test/kotlin/com/readmates/sessionimport/application/service/SessionImportServiceCommitValidatedTest.kt @@ -1,6 +1,7 @@ package com.readmates.sessionimport.application.service import com.readmates.auth.domain.MembershipRole +import com.readmates.notification.application.port.`in`.RecordNotificationEventUseCase import com.readmates.session.application.SessionRecordVisibility import com.readmates.sessionimport.application.model.SessionImportCommand import com.readmates.sessionimport.application.model.SessionImportFeedbackDocumentCommand @@ -102,7 +103,13 @@ class SessionImportServiceCommitValidatedTest { ) val writePort = RecordingWritePort(target) val cache = RecordingCacheInvalidation() - val service = SessionImportService(writePort = writePort, cacheInvalidation = cache) + val notificationEvents = RecordingNotificationEvents() + val service = + SessionImportService( + writePort = writePort, + recordNotificationEventUseCase = notificationEvents, + cacheInvalidation = cache, + ) val command = SessionImportCommand( @@ -151,6 +158,14 @@ class SessionImportServiceCommitValidatedTest { assertEquals(1, writePort.replaceCallCount, "replaceRecords must be invoked exactly once") assertEquals(1, cache.evictCount, "cache eviction must be invoked exactly once") assertEquals(clubId, cache.lastClubId) + + assertEquals(1, notificationEvents.feedbackEvents.size) + val event = notificationEvents.feedbackEvents.single() + assertEquals(clubId, event.clubId) + assertEquals(sessionId, event.sessionId) + assertEquals(7951, event.sessionNumber) + assertEquals("Import Test Book", event.bookTitle) + assertEquals(2, event.documentVersion) } private fun currentMember( @@ -187,10 +202,57 @@ private class RecordingWritePort( fileName = command.feedbackDocument.fileName, title = command.feedbackTitle, uploadedAt = "2026-05-16T00:00:00Z", + version = 2, ) } } +private data class RecordedFeedbackEvent( + val clubId: UUID, + val sessionId: UUID, + val sessionNumber: Int, + val bookTitle: String, + val documentVersion: Int, +) + +private class RecordingNotificationEvents : RecordNotificationEventUseCase { + val feedbackEvents = mutableListOf() + + override fun recordFeedbackDocumentPublished( + clubId: UUID, + sessionId: UUID, + sessionNumber: Int, + bookTitle: String, + documentVersion: Int, + ) { + feedbackEvents += RecordedFeedbackEvent(clubId, sessionId, sessionNumber, bookTitle, documentVersion) + } + + override fun recordNextBookPublished( + clubId: UUID, + sessionId: UUID, + sessionNumber: Int, + bookTitle: String, + ) = Unit + + override fun recordReviewPublished( + clubId: UUID, + sessionId: UUID, + sessionNumber: Int, + bookTitle: String, + authorMembershipId: UUID, + ) = Unit + + override fun recordSessionReminderDue(targetDate: LocalDate) = Unit + + override fun recordAiGenerationReady( + jobId: UUID, + sessionId: UUID, + clubId: UUID, + hostUserId: UUID, + ) = Unit +} + private class RecordingCacheInvalidation : ReadCacheInvalidationPort { var evictCount: Int = 0 var lastClubId: UUID? = null From f3ebec08c42d7eb1c577c4252638f8d4faf735f4 Mon Sep 17 00:00:00 2001 From: kws Date: Sun, 17 May 2026 20:58:09 +0900 Subject: [PATCH 02/30] fix(admin): restrict support access grants to owners Task: task_1 Risk: high Files: server/src/main/kotlin/com/readmates/shared/security/CurrentPlatformAdmin.kt, server/src/main/kotlin/com/readmates/club/application/service/SupportAccessGrantService.kt, server/src/test/kotlin/com/readmates/club/api/SupportAccessGrantControllerTest.kt --- .../service/SupportAccessGrantService.kt | 7 ++ .../shared/security/CurrentPlatformAdmin.kt | 3 + .../api/SupportAccessGrantControllerTest.kt | 83 +++++++++++++++++++ 3 files changed, 93 insertions(+) diff --git a/server/src/main/kotlin/com/readmates/club/application/service/SupportAccessGrantService.kt b/server/src/main/kotlin/com/readmates/club/application/service/SupportAccessGrantService.kt index ba8f95b1..691abb1a 100644 --- a/server/src/main/kotlin/com/readmates/club/application/service/SupportAccessGrantService.kt +++ b/server/src/main/kotlin/com/readmates/club/application/service/SupportAccessGrantService.kt @@ -13,6 +13,7 @@ import com.readmates.club.application.port.out.CreateSupportAccessGrantPort import com.readmates.club.application.port.out.LoadSupportAccessGrantPort import com.readmates.club.application.port.out.RevokeSupportAccessGrantPort import com.readmates.club.application.port.out.WritePlatformAuditEventPort +import com.readmates.shared.security.AccessDeniedException import com.readmates.shared.security.CurrentPlatformAdmin import org.springframework.stereotype.Service import tools.jackson.databind.ObjectMapper @@ -50,6 +51,9 @@ class SupportAccessGrantService( admin: CurrentPlatformAdmin, command: CreateSupportAccessGrantCommand, ): SupportAccessGrant { + if (!admin.canManageSupportAccess) { + throw AccessDeniedException("Platform admin role cannot manage support access grants") + } if (command.reason.isBlank()) { throw PlatformAdminException( PlatformAdminError.GRANT_REASON_REQUIRED, @@ -90,6 +94,9 @@ class SupportAccessGrantService( admin: CurrentPlatformAdmin, grantId: UUID, ) { + if (!admin.canManageSupportAccess) { + throw AccessDeniedException("Platform admin role cannot manage support access grants") + } val now = OffsetDateTime.now(ZoneOffset.UTC) val revoked = revokeGrantPort.revokeGrant(grantId, now) diff --git a/server/src/main/kotlin/com/readmates/shared/security/CurrentPlatformAdmin.kt b/server/src/main/kotlin/com/readmates/shared/security/CurrentPlatformAdmin.kt index ae49da3c..816a1325 100644 --- a/server/src/main/kotlin/com/readmates/shared/security/CurrentPlatformAdmin.kt +++ b/server/src/main/kotlin/com/readmates/shared/security/CurrentPlatformAdmin.kt @@ -16,6 +16,9 @@ data class CurrentPlatformAdmin( val canManageClubDomains: Boolean get() = role in setOf(PlatformAdminRole.OWNER, PlatformAdminRole.OPERATOR) + + val canManageSupportAccess: Boolean + get() = role == PlatformAdminRole.OWNER } data class CurrentUser( diff --git a/server/src/test/kotlin/com/readmates/club/api/SupportAccessGrantControllerTest.kt b/server/src/test/kotlin/com/readmates/club/api/SupportAccessGrantControllerTest.kt index 59ceb664..f25db8f7 100644 --- a/server/src/test/kotlin/com/readmates/club/api/SupportAccessGrantControllerTest.kt +++ b/server/src/test/kotlin/com/readmates/club/api/SupportAccessGrantControllerTest.kt @@ -472,6 +472,89 @@ class SupportAccessGrantControllerTest( } } + @Test + fun `operator cannot create support access grant`() { + val operator = createPlatformAdminUser(role = "OPERATOR", status = "ACTIVE") + val grantee = createPlatformAdminUser(role = "SUPPORT", status = "ACTIVE") + + mockMvc + .post("/api/admin/support-access-grants") { + contentType = MediaType.APPLICATION_JSON + content = + """ + { + "clubId": "$TEST_CLUB_ID", + "granteeUserId": "$grantee", + "scope": "HOST_SUPPORT_READ", + "reason": "Customer escalation ticket #1234", + "expiresAt": "2099-01-01T12:00:00Z" + } + """.trimIndent() + cookie(sessionCookieForUser(operator)) + }.andExpect { + status { isForbidden() } + } + } + + @Test + fun `support admin cannot create support access grant`() { + val support = createPlatformAdminUser(role = "SUPPORT", status = "ACTIVE") + val grantee = createPlatformAdminUser(role = "SUPPORT", status = "ACTIVE") + + mockMvc + .post("/api/admin/support-access-grants") { + contentType = MediaType.APPLICATION_JSON + content = + """ + { + "clubId": "$TEST_CLUB_ID", + "granteeUserId": "$grantee", + "scope": "HOST_SUPPORT_READ", + "reason": "Customer escalation ticket #1234", + "expiresAt": "2099-01-01T12:00:00Z" + } + """.trimIndent() + cookie(sessionCookieForUser(support)) + }.andExpect { + status { isForbidden() } + } + } + + @Test + fun `operator cannot revoke support access grant`() { + val owner = createPlatformAdminUser(role = "OWNER", status = "ACTIVE") + val operator = createPlatformAdminUser(role = "OPERATOR", status = "ACTIVE") + val grantee = createPlatformAdminUser(role = "SUPPORT", status = "ACTIVE") + + val createResult = + mockMvc + .post("/api/admin/support-access-grants") { + contentType = MediaType.APPLICATION_JSON + content = + """ + { + "clubId": "$TEST_CLUB_ID", + "granteeUserId": "$grantee", + "scope": "HOST_SUPPORT_READ", + "reason": "Revoke permission test", + "expiresAt": "2099-01-01T12:00:00Z" + } + """.trimIndent() + cookie(sessionCookieForUser(owner)) + }.andExpect { + status { isOk() } + }.andReturn() + val grantId = checkNotNull(createResult.response.jsonPathValue("$.id")) + createdGrantIds += grantId + + mockMvc + .delete("/api/admin/support-access-grants/$grantId") { + cookie(sessionCookieForUser(operator)) + }.andExpect { + status { isForbidden() } + } + } + @Test fun `revoke audit event is written when grant is revoked`() { val owner = createPlatformAdminUser(role = "OWNER", status = "ACTIVE") From 71c56621d204d15053fc22c83ef40ceb8f1d15be Mon Sep 17 00:00:00 2001 From: kws Date: Sun, 17 May 2026 21:07:21 +0900 Subject: [PATCH 03/30] feat(admin): add triage workbench model Task: task_2 Risk: mid Files: front/features/platform-admin/model/platform-admin-workbench-model.ts, front/features/platform-admin/model/platform-admin-workbench-model.test.ts --- .../platform-admin-workbench-model.test.ts | 147 +++++++++ .../model/platform-admin-workbench-model.ts | 311 ++++++++++++++++++ 2 files changed, 458 insertions(+) create mode 100644 front/features/platform-admin/model/platform-admin-workbench-model.test.ts create mode 100644 front/features/platform-admin/model/platform-admin-workbench-model.ts diff --git a/front/features/platform-admin/model/platform-admin-workbench-model.test.ts b/front/features/platform-admin/model/platform-admin-workbench-model.test.ts new file mode 100644 index 00000000..7fef64e1 --- /dev/null +++ b/front/features/platform-admin/model/platform-admin-workbench-model.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from "vitest"; +import { + buildPlatformAdminWorkbench, + type PlatformAdminWorkbenchInput, +} from "@/features/platform-admin/model/platform-admin-workbench-model"; + +const baseInput: PlatformAdminWorkbenchInput = { + role: "OWNER", + activeClubCount: 3, + domainActionRequiredCount: 1, + selectedClubId: null, + clubs: [ + { + clubId: "club-ready", + slug: "ready-club", + name: "Ready Club", + tagline: "함께 읽는 클럽", + about: "공개 소개가 입력되어 있습니다.", + status: "ACTIVE", + publicVisibility: "PRIVATE", + domainCount: 0, + domainActionRequiredCount: 0, + firstHostOnboardingState: "ASSIGNED", + }, + { + clubId: "club-host-missing", + slug: "host-missing", + name: "Host Missing", + tagline: "준비 중", + about: "공개 소개가 입력되어 있습니다.", + status: "SETUP_REQUIRED", + publicVisibility: "PRIVATE", + domainCount: 0, + domainActionRequiredCount: 0, + firstHostOnboardingState: "MISSING", + }, + { + clubId: "club-public", + slug: "public-club", + name: "Public Club", + tagline: "공개 클럽", + about: "이미 공개된 클럽입니다.", + status: "ACTIVE", + publicVisibility: "PUBLIC", + domainCount: 1, + domainActionRequiredCount: 1, + firstHostOnboardingState: "ASSIGNED", + }, + ], + domains: [ + { + id: "domain-public", + clubId: "club-public", + hostname: "public.example.com", + kind: "SUBDOMAIN", + status: "FAILED", + desiredState: "ENABLED", + manualAction: "NONE", + errorCode: "DNS_NOT_CONNECTED", + isPrimary: false, + verifiedAt: null, + lastCheckedAt: null, + }, + ], +}; + +describe("platform admin workbench model", () => { + it("orders blocked clubs before ready and stable clubs", () => { + const workbench = buildPlatformAdminWorkbench(baseInput); + + expect(workbench.queueItems.map((item) => item.clubId)).toEqual([ + "club-host-missing", + "club-public", + "club-ready", + ]); + expect(workbench.selectedClub?.clubId).toBe("club-host-missing"); + }); + + it("builds publish checklist and primary action for a ready private club", () => { + const workbench = buildPlatformAdminWorkbench({ + ...baseInput, + selectedClubId: "club-ready", + }); + + expect(workbench.selectedClub?.publishChecklist.every((item) => item.passed)).toBe(true); + expect(workbench.selectedClub?.primaryAction).toEqual({ + kind: "make-public", + label: "공개 전환", + disabled: false, + reason: null, + }); + }); + + it("blocks publish when the first host is missing", () => { + const workbench = buildPlatformAdminWorkbench({ + ...baseInput, + selectedClubId: "club-host-missing", + }); + + expect(workbench.selectedClub?.publishChecklist).toContainEqual({ + id: "first-host", + label: "첫 호스트 지정", + passed: false, + detail: "첫 호스트가 아직 없습니다.", + }); + expect(workbench.selectedClub?.primaryAction.disabled).toBe(true); + }); + + it("exposes role capabilities separately from queue state", () => { + const owner = buildPlatformAdminWorkbench(baseInput); + const support = buildPlatformAdminWorkbench({ ...baseInput, role: "SUPPORT" }); + + expect(owner.permissions.canCreateSupportGrant).toBe(true); + expect(support.permissions.canCreateSupportGrant).toBe(false); + expect(support.permissions.canUpdateClub).toBe(false); + }); + + it("returns the 'none' primary action for ARCHIVED and SUSPENDED clubs", () => { + const archived = buildPlatformAdminWorkbench({ + ...baseInput, + selectedClubId: "club-archived", + clubs: [ + ...baseInput.clubs, + { + clubId: "club-archived", + slug: "archived-club", + name: "보관 클럽", + tagline: "tagline", + about: "about", + status: "ARCHIVED", + publicVisibility: "PRIVATE", + domainCount: 0, + domainActionRequiredCount: 0, + firstHostOnboardingState: "ASSIGNED", + }, + ], + }); + + expect(archived.selectedClub?.primaryAction.kind).toBe("none"); + expect(archived.selectedClub?.primaryAction.disabled).toBe(true); + }); + + it("picks the first queue item when selectedClubId is null", () => { + const workbench = buildPlatformAdminWorkbench({ ...baseInput, selectedClubId: null }); + expect(workbench.selectedClub?.clubId).toBe("club-host-missing"); + }); +}); diff --git a/front/features/platform-admin/model/platform-admin-workbench-model.ts b/front/features/platform-admin/model/platform-admin-workbench-model.ts new file mode 100644 index 00000000..ec0ab63a --- /dev/null +++ b/front/features/platform-admin/model/platform-admin-workbench-model.ts @@ -0,0 +1,311 @@ +export type PlatformAdminRole = "OWNER" | "OPERATOR" | "SUPPORT"; +export type PlatformAdminClubStatus = "SETUP_REQUIRED" | "ACTIVE" | "SUSPENDED" | "ARCHIVED"; +export type PlatformAdminClubPublicVisibility = "PRIVATE" | "PUBLIC"; +export type FirstHostOnboardingState = "MISSING" | "INVITED" | "ASSIGNED"; +export type PlatformAdminDomainStatus = + | "REQUESTED" + | "ACTION_REQUIRED" + | "PROVISIONING" + | "ACTIVE" + | "FAILED" + | "DISABLED"; + +export type WorkQueueSeverity = "blocked" | "attention" | "ready" | "stable"; +// Filter chips are deferred (see spec Non-Goals); severity ordering covers triage for the first pass. +// When added back, key filtering off typed signals (e.g., severity, a domainState field) — not badge strings. + +export type PlatformAdminWorkbenchClub = { + clubId: string; + slug: string; + name: string; + tagline: string; + about: string; + status: PlatformAdminClubStatus; + publicVisibility: PlatformAdminClubPublicVisibility; + domainCount: number; + domainActionRequiredCount: number; + firstHostOnboardingState: FirstHostOnboardingState; +}; + +export type PlatformAdminWorkbenchDomain = { + id: string; + clubId: string; + hostname: string; + kind: string; + status: PlatformAdminDomainStatus; + desiredState: string; + manualAction: string; + errorCode: string | null; + isPrimary: boolean; + verifiedAt: string | null; + lastCheckedAt: string | null; +}; + +export type PlatformAdminWorkbenchInput = { + role: PlatformAdminRole; + activeClubCount: number; + domainActionRequiredCount: number; + selectedClubId: string | null; + clubs: PlatformAdminWorkbenchClub[]; + domains: PlatformAdminWorkbenchDomain[]; +}; + +export type PlatformAdminPermissionView = { + canCreateClub: boolean; + canUpdateClub: boolean; + canManageDomains: boolean; + canCreateSupportGrant: boolean; + canRevokeSupportGrant: boolean; +}; + +export type PublishChecklistItem = { + id: "public-info" | "first-host" | "lifecycle" | "domains"; + label: string; + passed: boolean; + detail: string; +}; + +export type SelectedClubAction = + | { kind: "make-public"; label: string; disabled: boolean; reason: string | null } + | { kind: "make-private"; label: string; disabled: boolean; reason: string | null } + | { kind: "none"; label: string; disabled: true; reason: string }; + +export type PlatformAdminWorkQueueItem = { + clubId: string; + slug: string; + name: string; + severity: WorkQueueSeverity; + reason: string; + primaryActionLabel: string; + badges: string[]; + sortRank: number; +}; + +export type PlatformAdminSelectedClubBrief = PlatformAdminWorkbenchClub & { + domains: PlatformAdminWorkbenchDomain[]; + publishChecklist: PublishChecklistItem[]; + primaryAction: SelectedClubAction; + queueItem: PlatformAdminWorkQueueItem; +}; + +export type PlatformAdminWorkbenchView = { + permissions: PlatformAdminPermissionView; + metrics: { + platformRole: PlatformAdminRole; + activeClubCount: number; + needsActionCount: number; + domainActionRequiredCount: number; + publishReadyCount: number; + }; + queueItems: PlatformAdminWorkQueueItem[]; + selectedClub: PlatformAdminSelectedClubBrief | null; +}; + +export function buildPlatformAdminWorkbench(input: PlatformAdminWorkbenchInput): PlatformAdminWorkbenchView { + const domainsByClub = groupDomainsByClub(input.domains); + const queueItems = input.clubs + .map((club) => buildQueueItem(club, domainsByClub.get(club.clubId) ?? [])) + .sort((a, b) => a.sortRank - b.sortRank || a.name.localeCompare(b.name, "ko-KR")); + const selectedClubId = selectClubId(input.selectedClubId, queueItems); + const selectedClub = input.clubs.find((club) => club.clubId === selectedClubId) ?? null; + const selectedDomains = selectedClub ? domainsByClub.get(selectedClub.clubId) ?? [] : []; + const selectedQueueItem = queueItems.find((item) => item.clubId === selectedClub?.clubId) ?? null; + + return { + permissions: permissionsForRole(input.role), + metrics: { + platformRole: input.role, + activeClubCount: input.activeClubCount, + needsActionCount: queueItems.filter((item) => item.severity === "blocked" || item.severity === "attention").length, + domainActionRequiredCount: input.domainActionRequiredCount, + publishReadyCount: queueItems.filter((item) => item.primaryActionLabel === "공개 전환").length, + }, + queueItems, + selectedClub: selectedClub && selectedQueueItem + ? { + ...selectedClub, + domains: selectedDomains, + publishChecklist: buildPublishChecklist(selectedClub, selectedDomains), + primaryAction: buildPrimaryAction(selectedClub, selectedDomains), + queueItem: selectedQueueItem, + } + : null, + }; +} + +function permissionsForRole(role: PlatformAdminRole): PlatformAdminPermissionView { + const canOperate = role === "OWNER" || role === "OPERATOR"; + return { + canCreateClub: canOperate, + canUpdateClub: canOperate, + canManageDomains: canOperate, + canCreateSupportGrant: role === "OWNER", + canRevokeSupportGrant: role === "OWNER", + }; +} + +function buildQueueItem( + club: PlatformAdminWorkbenchClub, + domains: PlatformAdminWorkbenchDomain[], +): PlatformAdminWorkQueueItem { + const checklist = buildPublishChecklist(club, domains); + const failedDomain = domains.find((domain) => domain.status === "FAILED"); + const actionRequiredDomain = domains.find((domain) => domain.status === "ACTION_REQUIRED"); + const badges = [club.status, club.publicVisibility, `host ${club.firstHostOnboardingState}`]; + + if (failedDomain) { + return { + clubId: club.clubId, + slug: club.slug, + name: club.name, + severity: "attention", + reason: `${failedDomain.hostname} 도메인 확인이 실패했습니다.`, + primaryActionLabel: "도메인 확인", + badges: [...badges, "domain FAILED"], + sortRank: 20, + }; + } + + if (actionRequiredDomain) { + return { + clubId: club.clubId, + slug: club.slug, + name: club.name, + severity: "attention", + reason: `${actionRequiredDomain.hostname} 연결 작업이 필요합니다.`, + primaryActionLabel: "도메인 확인", + badges: [...badges, "domain ACTION_REQUIRED"], + sortRank: 30, + }; + } + + if (!checklist.every((item) => item.passed)) { + return { + clubId: club.clubId, + slug: club.slug, + name: club.name, + severity: "blocked", + reason: checklist.find((item) => !item.passed)?.detail ?? "공개 준비 조건을 확인해야 합니다.", + primaryActionLabel: "체크리스트", + badges, + sortRank: 10, + }; + } + + if (club.publicVisibility === "PRIVATE") { + return { + clubId: club.clubId, + slug: club.slug, + name: club.name, + severity: "ready", + reason: "공개 전환 조건을 충족했습니다.", + primaryActionLabel: "공개 전환", + badges, + sortRank: 40, + }; + } + + return { + clubId: club.clubId, + slug: club.slug, + name: club.name, + severity: "stable", + reason: "현재 공개 상태입니다.", + primaryActionLabel: "검토", + badges, + sortRank: 50, + }; +} + +function buildPublishChecklist( + club: PlatformAdminWorkbenchClub, + domains: PlatformAdminWorkbenchDomain[], +): PublishChecklistItem[] { + const hasPublicInfo = [club.name, club.tagline, club.about].every((value) => value.trim().length > 0); + const lifecycleAllowed = club.status !== "SUSPENDED" && club.status !== "ARCHIVED"; + const hasBlockingDomain = domains.some((domain) => domain.status === "FAILED"); + return [ + { + id: "public-info", + label: "공개 정보", + passed: hasPublicInfo, + detail: hasPublicInfo ? "이름, tagline, about이 입력되어 있습니다." : "공개 소개 정보가 비어 있습니다.", + }, + { + id: "first-host", + label: "첫 호스트 지정", + passed: club.firstHostOnboardingState === "ASSIGNED", + detail: hostStateDetail(club.firstHostOnboardingState), + }, + { + id: "lifecycle", + label: "운영 상태", + passed: lifecycleAllowed, + detail: lifecycleAllowed ? "공개 가능한 운영 상태입니다." : "정지 또는 보관 상태에서는 공개 전환하지 않습니다.", + }, + { + id: "domains", + label: "도메인 상태", + passed: !hasBlockingDomain, + detail: hasBlockingDomain ? "실패한 도메인 확인이 있습니다." : "도메인 실패가 없습니다.", + }, + ]; +} + +function buildPrimaryAction( + club: PlatformAdminWorkbenchClub, + domains: PlatformAdminWorkbenchDomain[], +): SelectedClubAction { + if (club.status === "SUSPENDED" || club.status === "ARCHIVED") { + return { + kind: "none", + label: "전환 불가", + disabled: true, + reason: club.status === "ARCHIVED" + ? "보관된 클럽은 공개/비공개 전환 대상이 아닙니다." + : "정지된 클럽은 공개/비공개 전환 대상이 아닙니다.", + }; + } + + const checklist = buildPublishChecklist(club, domains); + const failed = checklist.find((item) => !item.passed); + + if (club.publicVisibility === "PUBLIC") { + return { kind: "make-private", label: "비공개 전환", disabled: false, reason: null }; + } + + if (failed) { + return { kind: "make-public", label: "공개 전환", disabled: true, reason: failed.detail }; + } + + return { kind: "make-public", label: "공개 전환", disabled: false, reason: null }; +} + +function groupDomainsByClub(domains: PlatformAdminWorkbenchDomain[]): Map { + const grouped = new Map(); + for (const domain of domains) { + grouped.set(domain.clubId, [...(grouped.get(domain.clubId) ?? []), domain]); + } + return grouped; +} + +function selectClubId( + requestedClubId: string | null, + queueItems: PlatformAdminWorkQueueItem[], +): string | null { + if (requestedClubId && queueItems.some((item) => item.clubId === requestedClubId)) { + return requestedClubId; + } + return queueItems[0]?.clubId ?? null; +} + +function hostStateDetail(state: FirstHostOnboardingState): string { + switch (state) { + case "ASSIGNED": + return "첫 호스트가 지정되어 있습니다."; + case "INVITED": + return "첫 호스트 초대 수락을 기다리고 있습니다."; + case "MISSING": + return "첫 호스트가 아직 없습니다."; + } +} From c17e6eb47304d997015660e67a92db4689cf7921 Mon Sep 17 00:00:00 2001 From: kws Date: Sun, 17 May 2026 21:09:16 +0900 Subject: [PATCH 04/30] refactor(server): remove standalone feedback uploads Task: task_1 Risk: high Files: - server/src/main/kotlin/com/readmates/feedback/adapter/in/web/FeedbackDocumentController.kt - server/src/main/kotlin/com/readmates/feedback/adapter/in/web/FeedbackDocumentUploadValidator.kt (deleted) - server/src/main/kotlin/com/readmates/feedback/adapter/out/persistence/JdbcFeedbackDocumentStoreAdapter.kt - server/src/main/kotlin/com/readmates/feedback/application/model/FeedbackDocumentCommands.kt (deleted) - server/src/main/kotlin/com/readmates/feedback/application/port/in/FeedbackDocumentUseCases.kt - server/src/main/kotlin/com/readmates/feedback/application/port/out/FeedbackDocumentStorePort.kt - server/src/main/kotlin/com/readmates/feedback/application/service/FeedbackDocumentService.kt - server/src/test/kotlin/com/readmates/feedback/api/FeedbackDocumentControllerTest.kt Removes the POST /api/host/sessions/{sessionId}/feedback-document upload endpoint along with UploadHostFeedbackDocumentUseCase, AuthorizeHostFeedbackDocumentUploadUseCase, FeedbackDocumentUploadValidator, and FeedbackDocumentUploadCommand. FeedbackDocumentService now only handles read/list/status surfaces, so its RecordNotificationEventUseCase and ReadmatesOperationalMetrics deps are dropped. The sole source of FEEDBACK_DOCUMENT_PUBLISHED notifications is now SessionImportService.commitVerifiedTarget. Co-Authored-By: Claude Opus 4.7 --- .../in/web/FeedbackDocumentController.kt | 38 -- .../in/web/FeedbackDocumentUploadValidator.kt | 98 ------ .../JdbcFeedbackDocumentStoreAdapter.kt | 69 ---- .../model/FeedbackDocumentCommands.kt | 11 - .../port/in/FeedbackDocumentUseCases.kt | 12 - .../port/out/FeedbackDocumentStorePort.kt | 19 - .../service/FeedbackDocumentService.kt | 59 +--- .../api/FeedbackDocumentControllerTest.kt | 324 +----------------- 8 files changed, 5 insertions(+), 625 deletions(-) delete mode 100644 server/src/main/kotlin/com/readmates/feedback/adapter/in/web/FeedbackDocumentUploadValidator.kt delete mode 100644 server/src/main/kotlin/com/readmates/feedback/application/model/FeedbackDocumentCommands.kt diff --git a/server/src/main/kotlin/com/readmates/feedback/adapter/in/web/FeedbackDocumentController.kt b/server/src/main/kotlin/com/readmates/feedback/adapter/in/web/FeedbackDocumentController.kt index f347dfa3..1db38ea4 100644 --- a/server/src/main/kotlin/com/readmates/feedback/adapter/in/web/FeedbackDocumentController.kt +++ b/server/src/main/kotlin/com/readmates/feedback/adapter/in/web/FeedbackDocumentController.kt @@ -1,22 +1,15 @@ package com.readmates.feedback.adapter.`in`.web -import com.readmates.feedback.application.model.FeedbackDocumentUploadCommand -import com.readmates.feedback.application.port.`in`.AuthorizeHostFeedbackDocumentUploadUseCase import com.readmates.feedback.application.port.`in`.GetHostFeedbackDocumentStatusUseCase import com.readmates.feedback.application.port.`in`.GetReadableFeedbackDocumentUseCase import com.readmates.feedback.application.port.`in`.ListMyReadableFeedbackDocumentsUseCase -import com.readmates.feedback.application.port.`in`.UploadHostFeedbackDocumentUseCase import com.readmates.shared.paging.PageRequest import com.readmates.shared.security.CurrentMember import org.springframework.http.HttpStatus -import org.springframework.http.MediaType import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable -import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestParam -import org.springframework.web.bind.annotation.ResponseStatus import org.springframework.web.bind.annotation.RestController -import org.springframework.web.multipart.MultipartFile import org.springframework.web.server.ResponseStatusException import java.util.UUID @@ -25,9 +18,6 @@ class FeedbackDocumentController( private val listMyReadableFeedbackDocumentsUseCase: ListMyReadableFeedbackDocumentsUseCase, private val getReadableFeedbackDocumentUseCase: GetReadableFeedbackDocumentUseCase, private val getHostFeedbackDocumentStatusUseCase: GetHostFeedbackDocumentStatusUseCase, - private val authorizeHostFeedbackDocumentUploadUseCase: AuthorizeHostFeedbackDocumentUploadUseCase, - private val uploadHostFeedbackDocumentUseCase: UploadHostFeedbackDocumentUseCase, - private val feedbackDocumentUploadValidator: FeedbackDocumentUploadValidator, ) { @GetMapping("/api/feedback-documents/me") fun myFeedbackDocuments( @@ -60,34 +50,6 @@ class FeedbackDocumentController( .getHostFeedbackDocumentStatus(currentMember, parseSessionId(sessionId)) .toWebDto() - @PostMapping( - "/api/host/sessions/{sessionId}/feedback-document", - consumes = [MediaType.MULTIPART_FORM_DATA_VALUE], - ) - @ResponseStatus(HttpStatus.CREATED) - fun uploadFeedbackDocument( - currentMember: CurrentMember, - @PathVariable sessionId: String, - @RequestParam("file") file: MultipartFile, - ): FeedbackDocumentResponse { - val sessionUuid = parseSessionId(sessionId) - authorizeHostFeedbackDocumentUploadUseCase.authorizeHostFeedbackDocumentUpload(currentMember) - val upload = feedbackDocumentUploadValidator.validate(file) - - return uploadHostFeedbackDocumentUseCase - .uploadHostFeedbackDocument( - currentMember = currentMember, - command = - FeedbackDocumentUploadCommand( - sessionId = sessionUuid, - fileName = upload.fileName, - contentType = upload.contentType, - sourceText = upload.sourceText, - fileSize = upload.fileSize, - ), - ).toWebDto() - } - private fun parseSessionId(value: String): UUID = runCatching { UUID.fromString(value) } .getOrElse { throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid session id") } diff --git a/server/src/main/kotlin/com/readmates/feedback/adapter/in/web/FeedbackDocumentUploadValidator.kt b/server/src/main/kotlin/com/readmates/feedback/adapter/in/web/FeedbackDocumentUploadValidator.kt deleted file mode 100644 index 5f89f08c..00000000 --- a/server/src/main/kotlin/com/readmates/feedback/adapter/in/web/FeedbackDocumentUploadValidator.kt +++ /dev/null @@ -1,98 +0,0 @@ -package com.readmates.feedback.adapter.`in`.web - -import org.springframework.http.HttpStatus -import org.springframework.stereotype.Component -import org.springframework.web.multipart.MultipartFile -import org.springframework.web.server.ResponseStatusException -import java.nio.ByteBuffer -import java.nio.charset.CharacterCodingException -import java.nio.charset.CodingErrorAction -import java.nio.charset.StandardCharsets -import java.util.Locale - -data class ValidatedFeedbackDocumentUpload( - val fileName: String, - val contentType: String, - val sourceText: String, - val fileSize: Long, -) - -@Component -class FeedbackDocumentUploadValidator { - fun validate(file: MultipartFile): ValidatedFeedbackDocumentUpload { - val storedFileName = validatedFileName(file.originalFilename) - val contentType = contentTypeFor(storedFileName) - val sourceText = decodeUtf8(file) - if (sourceText.isBlank()) { - throw ResponseStatusException(HttpStatus.BAD_REQUEST, EMPTY_DOCUMENT_MESSAGE) - } - - return ValidatedFeedbackDocumentUpload( - fileName = storedFileName, - contentType = contentType, - sourceText = sourceText, - fileSize = file.size, - ) - } - - private fun validatedFileName(originalFilename: String?): String { - val fileName = - originalFilename - ?.trim() - ?.takeIf { it.isNotEmpty() } - ?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, UNSUPPORTED_FILE_MESSAGE) - if ( - fileName.length > MAX_FILE_NAME_LENGTH || - fileName.contains('/') || - fileName.contains('\\') || - fileName.contains(NUL) - ) { - throw ResponseStatusException(HttpStatus.BAD_REQUEST, UNSUPPORTED_FILE_MESSAGE) - } - val lowercaseFileName = fileName.lowercase(Locale.ROOT) - if (!lowercaseFileName.endsWith(".md") && !lowercaseFileName.endsWith(".txt")) { - throw ResponseStatusException(HttpStatus.BAD_REQUEST, UNSUPPORTED_FILE_MESSAGE) - } - return fileName - } - - private fun contentTypeFor(fileName: String): String = - when { - fileName.lowercase(Locale.ROOT).endsWith(".md") -> "text/markdown" - fileName.lowercase(Locale.ROOT).endsWith(".txt") -> "text/plain" - else -> throw ResponseStatusException(HttpStatus.BAD_REQUEST, UNSUPPORTED_FILE_MESSAGE) - } - - private fun decodeUtf8(file: MultipartFile): String { - if (file.isEmpty || file.size <= 0) { - throw ResponseStatusException(HttpStatus.BAD_REQUEST, EMPTY_DOCUMENT_MESSAGE) - } - if (file.size > MAX_FILE_SIZE_BYTES) { - throw ResponseStatusException(HttpStatus.BAD_REQUEST, "피드백 문서는 512KB 이하만 업로드할 수 있습니다.") - } - - val sourceText = - try { - StandardCharsets.UTF_8 - .newDecoder() - .onMalformedInput(CodingErrorAction.REPORT) - .onUnmappableCharacter(CodingErrorAction.REPORT) - .decode(ByteBuffer.wrap(file.bytes)) - .toString() - } catch (ex: CharacterCodingException) { - throw ResponseStatusException(HttpStatus.BAD_REQUEST, "문서를 UTF-8 텍스트로 읽을 수 없습니다.") - } - if (sourceText.contains(NUL)) { - throw ResponseStatusException(HttpStatus.BAD_REQUEST, "문서를 UTF-8 텍스트로 읽을 수 없습니다.") - } - return sourceText - } - - private companion object { - private const val MAX_FILE_SIZE_BYTES = 512L * 1024L - private const val MAX_FILE_NAME_LENGTH = 255 - private const val NUL = '\u0000' - private const val EMPTY_DOCUMENT_MESSAGE = "피드백 문서가 비어 있습니다." - private const val UNSUPPORTED_FILE_MESSAGE = ".md 또는 .txt 파일만 업로드할 수 있습니다." - } -} diff --git a/server/src/main/kotlin/com/readmates/feedback/adapter/out/persistence/JdbcFeedbackDocumentStoreAdapter.kt b/server/src/main/kotlin/com/readmates/feedback/adapter/out/persistence/JdbcFeedbackDocumentStoreAdapter.kt index d2997c49..061ca6a9 100644 --- a/server/src/main/kotlin/com/readmates/feedback/adapter/out/persistence/JdbcFeedbackDocumentStoreAdapter.kt +++ b/server/src/main/kotlin/com/readmates/feedback/adapter/out/persistence/JdbcFeedbackDocumentStoreAdapter.kt @@ -1,7 +1,6 @@ package com.readmates.feedback.adapter.out.persistence import com.readmates.feedback.application.model.FeedbackDocumentSessionResult -import com.readmates.feedback.application.model.FeedbackDocumentUploadCommand import com.readmates.feedback.application.model.StoredFeedbackDocumentListResult import com.readmates.feedback.application.model.StoredFeedbackDocumentResult import com.readmates.feedback.application.port.out.FeedbackDocumentStorePort @@ -211,74 +210,6 @@ class JdbcFeedbackDocumentStoreAdapter( sessionId.dbString(), ).firstOrNull() - override fun findSessionForUpload( - clubId: UUID, - sessionId: UUID, - ): FeedbackDocumentSessionResult? = - jdbcTemplate - .query( - """ - select id, number, book_title, session_date - from sessions - where id = ? - and club_id = ? - for update - """.trimIndent(), - { resultSet, _ -> resultSet.toSessionMetadata() }, - sessionId.dbString(), - clubId.dbString(), - ).firstOrNull() - - override fun nextDocumentVersion( - clubId: UUID, - sessionId: UUID, - ): Int = - jdbcTemplate.queryForObject( - """ - select coalesce(max(version), 0) + 1 - from session_feedback_documents - where club_id = ? - and session_id = ? - """.trimIndent(), - Int::class.java, - clubId.dbString(), - sessionId.dbString(), - ) ?: 1 - - override fun insertDocument( - currentMember: CurrentMember, - command: FeedbackDocumentUploadCommand, - version: Int, - documentId: UUID, - title: String, - ) { - jdbcTemplate.update( - """ - insert into session_feedback_documents ( - id, - club_id, - session_id, - version, - source_text, - document_title, - file_name, - content_type, - file_size - ) - values (?, ?, ?, ?, ?, ?, ?, ?, ?) - """.trimIndent(), - documentId.dbString(), - currentMember.clubId.dbString(), - command.sessionId.dbString(), - version, - command.sourceText, - title, - command.fileName, - command.contentType, - command.fileSize, - ) - } - private fun ResultSet.toStoredFeedbackDocumentList(): StoredFeedbackDocumentListResult = StoredFeedbackDocumentListResult( documentId = uuid("document_id"), diff --git a/server/src/main/kotlin/com/readmates/feedback/application/model/FeedbackDocumentCommands.kt b/server/src/main/kotlin/com/readmates/feedback/application/model/FeedbackDocumentCommands.kt deleted file mode 100644 index aa603b52..00000000 --- a/server/src/main/kotlin/com/readmates/feedback/application/model/FeedbackDocumentCommands.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.readmates.feedback.application.model - -import java.util.UUID - -data class FeedbackDocumentUploadCommand( - val sessionId: UUID, - val fileName: String, - val contentType: String, - val sourceText: String, - val fileSize: Long, -) diff --git a/server/src/main/kotlin/com/readmates/feedback/application/port/in/FeedbackDocumentUseCases.kt b/server/src/main/kotlin/com/readmates/feedback/application/port/in/FeedbackDocumentUseCases.kt index 01b78674..672f0ecc 100644 --- a/server/src/main/kotlin/com/readmates/feedback/application/port/in/FeedbackDocumentUseCases.kt +++ b/server/src/main/kotlin/com/readmates/feedback/application/port/in/FeedbackDocumentUseCases.kt @@ -3,7 +3,6 @@ package com.readmates.feedback.application.port.`in` import com.readmates.feedback.application.model.FeedbackDocumentListItemResult import com.readmates.feedback.application.model.FeedbackDocumentResult import com.readmates.feedback.application.model.FeedbackDocumentStatusResult -import com.readmates.feedback.application.model.FeedbackDocumentUploadCommand import com.readmates.shared.paging.CursorPage import com.readmates.shared.paging.PageRequest import com.readmates.shared.security.CurrentMember @@ -29,14 +28,3 @@ interface GetHostFeedbackDocumentStatusUseCase { sessionId: UUID, ): FeedbackDocumentStatusResult } - -interface AuthorizeHostFeedbackDocumentUploadUseCase { - fun authorizeHostFeedbackDocumentUpload(currentMember: CurrentMember) -} - -interface UploadHostFeedbackDocumentUseCase { - fun uploadHostFeedbackDocument( - currentMember: CurrentMember, - command: FeedbackDocumentUploadCommand, - ): FeedbackDocumentResult -} diff --git a/server/src/main/kotlin/com/readmates/feedback/application/port/out/FeedbackDocumentStorePort.kt b/server/src/main/kotlin/com/readmates/feedback/application/port/out/FeedbackDocumentStorePort.kt index 99e5a4a6..46867545 100644 --- a/server/src/main/kotlin/com/readmates/feedback/application/port/out/FeedbackDocumentStorePort.kt +++ b/server/src/main/kotlin/com/readmates/feedback/application/port/out/FeedbackDocumentStorePort.kt @@ -1,7 +1,6 @@ package com.readmates.feedback.application.port.out import com.readmates.feedback.application.model.FeedbackDocumentSessionResult -import com.readmates.feedback.application.model.FeedbackDocumentUploadCommand import com.readmates.feedback.application.model.StoredFeedbackDocumentListResult import com.readmates.feedback.application.model.StoredFeedbackDocumentResult import com.readmates.shared.paging.CursorPage @@ -29,22 +28,4 @@ interface FeedbackDocumentStorePort { clubId: UUID, sessionId: UUID, ): StoredFeedbackDocumentResult? - - fun findSessionForUpload( - clubId: UUID, - sessionId: UUID, - ): FeedbackDocumentSessionResult? - - fun nextDocumentVersion( - clubId: UUID, - sessionId: UUID, - ): Int - - fun insertDocument( - currentMember: CurrentMember, - command: FeedbackDocumentUploadCommand, - version: Int, - documentId: UUID, - title: String, - ) } diff --git a/server/src/main/kotlin/com/readmates/feedback/application/service/FeedbackDocumentService.kt b/server/src/main/kotlin/com/readmates/feedback/application/service/FeedbackDocumentService.kt index e8ff997c..14042873 100644 --- a/server/src/main/kotlin/com/readmates/feedback/application/service/FeedbackDocumentService.kt +++ b/server/src/main/kotlin/com/readmates/feedback/application/service/FeedbackDocumentService.kt @@ -8,39 +8,29 @@ import com.readmates.feedback.application.model.FeedbackDocumentListItemResult import com.readmates.feedback.application.model.FeedbackDocumentResult import com.readmates.feedback.application.model.FeedbackDocumentSessionResult import com.readmates.feedback.application.model.FeedbackDocumentStatusResult -import com.readmates.feedback.application.model.FeedbackDocumentUploadCommand import com.readmates.feedback.application.model.FeedbackMetadataItemResult import com.readmates.feedback.application.model.FeedbackParticipantResult import com.readmates.feedback.application.model.FeedbackProblemResult import com.readmates.feedback.application.model.FeedbackRevealingQuoteResult import com.readmates.feedback.application.model.StoredFeedbackDocumentListResult import com.readmates.feedback.application.model.StoredFeedbackDocumentResult -import com.readmates.feedback.application.port.`in`.AuthorizeHostFeedbackDocumentUploadUseCase import com.readmates.feedback.application.port.`in`.GetHostFeedbackDocumentStatusUseCase import com.readmates.feedback.application.port.`in`.GetReadableFeedbackDocumentUseCase import com.readmates.feedback.application.port.`in`.ListMyReadableFeedbackDocumentsUseCase -import com.readmates.feedback.application.port.`in`.UploadHostFeedbackDocumentUseCase import com.readmates.feedback.application.port.out.FeedbackDocumentStorePort -import com.readmates.notification.application.port.`in`.RecordNotificationEventUseCase -import com.readmates.notification.application.service.ReadmatesOperationalMetrics import com.readmates.shared.paging.CursorPage import com.readmates.shared.paging.PageRequest import com.readmates.shared.security.AccessDeniedException import com.readmates.shared.security.CurrentMember import org.springframework.stereotype.Service -import org.springframework.transaction.annotation.Transactional import java.util.UUID @Service class FeedbackDocumentService( private val feedbackDocumentStorePort: FeedbackDocumentStorePort, - private val recordNotificationEventUseCase: RecordNotificationEventUseCase, - private val operationalMetrics: ReadmatesOperationalMetrics, ) : ListMyReadableFeedbackDocumentsUseCase, GetReadableFeedbackDocumentUseCase, - GetHostFeedbackDocumentStatusUseCase, - AuthorizeHostFeedbackDocumentUploadUseCase, - UploadHostFeedbackDocumentUseCase { + GetHostFeedbackDocumentStatusUseCase { private val parser = FeedbackDocumentParser() override fun listMyReadableFeedbackDocuments( @@ -90,7 +80,7 @@ class FeedbackDocumentService( currentMember: CurrentMember, sessionId: UUID, ): FeedbackDocumentStatusResult { - requireHostFeedbackDocumentUploadAccess(currentMember) + requireHostFeedbackDocumentStatusAccess(currentMember) feedbackDocumentStorePort.findReadableSession(currentMember.clubId, sessionId) ?: throw FeedbackDocumentException(FeedbackDocumentError.NOT_FOUND, "Feedback session not found") @@ -102,50 +92,7 @@ class FeedbackDocumentService( ) } - override fun authorizeHostFeedbackDocumentUpload(currentMember: CurrentMember) = requireHostFeedbackDocumentUploadAccess(currentMember) - - @Transactional - override fun uploadHostFeedbackDocument( - currentMember: CurrentMember, - command: FeedbackDocumentUploadCommand, - ): FeedbackDocumentResult { - requireHostFeedbackDocumentUploadAccess(currentMember) - return runCatching { - val parsedDocument = parser.parse(command.sourceText) - val session = - feedbackDocumentStorePort.findSessionForUpload(currentMember.clubId, command.sessionId) - ?: throw FeedbackDocumentException(FeedbackDocumentError.NOT_FOUND, "Feedback session not found") - val version = feedbackDocumentStorePort.nextDocumentVersion(currentMember.clubId, command.sessionId) - feedbackDocumentStorePort.insertDocument( - currentMember = currentMember, - command = command, - version = version, - documentId = UUID.randomUUID(), - title = parsedDocument.title, - ) - recordNotificationEventUseCase.recordFeedbackDocumentPublished( - clubId = currentMember.clubId, - sessionId = command.sessionId, - sessionNumber = session.sessionNumber, - bookTitle = session.bookTitle, - documentVersion = version, - ) - - val storedDocument = - feedbackDocumentStorePort.findLatestDocument(currentMember.clubId, command.sessionId) - ?: throw FeedbackDocumentException( - FeedbackDocumentError.STORAGE_UNAVAILABLE, - "Stored feedback document not found after upload", - ) - storedDocument.toResponse(session, parsedDocument) - }.onSuccess { - operationalMetrics.feedbackUploadSucceeded() - }.onFailure { - operationalMetrics.feedbackUploadFailed() - }.getOrThrow() - } - - private fun requireHostFeedbackDocumentUploadAccess(currentMember: CurrentMember) { + private fun requireHostFeedbackDocumentStatusAccess(currentMember: CurrentMember) { if (!currentMember.isHost) { throw AccessDeniedException("Host role required") } diff --git a/server/src/test/kotlin/com/readmates/feedback/api/FeedbackDocumentControllerTest.kt b/server/src/test/kotlin/com/readmates/feedback/api/FeedbackDocumentControllerTest.kt index 5a6fdcb5..3d2d85d0 100644 --- a/server/src/test/kotlin/com/readmates/feedback/api/FeedbackDocumentControllerTest.kt +++ b/server/src/test/kotlin/com/readmates/feedback/api/FeedbackDocumentControllerTest.kt @@ -1,7 +1,6 @@ package com.readmates.feedback.api import com.readmates.support.ReadmatesMySqlIntegrationTestSupport -import org.assertj.core.api.Assertions.assertThat import org.hamcrest.Matchers.hasItem import org.hamcrest.Matchers.not import org.junit.jupiter.api.Tag @@ -347,274 +346,12 @@ class FeedbackDocumentControllerTest( } @Test - fun `host uploads markdown feedback document without csrf and receives parsed title`() { - mockMvc - .multipart("/api/host/sessions/00000000-0000-0000-0000-000000000306/feedback-document") { - with(user("host@example.com")) - file(validMarkdownFile()) - }.andExpect { - status { isCreated() } - jsonPath("$.title") { value("독서모임 6차 피드백") } - } - } - - @Test - fun `uploaded feedback document stores title for list projection`() { - mockMvc - .multipart("/api/host/sessions/00000000-0000-0000-0000-000000000306/feedback-document") { - with(user("host@example.com")) - file(validMarkdownFile()) - }.andExpect { - status { isCreated() } - jsonPath("$.title") { value("독서모임 6차 피드백") } - } - - mockMvc - .get("/api/feedback-documents/me") { - with(user("host@example.com")) - }.andExpect { - status { isOk() } - jsonPath("$.items[?(@.sessionNumber == 6)].title") { value(hasItem("독서모임 6차 피드백")) } - } - - assertThat( - jdbcTemplate.queryForObject( - """ - select document_title - from session_feedback_documents - where session_id = '00000000-0000-0000-0000-000000000306' - order by version desc - limit 1 - """.trimIndent(), - String::class.java, - ), - ).isEqualTo("독서모임 6차 피드백") - } - - @Test - fun `host feedback upload enqueues attendee notification`() { - mockMvc - .multipart("/api/host/sessions/00000000-0000-0000-0000-000000000306/feedback-document") { - with(user("host@example.com")) - file(validMarkdownFile()) - }.andExpect { - status { isCreated() } - } - - val event = - jdbcTemplate.queryForMap( - """ - select - dedupe_key, - json_unquote(json_extract(payload_json, '$.sessionId')) as session_id, - cast(json_unquote(json_extract(payload_json, '$.sessionNumber')) as signed) as session_number, - json_unquote(json_extract(payload_json, '$.bookTitle')) as book_title, - cast(json_unquote(json_extract(payload_json, '$.documentVersion')) as signed) as document_version - from notification_event_outbox - where event_type = 'FEEDBACK_DOCUMENT_PUBLISHED' - and aggregate_id = '00000000-0000-0000-0000-000000000306' - """.trimIndent(), - ) - - val documentVersion = - jdbcTemplate.queryForObject( - """ - select max(version) - from session_feedback_documents - where session_id = '00000000-0000-0000-0000-000000000306' - """.trimIndent(), - Int::class.java, - ) - - assertThat(event["dedupe_key"]).isEqualTo( - "feedback-document:00000000-0000-0000-0000-000000000306:$documentVersion", - ) - assertThat(event["session_id"]).isEqualTo("00000000-0000-0000-0000-000000000306") - assertThat((event["session_number"] as Number).toInt()).isEqualTo(6) - assertThat(event["book_title"]).isEqualTo("가난한 찰리의 연감") - assertThat((event["document_version"] as Number).toInt()).isEqualTo(documentVersion) - } - - @Test - fun `latest uploaded feedback document version wins for read list and status`() { - mockMvc - .multipart("/api/host/sessions/00000000-0000-0000-0000-000000000306/feedback-document") { - with(user("host@example.com")) - file(validMarkdownFile()) - }.andExpect { - status { isCreated() } - jsonPath("$.fileName") { value("feedback-6-test.md") } - } - - mockMvc - .get("/api/sessions/00000000-0000-0000-0000-000000000306/feedback-document") { - with(user("host@example.com")) - }.andExpect { - status { isOk() } - jsonPath("$.title") { value("독서모임 6차 피드백") } - jsonPath("$.fileName") { value("feedback-6-test.md") } - } - - mockMvc - .get("/api/feedback-documents/me") { - with(user("host@example.com")) - }.andExpect { - status { isOk() } - jsonPath("$.items[?(@.sessionNumber == 6)].fileName") { value(hasItem("feedback-6-test.md")) } - } - - mockMvc - .get("/api/host/sessions/00000000-0000-0000-0000-000000000306/feedback-document") { - with(user("host@example.com")) - }.andExpect { - status { isOk() } - jsonPath("$.uploaded") { value(true) } - jsonPath("$.fileName") { value("feedback-6-test.md") } - } - } - - @Test - fun `host upload with missing marker returns bad request`() { - val file = - MockMultipartFile( - "file", - "feedback-6-missing-marker.md", - "text/markdown", - validFeedbackMarkdown() - .replace("\n\n", "") - .toByteArray(StandardCharsets.UTF_8), - ) - - mockMvc - .multipart("/api/host/sessions/00000000-0000-0000-0000-000000000306/feedback-document") { - with(user("host@example.com")) - file(file) - }.andExpect { - status { isBadRequest() } - } - } - - @Test - fun `host upload rejects unsupported extension`() { - val file = - MockMultipartFile( - "file", - "feedback-6.pdf", - "application/pdf", - validFeedbackMarkdown().toByteArray(StandardCharsets.UTF_8), - ) - - mockMvc - .multipart("/api/host/sessions/00000000-0000-0000-0000-000000000306/feedback-document") { - with(user("host@example.com")) - file(file) - }.andExpect { - status { isBadRequest() } - } - } - - @Test - fun `host upload rejects empty file`() { - val file = - MockMultipartFile( - "file", - "feedback-6.md", - "text/markdown", - ByteArray(0), - ) - - mockMvc - .multipart("/api/host/sessions/00000000-0000-0000-0000-000000000306/feedback-document") { - with(user("host@example.com")) - file(file) - }.andExpect { - status { isBadRequest() } - } - } - - @Test - fun `host upload rejects invalid utf8`() { - val file = - MockMultipartFile( - "file", - "feedback-6.md", - "text/markdown", - byteArrayOf(0xC3.toByte(), 0x28), - ) - - mockMvc - .multipart("/api/host/sessions/00000000-0000-0000-0000-000000000306/feedback-document") { - with(user("host@example.com")) - file(file) - }.andExpect { - status { isBadRequest() } - } - } - - @Test - fun `host upload rejects too large file`() { + fun `host feedback document upload endpoint is removed`() { val file = MockMultipartFile( "file", "feedback-6.md", "text/markdown", - ByteArray((512 * 1024) + 1) { 'a'.code.toByte() }, - ) - - mockMvc - .multipart("/api/host/sessions/00000000-0000-0000-0000-000000000306/feedback-document") { - with(user("host@example.com")) - file(file) - }.andExpect { - status { isBadRequest() } - } - } - - @Test - fun `host upload rejects long filename before persistence`() { - val file = - MockMultipartFile( - "file", - "${"a".repeat(253)}.md", - "text/markdown", - validFeedbackMarkdown().toByteArray(StandardCharsets.UTF_8), - ) - - mockMvc - .multipart("/api/host/sessions/00000000-0000-0000-0000-000000000306/feedback-document") { - with(user("host@example.com")) - file(file) - }.andExpect { - status { isBadRequest() } - } - } - - @Test - fun `host upload rejects slash filename before persistence`() { - val file = - MockMultipartFile( - "file", - "../feedback-6.md", - "text/markdown", - validFeedbackMarkdown().toByteArray(StandardCharsets.UTF_8), - ) - - mockMvc - .multipart("/api/host/sessions/00000000-0000-0000-0000-000000000306/feedback-document") { - with(user("host@example.com")) - file(file) - }.andExpect { - status { isBadRequest() } - } - } - - @Test - fun `host upload rejects backslash filename before persistence`() { - val file = - MockMultipartFile( - "file", - "..\\feedback-6.md", - "text/markdown", validFeedbackMarkdown().toByteArray(StandardCharsets.UTF_8), ) @@ -623,56 +360,7 @@ class FeedbackDocumentControllerTest( with(user("host@example.com")) file(file) }.andExpect { - status { isBadRequest() } - } - } - - @Test - fun `host upload rejects nul source text before persistence`() { - val file = - MockMultipartFile( - "file", - "feedback-6.md", - "text/markdown", - (validFeedbackMarkdown() + "\u0000").toByteArray(StandardCharsets.UTF_8), - ) - - mockMvc - .multipart("/api/host/sessions/00000000-0000-0000-0000-000000000306/feedback-document") { - with(user("host@example.com")) - file(file) - }.andExpect { - status { isBadRequest() } - } - } - - @Test - fun `member cannot upload markdown feedback document`() { - mockMvc - .multipart("/api/host/sessions/00000000-0000-0000-0000-000000000306/feedback-document") { - with(user("member5@example.com")) - file(validMarkdownFile()) - }.andExpect { - status { isForbidden() } - } - } - - @Test - fun `member upload rejects before reading malformed file`() { - val file = - MockMultipartFile( - "file", - "feedback-6.md", - "text/markdown", - byteArrayOf(0xC3.toByte(), 0x28), - ) - - mockMvc - .multipart("/api/host/sessions/00000000-0000-0000-0000-000000000306/feedback-document") { - with(user("member5@example.com")) - file(file) - }.andExpect { - status { isForbidden() } + status { isMethodNotAllowed() } } } @@ -753,14 +441,6 @@ class FeedbackDocumentControllerTest( ) } - private fun validMarkdownFile(): MockMultipartFile = - MockMultipartFile( - "file", - "feedback-6-test.md", - "text/markdown", - validFeedbackMarkdown().toByteArray(StandardCharsets.UTF_8), - ) - private fun validFeedbackMarkdown(): String = """ From 2b1c879e5e57b57df36458e9bdc52873a4639d98 Mon Sep 17 00:00:00 2001 From: kws Date: Sun, 17 May 2026 21:22:10 +0900 Subject: [PATCH 05/30] feat(admin): render triage console with selected-club support grants Task: task_3, task_4 Risk: high Files: route, dashboard, work-queue, brief, checklist, domain panel, overview metrics, support grants panel, club-detail, platform-admin.test.tsx --- .../route/platform-admin-route.tsx | 77 ++++- .../ui/club-operations-brief.tsx | 102 ++++++ .../ui/club-publish-checklist.tsx | 58 ++++ .../ui/domain-provisioning-panel.tsx | 119 +++++++ .../ui/platform-admin-club-detail.tsx | 8 +- .../ui/platform-admin-dashboard.tsx | 225 +++--------- .../ui/platform-admin-overview-metrics.tsx | 25 ++ .../ui/platform-admin-work-queue.tsx | 53 +++ .../ui/support-access-grants-panel.tsx | 65 ++-- front/tests/unit/platform-admin.test.tsx | 323 ++++++++++++------ 10 files changed, 745 insertions(+), 310 deletions(-) create mode 100644 front/features/platform-admin/ui/club-operations-brief.tsx create mode 100644 front/features/platform-admin/ui/club-publish-checklist.tsx create mode 100644 front/features/platform-admin/ui/domain-provisioning-panel.tsx create mode 100644 front/features/platform-admin/ui/platform-admin-overview-metrics.tsx create mode 100644 front/features/platform-admin/ui/platform-admin-work-queue.tsx diff --git a/front/features/platform-admin/route/platform-admin-route.tsx b/front/features/platform-admin/route/platform-admin-route.tsx index 1f93c0e6..236ffbe5 100644 --- a/front/features/platform-admin/route/platform-admin-route.tsx +++ b/front/features/platform-admin/route/platform-admin-route.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useLoaderData } from "react-router-dom"; import type { PlatformAdminRouteData } from "@/features/platform-admin/route/platform-admin-data"; import { PlatformAdminDashboard } from "@/features/platform-admin/ui/platform-admin-dashboard"; @@ -6,6 +6,7 @@ import { checkPlatformAdminDomainProvisioning, commitPlatformAdminOnboarding, createSupportAccessGrant, + listSupportAccessGrantsByClub, previewPlatformAdminOnboarding, revokeSupportAccessGrant, updatePlatformAdminClub, @@ -17,6 +18,10 @@ import type { PlatformAdminSummaryResponse, SupportAccessGrantResponse, } from "@/features/platform-admin/api/platform-admin-contracts"; +import { + buildPlatformAdminWorkbench, + type PlatformAdminWorkbenchInput, +} from "@/features/platform-admin/model/platform-admin-workbench-model"; import type { CreateSupportAccessGrantFields } from "@/features/platform-admin/ui/support-access-grants-panel"; export function PlatformAdminRoute() { @@ -26,10 +31,64 @@ export function PlatformAdminRoute() { const [checkingDomainIds, setCheckingDomainIds] = useState>(new Set()); const [checkErrorByDomainId, setCheckErrorByDomainId] = useState>({}); const [activeGrants, setActiveGrants] = useState([]); + const [selectedClubId, setSelectedClubId] = useState(null); + const [supportGrantLoadError, setSupportGrantLoadError] = useState(null); + const [loadingSupportGrants, setLoadingSupportGrants] = useState(false); + + const workbench = useMemo(() => { + const input: PlatformAdminWorkbenchInput = { + role: summary.platformRole, + activeClubCount: summary.activeClubCount, + domainActionRequiredCount: summary.domainActionRequiredCount, + selectedClubId, + clubs: clubs.items, + domains: summary.domains ?? summary.domainsRequiringAction ?? [], + }; + return buildPlatformAdminWorkbench(input); + }, [clubs.items, selectedClubId, summary]); + + const effectiveSelectedClubId = workbench.selectedClub?.clubId ?? null; + + useEffect(() => { + const clubId = effectiveSelectedClubId; + if (!clubId) { + return; + } + + let cancelled = false; + const run = async () => { + setLoadingSupportGrants(true); + setSupportGrantLoadError(null); + try { + const grants = await listSupportAccessGrantsByClub(clubId); + if (!cancelled) { + setActiveGrants(grants); + } + } catch { + if (!cancelled) { + setActiveGrants([]); + setSupportGrantLoadError("지원 접근 권한을 불러오지 못했습니다."); + } + } finally { + if (!cancelled) { + setLoadingSupportGrants(false); + } + } + }; + void run(); + + return () => { + cancelled = true; + }; + }, [effectiveSelectedClubId]); async function handleCreateGrant(fields: CreateSupportAccessGrantFields) { + const clubId = workbench.selectedClub?.clubId; + if (!clubId) { + throw new Error("No selected club for support access grant"); + } const request: CreateSupportAccessGrantRequest = { - clubId: fields.clubId, + clubId, granteeUserId: fields.granteeUserId, scope: fields.scope, reason: fields.reason, @@ -46,8 +105,9 @@ export function PlatformAdminRoute() { return ( { @@ -69,6 +129,7 @@ export function PlatformAdminRoute() { onCommitOnboarding={async (request) => { const result = await commitPlatformAdminOnboarding(request); setClubs((current) => prependOrReplaceClub(current, result.club)); + setSelectedClubId(result.club.clubId); return result; }} onUpdateClub={async (clubId, request) => { @@ -76,7 +137,15 @@ export function PlatformAdminRoute() { setClubs((current) => replaceClub(current, updated)); return updated; }} + onSetVisibility={async (publicVisibility) => { + const clubId = workbench.selectedClub?.clubId; + if (!clubId) return; + const updated = await updatePlatformAdminClub(clubId, { publicVisibility }); + setClubs((current) => replaceClub(current, updated)); + }} activeGrants={activeGrants} + loadingSupportGrants={loadingSupportGrants} + supportGrantLoadError={supportGrantLoadError} onCreateGrant={handleCreateGrant} onRevokeGrant={handleRevokeGrant} /> diff --git a/front/features/platform-admin/ui/club-operations-brief.tsx b/front/features/platform-admin/ui/club-operations-brief.tsx new file mode 100644 index 00000000..aad09c20 --- /dev/null +++ b/front/features/platform-admin/ui/club-operations-brief.tsx @@ -0,0 +1,102 @@ +import type { + PlatformAdminPermissionView, + PlatformAdminSelectedClubBrief, +} from "@/features/platform-admin/model/platform-admin-workbench-model"; +import type { PlatformAdminClubRegistryItem } from "@/features/platform-admin/ui/platform-admin-club-registry"; +import { ClubPublishChecklist } from "@/features/platform-admin/ui/club-publish-checklist"; +import { DomainProvisioningPanel } from "@/features/platform-admin/ui/domain-provisioning-panel"; +import { PlatformAdminClubDetail } from "@/features/platform-admin/ui/platform-admin-club-detail"; +import { + SupportAccessGrantsPanel, + type CreateSupportAccessGrantFields, + type SupportAccessGrantView, +} from "@/features/platform-admin/ui/support-access-grants-panel"; + +type Props = { + club: PlatformAdminSelectedClubBrief | null; + permissions: PlatformAdminPermissionView; + savingClub?: boolean; + checkingDomainIds?: ReadonlySet; + domainCheckErrors?: Record; + activeGrants?: SupportAccessGrantView[]; + loadingSupportGrants?: boolean; + supportGrantLoadError?: string | null; + onUpdateClub?: ( + clubId: string, + request: { + name?: string; + tagline?: string; + about?: string; + publicVisibility?: "PRIVATE" | "PUBLIC"; + }, + ) => Promise; + onSetVisibility?: (publicVisibility: "PRIVATE" | "PUBLIC") => void; + onCheckDomain?: (domainId: string) => void; + onCreateGrant?: (fields: CreateSupportAccessGrantFields) => Promise; + onRevokeGrant?: (grantId: string) => Promise; +}; + +export function ClubOperationsBrief({ + club, + permissions, + savingClub = false, + checkingDomainIds, + domainCheckErrors, + activeGrants = [], + loadingSupportGrants = false, + supportGrantLoadError = null, + onUpdateClub, + onSetVisibility, + onCheckDomain, + onCreateGrant, + onRevokeGrant, +}: Props) { + if (!club) { + return ( +
+

선택할 클럽이 없습니다.

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

Club operations brief

+

+ {club.name} +

+

+ {club.slug} · {club.status} · {club.publicVisibility} +

+
+ + + + +
+ ); +} diff --git a/front/features/platform-admin/ui/club-publish-checklist.tsx b/front/features/platform-admin/ui/club-publish-checklist.tsx new file mode 100644 index 00000000..ce2b9fdc --- /dev/null +++ b/front/features/platform-admin/ui/club-publish-checklist.tsx @@ -0,0 +1,58 @@ +import type { + PlatformAdminPermissionView, + PlatformAdminSelectedClubBrief, +} from "@/features/platform-admin/model/platform-admin-workbench-model"; + +type Props = { + club: PlatformAdminSelectedClubBrief; + permissions: PlatformAdminPermissionView; + saving?: boolean; + onSetVisibility?: (publicVisibility: "PRIVATE" | "PUBLIC") => void; +}; + +export function ClubPublishChecklist({ club, permissions, saving = false, onSetVisibility }: Props) { + const canUsePrimaryAction = permissions.canUpdateClub && !club.primaryAction.disabled; + return ( +
+
+
+

Publish readiness

+

+ 공개 준비 체크리스트 +

+
+
+
+ {club.publishChecklist.map((item) => ( +
+ + {item.label} + {item.detail} +
+ ))} +
+ {club.primaryAction.reason && + !club.publishChecklist.some((item) => item.detail === club.primaryAction.reason) ? ( +

{club.primaryAction.reason}

+ ) : null} + + {!permissions.canUpdateClub ? ( +

현재 역할은 공개 상태를 변경할 수 없습니다.

+ ) : null} +
+ ); +} diff --git a/front/features/platform-admin/ui/domain-provisioning-panel.tsx b/front/features/platform-admin/ui/domain-provisioning-panel.tsx new file mode 100644 index 00000000..adc265b4 --- /dev/null +++ b/front/features/platform-admin/ui/domain-provisioning-panel.tsx @@ -0,0 +1,119 @@ +import type { + PlatformAdminDomainStatus, + PlatformAdminWorkbenchDomain, +} from "@/features/platform-admin/model/platform-admin-workbench-model"; + +type DomainProvisioningPanelProps = { + domains: PlatformAdminWorkbenchDomain[]; + checkingDomainIds?: ReadonlySet; + domainCheckErrors?: Record; + canManageDomains: boolean; + onCheckDomain?: (domainId: string) => void; +}; + +export function DomainProvisioningPanel({ + domains, + checkingDomainIds = new Set(), + domainCheckErrors = {}, + canManageDomains, + onCheckDomain, +}: DomainProvisioningPanelProps) { + return ( +
+
+
+

Domain provisioning

+

+ 선택 클럽 도메인 +

+
+
+ {domains.length > 0 ? ( +
+ {domains.map((domain) => ( + + ))} +
+ ) : ( +

선택한 클럽에 등록된 도메인이 없습니다.

+ )} +
+ ); +} + +function DomainProvisioningRow({ + domain, + isChecking, + checkError, + canManageDomains, + onCheckDomain, +}: { + domain: PlatformAdminWorkbenchDomain; + isChecking: boolean; + checkError?: string; + canManageDomains: boolean; + onCheckDomain?: (domainId: string) => void; +}) { + const canCheck = + canManageDomains && + domain.status !== "ACTIVE" && + domain.status !== "DISABLED" && + Boolean(onCheckDomain); + + return ( +
+
+

{domain.hostname}

+

+ {domain.kind} · desired {domain.desiredState} · manual action {domain.manualAction} +

+
+
+ {domain.status} + {domain.errorCode ? {domain.errorCode} : null} +
+ {domain.status === "ACTION_REQUIRED" ? ( +

+ Cloudflare Pages custom domain 연결 후 상태 확인을 실행하세요. +

+ ) : ( +

{domainActionText(domain.status)}

+ )} + {canCheck ? ( + + ) : null} + {checkError ?

{checkError}

: null} +
+ ); +} + +function domainActionText(status: PlatformAdminDomainStatus): string { + switch (status) { + case "REQUESTED": + return "연결 작업을 시작하세요."; + case "PROVISIONING": + return "DNS와 인증서 발급 상태를 기다리고 있습니다."; + case "ACTIVE": + return "추가 조치 없음"; + case "FAILED": + return "오류 코드를 확인하고 다시 검증하세요."; + case "DISABLED": + return "ReadMates에서 이 hostname을 받지 않습니다."; + case "ACTION_REQUIRED": + return "Cloudflare Pages custom domain 연결 후 상태 확인을 실행하세요."; + } +} diff --git a/front/features/platform-admin/ui/platform-admin-club-detail.tsx b/front/features/platform-admin/ui/platform-admin-club-detail.tsx index a9a3f2c5..89e6406f 100644 --- a/front/features/platform-admin/ui/platform-admin-club-detail.tsx +++ b/front/features/platform-admin/ui/platform-admin-club-detail.tsx @@ -51,12 +51,12 @@ export function PlatformAdminClubDetail({ club, onUpdateClub }: Props) { } return ( -
+

Club detail

-

- {club.name} -

+

+ 공개 정보 +

diff --git a/front/features/platform-admin/ui/platform-admin-dashboard.tsx b/front/features/platform-admin/ui/platform-admin-dashboard.tsx index f0d5ca19..5e2dc77f 100644 --- a/front/features/platform-admin/ui/platform-admin-dashboard.tsx +++ b/front/features/platform-admin/ui/platform-admin-dashboard.tsx @@ -1,54 +1,26 @@ -import { SupportAccessGrantsPanel } from "@/features/platform-admin/ui/support-access-grants-panel"; -import { - PlatformAdminClubRegistry, - type PlatformAdminClubRegistryItem, -} from "@/features/platform-admin/ui/platform-admin-club-registry"; -import { PlatformAdminClubDetail } from "@/features/platform-admin/ui/platform-admin-club-detail"; +import { useState } from "react"; +import type { + PlatformAdminWorkbenchView, +} from "@/features/platform-admin/model/platform-admin-workbench-model"; +import type { PlatformAdminClubRegistryItem } from "@/features/platform-admin/ui/platform-admin-club-registry"; import { PlatformAdminOnboardingWizard, type PlatformAdminOnboardingPreviewResponse, type PlatformAdminOnboardingRequest, type PlatformAdminOnboardingResultResponse, } from "@/features/platform-admin/ui/platform-admin-onboarding-wizard"; +import { PlatformAdminOverviewMetrics } from "@/features/platform-admin/ui/platform-admin-overview-metrics"; +import { PlatformAdminWorkQueue } from "@/features/platform-admin/ui/platform-admin-work-queue"; +import { ClubOperationsBrief } from "@/features/platform-admin/ui/club-operations-brief"; import type { CreateSupportAccessGrantFields, SupportAccessGrantView, } from "@/features/platform-admin/ui/support-access-grants-panel"; -import { useState } from "react"; - -type PlatformAdminDomainStatus = - | "REQUESTED" - | "ACTION_REQUIRED" - | "PROVISIONING" - | "ACTIVE" - | "FAILED" - | "DISABLED"; - -type PlatformAdminDomainView = { - id: string; - clubId: string; - hostname: string; - kind: string; - status: PlatformAdminDomainStatus; - desiredState: string; - manualAction: string; - errorCode: string | null; - isPrimary: boolean; - verifiedAt: string | null; - lastCheckedAt: string | null; -}; - -type PlatformAdminSummaryView = { - platformRole: string; - activeClubCount: number; - domainActionRequiredCount: number; - domains?: PlatformAdminDomainView[]; - domainsRequiringAction?: PlatformAdminDomainView[]; -}; type PlatformAdminDashboardProps = { - summary: PlatformAdminSummaryView; - clubs?: { items: PlatformAdminClubRegistryItem[] }; + workbench: PlatformAdminWorkbenchView; + selectedClubId: string | null; + onSelectClub?: (clubId: string) => void; checkingDomainIds?: ReadonlySet; domainCheckErrors?: Record; onCheckDomain?: (domainId: string) => void; @@ -63,26 +35,31 @@ type PlatformAdminDashboardProps = { publicVisibility?: "PRIVATE" | "PUBLIC"; }, ) => Promise; + onSetVisibility?: (publicVisibility: "PRIVATE" | "PUBLIC") => void; activeGrants?: SupportAccessGrantView[]; + loadingSupportGrants?: boolean; + supportGrantLoadError?: string | null; onCreateGrant?: (fields: CreateSupportAccessGrantFields) => Promise; onRevokeGrant?: (grantId: string) => Promise; }; export function PlatformAdminDashboard({ - summary, - clubs = { items: [] }, + workbench, + selectedClubId, + onSelectClub, checkingDomainIds = new Set(), domainCheckErrors = {}, onCheckDomain, onPreviewOnboarding, onCommitOnboarding, onUpdateClub, + onSetVisibility, activeGrants = [], + loadingSupportGrants = false, + supportGrantLoadError = null, onCreateGrant, onRevokeGrant, }: PlatformAdminDashboardProps) { - const domains = summary.domains ?? summary.domainsRequiringAction ?? []; - const [selectedClub, setSelectedClub] = useState(clubs.items[0] ?? null); const [showOnboarding, setShowOnboarding] = useState(false); return ( @@ -93,150 +70,52 @@ export function PlatformAdminDashboard({

플랫폼 관리

+ {workbench.permissions.canCreateClub ? ( + + ) : null}
-
- - - -
+ - setShowOnboarding((current) => !current)} - onSelectClub={(club) => { - setSelectedClub(club); - setShowOnboarding(false); - }} - /> +
+ + +
{showOnboarding && onPreviewOnboarding != null && onCommitOnboarding != null ? ( { - setSelectedClub(result.club); + onSelectClub?.(result.club.clubId); setShowOnboarding(false); }} /> ) : null} - - { - const updated = await onUpdateClub?.(clubId, request); - if (updated == null) { - throw new Error("Platform admin club update handler is not configured"); - } - setSelectedClub(updated); - return updated; - }} - /> - -
-
-
-

Domain provisioning

-

- Cloudflare Pages custom domain -

-
-
- - {domains.length > 0 ? ( -
- {domains.map((domain) => ( - - ))} -
- ) : ( -

등록된 도메인이 없습니다.

- )} -
- -
); } - -function MetricCard({ label, value }: { label: string; value: string }) { - return ( -
-

{label}

-

{value}

-
- ); -} - -function DomainProvisioningRow({ - domain, - isChecking, - checkError, - onCheckDomain, -}: { - domain: PlatformAdminDomainView; - isChecking: boolean; - checkError?: string; - onCheckDomain?: (domainId: string) => void; -}) { - const canCheck = domain.status !== "ACTIVE" && domain.status !== "DISABLED" && Boolean(onCheckDomain); - - return ( -
-
-

{domain.hostname}

-

- {domain.kind} · desired {domain.desiredState} · manual action {domain.manualAction} -

-
-
- {domain.status} - {domain.errorCode ? {domain.errorCode} : null} -
- {domain.status === "ACTION_REQUIRED" ? ( -

Cloudflare Pages custom domain 연결 후 상태 확인을 실행하세요.

- ) : ( -

{domainActionText(domain.status)}

- )} - {canCheck ? ( - - ) : null} - {checkError ?

{checkError}

: null} -
- ); -} - -function domainActionText(status: PlatformAdminDomainStatus): string { - switch (status) { - case "REQUESTED": - return "연결 작업을 시작하세요."; - case "PROVISIONING": - return "DNS와 인증서 발급 상태를 기다리고 있습니다."; - case "ACTIVE": - return "추가 조치 없음"; - case "FAILED": - return "오류 코드를 확인하고 다시 검증하세요."; - case "DISABLED": - return "ReadMates에서 이 hostname을 받지 않습니다."; - case "ACTION_REQUIRED": - return "Cloudflare Pages custom domain 연결 후 상태 확인을 실행하세요."; - } -} diff --git a/front/features/platform-admin/ui/platform-admin-overview-metrics.tsx b/front/features/platform-admin/ui/platform-admin-overview-metrics.tsx new file mode 100644 index 00000000..ef32d74d --- /dev/null +++ b/front/features/platform-admin/ui/platform-admin-overview-metrics.tsx @@ -0,0 +1,25 @@ +import type { PlatformAdminWorkbenchView } from "@/features/platform-admin/model/platform-admin-workbench-model"; + +type Props = { + metrics: PlatformAdminWorkbenchView["metrics"]; +}; + +export function PlatformAdminOverviewMetrics({ metrics }: Props) { + return ( +
+ + + + +
+ ); +} + +function MetricCard({ label, value }: { label: string; value: string }) { + return ( +
+

{label}

+

{value}

+
+ ); +} diff --git a/front/features/platform-admin/ui/platform-admin-work-queue.tsx b/front/features/platform-admin/ui/platform-admin-work-queue.tsx new file mode 100644 index 00000000..596a28a0 --- /dev/null +++ b/front/features/platform-admin/ui/platform-admin-work-queue.tsx @@ -0,0 +1,53 @@ +import type { PlatformAdminWorkQueueItem } from "@/features/platform-admin/model/platform-admin-workbench-model"; + +type Props = { + items: PlatformAdminWorkQueueItem[]; + selectedClubId: string | null; + onSelectClub?: (clubId: string) => void; +}; + +export function PlatformAdminWorkQueue({ items, selectedClubId, onSelectClub }: Props) { + return ( +
+
+
+

Operations queue

+

+ 운영 작업 큐 +

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

표시할 클럽이 없습니다.

+ )} +
+ ); +} diff --git a/front/features/platform-admin/ui/support-access-grants-panel.tsx b/front/features/platform-admin/ui/support-access-grants-panel.tsx index b15a4b00..e3347303 100644 --- a/front/features/platform-admin/ui/support-access-grants-panel.tsx +++ b/front/features/platform-admin/ui/support-access-grants-panel.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import type { PlatformAdminSelectedClubBrief } from "@/features/platform-admin/model/platform-admin-workbench-model"; type SupportAccessGrantScope = "METADATA_READ" | "HOST_SUPPORT_READ"; @@ -15,7 +16,6 @@ export type SupportAccessGrantView = { }; export type CreateSupportAccessGrantFields = { - clubId: string; granteeUserId: string; scope: SupportAccessGrantScope; reason: string; @@ -23,7 +23,12 @@ export type CreateSupportAccessGrantFields = { }; type SupportAccessGrantsPanelProps = { + selectedClub: PlatformAdminSelectedClubBrief; grants: SupportAccessGrantView[]; + loading?: boolean; + loadError?: string | null; + canCreateGrant?: boolean; + canRevokeGrant?: boolean; onCreateGrant?: (fields: CreateSupportAccessGrantFields) => Promise; onRevokeGrant?: (grantId: string) => Promise; }; @@ -37,8 +42,16 @@ function defaultExpiresAt(): string { ); } -export function SupportAccessGrantsPanel({ grants, onCreateGrant, onRevokeGrant }: SupportAccessGrantsPanelProps) { - const [clubId, setClubId] = useState(""); +export function SupportAccessGrantsPanel({ + selectedClub, + grants, + loading = false, + loadError = null, + canCreateGrant = false, + canRevokeGrant = false, + onCreateGrant, + onRevokeGrant, +}: SupportAccessGrantsPanelProps) { const [granteeUserId, setGranteeUserId] = useState(""); const [reason, setReason] = useState(""); const [expiresAt, setExpiresAt] = useState(defaultExpiresAt); @@ -48,19 +61,17 @@ export function SupportAccessGrantsPanel({ grants, onCreateGrant, onRevokeGrant async function handleCreate(e: React.FormEvent) { e.preventDefault(); - if (!clubId.trim() || !reason.trim() || !expiresAt || !onCreateGrant) return; + if (!reason.trim() || !expiresAt || !onCreateGrant) return; setCreating(true); setCreateError(null); try { await onCreateGrant({ - clubId: clubId.trim(), granteeUserId: granteeUserId.trim(), scope: "HOST_SUPPORT_READ", reason: reason.trim(), expiresAt: new Date(expiresAt).toISOString(), }); - setClubId(""); setGranteeUserId(""); setReason(""); setExpiresAt(defaultExpiresAt()); @@ -87,30 +98,24 @@ export function SupportAccessGrantsPanel({ grants, onCreateGrant, onRevokeGrant } } + const submitDisabled = creating || !onCreateGrant || !canCreateGrant; + return (

Support access

-

+

긴급 지원 접근 권한 -

+ +

+ 대상 클럽: {selectedClub.name} ({selectedClub.slug}) +

-
{createError ?

{createError}

: null} -
+ {loadError ?

{loadError}

: null} + {loading ?

지원 접근 권한을 불러오는 중…

: null} + {grants.length > 0 ? (
{grants.map((grant) => ( @@ -157,13 +168,13 @@ export function SupportAccessGrantsPanel({ grants, onCreateGrant, onRevokeGrant key={grant.id} grant={grant} isRevoking={revokingIds.has(grant.id)} - onRevoke={onRevokeGrant ? handleRevoke : undefined} + onRevoke={onRevokeGrant && canRevokeGrant ? handleRevoke : undefined} /> ))}
- ) : ( + ) : !loading && !loadError ? (

활성 지원 접근 권한이 없습니다.

- )} + ) : null}
); } @@ -182,9 +193,13 @@ function SupportAccessGrantRow({

{grant.clubId}

- grantee: {grant.granteeUserId} · scope: {grant.scope} · expires: {new Date(grant.expiresAt).toLocaleString("ko-KR")} + grantee: {grant.granteeUserId} · scope: {grant.scope} · expires:{" "} + {new Date(grant.expiresAt).toLocaleString("ko-KR")} +

+

+ 사유: + {grant.reason}

-

사유: {grant.reason}

{onRevoke ? ( - ); - })} - - ) : null} - - {effectiveImportMode === "aigen" && sessionIdForAigen && clubSlug ? ( - - - - ) : ( - - )} - - - - + sessionId={session?.sessionId} + clubSlug={clubSlug} + mode={effectiveImportMode} + canUseAigen={canShowImportModeToggle} + feedbackDocument={feedbackDocumentForPanel} + previewState={feedbackPreviewState} + LinkComponent={LinkComponent} + recordVisibility={recordVisibility} + preview={sessionImportPreview} + status={sessionImportStatus} + error={sessionImportError} + onModeChange={handleImportModeChange} + onAigenCommitted={handleAigenCommitted} + onFileSelected={previewSessionImport} + onCommit={commitSessionImport} + />
); diff --git a/front/tests/unit/platform-admin.test.tsx b/front/tests/unit/platform-admin.test.tsx index 85982455..4d968d52 100644 --- a/front/tests/unit/platform-admin.test.tsx +++ b/front/tests/unit/platform-admin.test.tsx @@ -1,4 +1,4 @@ -import { cleanup, render, screen } from "@testing-library/react"; +import { cleanup, render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createMemoryRouter, MemoryRouter, RouterProvider } from "react-router-dom"; @@ -394,7 +394,7 @@ describe("platform admin frontend shell", () => { platformRole: "OWNER", activeClubCount: 1, needsActionCount: 1, - domainActionRequiredCount: 0, + domainActionRequiredCount: 2, publishReadyCount: 0, }, queueItems: [ @@ -455,7 +455,13 @@ describe("platform admin frontend shell", () => { expect(screen.getByRole("heading", { name: "플랫폼 관리" })).toBeInTheDocument(); expect(screen.getByRole("heading", { name: "운영 작업 큐" })).toBeInTheDocument(); - expect(screen.getByLabelText("플랫폼 요약")).toBeInTheDocument(); + const summary = screen.getByLabelText("플랫폼 요약"); + expect(summary).toBeInTheDocument(); + expect(within(summary).getByText("플랫폼 역할")).toBeInTheDocument(); + expect(within(summary).getByText("활성 클럽")).toBeInTheDocument(); + expect(within(summary).getByText("조치 필요")).toBeInTheDocument(); + expect(within(summary).getByText("도메인 조치 필요")).toBeInTheDocument(); + expect(within(summary).getByText("공개 준비")).toBeInTheDocument(); expect(screen.getByRole("button", { name: /읽는사이/ })).toHaveAttribute("aria-pressed", "true"); expect(screen.getByText("첫 호스트가 아직 없습니다.")).toBeInTheDocument(); expect(screen.getByRole("heading", { name: "읽는사이" })).toBeInTheDocument(); From 884f41c629685fe92d9554227929a5c9e9bd951c Mon Sep 17 00:00:00 2001 From: kws Date: Sun, 17 May 2026 22:38:36 +0900 Subject: [PATCH 14/30] test(aigen): clean notification_event_outbox in integration test teardown SessionImportService.commitValidated now enqueues a notification event via recordFeedbackDocumentPublished, which inserts into notification_event_outbox with an FK to clubs(id). The aigen integration test's CLEANUP_SQL did not delete from that table, so delete from clubs failed with an FK violation during AFTER_TEST_METHOD and cascaded into ReadmatesMySqlSeedTest. Co-Authored-By: Claude Opus 4.7 --- .../com/readmates/aigen/api/AiGenerateApiIntegrationTest.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/server/src/test/kotlin/com/readmates/aigen/api/AiGenerateApiIntegrationTest.kt b/server/src/test/kotlin/com/readmates/aigen/api/AiGenerateApiIntegrationTest.kt index 395a02f6..1da806e9 100644 --- a/server/src/test/kotlin/com/readmates/aigen/api/AiGenerateApiIntegrationTest.kt +++ b/server/src/test/kotlin/com/readmates/aigen/api/AiGenerateApiIntegrationTest.kt @@ -40,6 +40,7 @@ private const val MEMBER_MEMBERSHIP_ID = "00000000-0000-0000-0000-000000088622" private const val HOST_EMAIL = "aigen-host@example.test" private const val CLEANUP_SQL = """ + delete from notification_event_outbox where club_id = '$CLUB_ID'; delete from session_feedback_documents where session_id = '$SESSION_ID'; delete from public_session_publications where session_id = '$SESSION_ID'; delete from highlights where session_id = '$SESSION_ID'; From 94d4f7fa6a258530f2f51e1e9f2c652f5b009612 Mon Sep 17 00:00:00 2001 From: kws Date: Sun, 17 May 2026 22:44:22 +0900 Subject: [PATCH 15/30] docs: add engineering proof portfolio spec --- ...ates-engineering-proof-portfolio-design.md | 682 ++++++++++++++++++ 1 file changed, 682 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-17-readmates-engineering-proof-portfolio-design.md diff --git a/docs/superpowers/specs/2026-05-17-readmates-engineering-proof-portfolio-design.md b/docs/superpowers/specs/2026-05-17-readmates-engineering-proof-portfolio-design.md new file mode 100644 index 00000000..7955d352 --- /dev/null +++ b/docs/superpowers/specs/2026-05-17-readmates-engineering-proof-portfolio-design.md @@ -0,0 +1,682 @@ +# ReadMates Engineering Proof Portfolio Design + +작성일: 2026-05-17 +상태: APPROVED DESIGN SPEC +문서 목적: ReadMates를 채용/포트폴리오 리뷰어, 미래 유지보수자, 오픈소스/기술 독자가 짧은 시간 안에 평가할 수 있는 "운영 가능한 풀스택 제품 증거물"로 고도화하는 분기급 설계를 정의한다. + +## 1. 배경 + +ReadMates는 이미 단순 CRUD 애플리케이션 범위를 넘어섰다. 현재 제품은 여러 정기 독서모임의 공개 사이트, 멤버 앱, 호스트 운영 도구, 플랫폼 관리자 콘솔, Google OAuth, Cloudflare Pages Functions BFF, Spring Boot API, MySQL/Flyway, optional Redis/Kafka, 알림 outbox, in-app AI 세션 기록 생성, 공개 릴리즈 safety scan, 운영 runbook을 함께 갖고 있다. + +최근 작업 흐름도 제품 신규 기능보다 운영형 플랫폼으로서의 완성도에 집중되어 있다. + +- in-app AI 세션 생성과 PII-safe 운영 경계 +- 플랫폼 관리자 triage 콘솔 +- release risk remediation +- Flyway collation 사고 후속 정리 +- public release candidate scan +- 디자인 시스템과 architecture boundary 강화 + +현재 강점은 많지만, 외부 리뷰어가 처음 README를 열었을 때 이 강점들이 하나의 주장으로 빠르게 연결되지는 않는다. 문서, case study, ADR, runbook, 테스트, CI, release checklist가 각각 존재하지만 "ReadMates는 어떤 어려운 문제를 어떤 근거로 해결했는가"라는 평가 흐름이 분산되어 있다. + +이번 분기 고도화는 새 기능을 크게 늘리는 프로젝트가 아니다. 핵심은 이미 존재하는 제품과 엔지니어링 자산을 외부 평가 가능한 증거 체계로 묶고, 그 증거를 믿게 만드는 유지보수 품질 작업을 병행하는 것이다. + +## 2. 목표 + +분기 목표는 다음 한 문장으로 고정한다. + +> ReadMates를 "운영 가능한 풀스택 제품을 설계, 출시, 개선할 수 있는 엔지니어링 증거물"로 만든다. + +구체 목표: + +- README에서 5분 안에 제품 문제, 역할 모델, 운영 난이도, 기술 선택, 핵심 증거를 이해할 수 있게 한다. +- 공개 guest-mode 경로를 리뷰어용 walkthrough로 정리하되, 공개 권한을 넓히지 않는다. +- BFF 보안, 알림 outbox, multi-club domain, PII-safe AI 세션 생성, release safety 같은 강점을 case study와 테스트 근거로 연결한다. +- 프론트 서버 상태 관리, 서버 UseCase/transaction 경계, architecture/quality gate를 분기 내 작은 PR 단위로 개선한다. +- release readiness, public release scan, deploy runbook, post-deploy watch, postmortem을 하나의 운영 증거 흐름으로 묶는다. +- 공개 저장소 안전 규칙을 유지한다. 실제 멤버 데이터, secret, private domain, deployment state, OCID, token-shaped example, local absolute path를 추가하지 않는다. + +## 3. 비목표 + +- 신규 대형 제품 기능을 만들지 않는다. +- 게스트 권한을 멤버/호스트/admin/AI workflow까지 넓히지 않는다. +- public bypass, demo auth, fake production admin entrypoint를 만들지 않는다. +- 실제 운영 멤버 데이터나 private 운영값을 데모에 사용하지 않는다. +- 분기 안에 전체 리팩터링 완료를 약속하지 않는다. +- 문서만 보기 좋게 만들고 코드/테스트 근거가 없는 showcase를 만들지 않는다. +- AI provider pricing, external platform limit, current model catalog 같은 변동성 높은 외부 사실을 새로 주장하지 않는다. 필요한 경우 공식 문서로 별도 검증하거나 "재검증 필요"로 표시한다. + +## 4. 주요 대상 + +우선순위: + +1. 채용/포트폴리오 리뷰어 +2. 미래 유지보수자 +3. 오픈소스/기술 독자 + +### 4.1 채용/포트폴리오 리뷰어 + +리뷰어가 확인하고 싶은 질문: + +- 제품이 실제 문제를 푸는가, 아니면 데모성 CRUD인가? +- 한 사람이 프론트, BFF, 백엔드, DB, 배포, 운영까지 연결해 설계할 수 있는가? +- 보안, 권한, 공개 저장소 안전, 장애 대응을 진지하게 다루는가? +- 복잡한 기능을 테스트와 문서로 유지보수 가능하게 만들었는가? + +이번 고도화는 이 질문에 README, walkthrough, case study, test evidence로 답한다. + +### 4.2 미래 유지보수자 + +유지보수자가 확인하고 싶은 질문: + +- 현재 동작의 source of truth는 어디인가? +- 변경할 때 어느 guide를 읽어야 하는가? +- frontend/server/doc boundary는 어떻게 나뉘는가? +- 어떤 테스트가 어떤 회귀를 막는가? +- release risk와 public safety는 어떻게 검증하는가? + +이번 고도화는 문서 구조와 implementation backlog를 통해 유지보수자가 첫 변경을 안전하게 시작하도록 만든다. + +### 4.3 오픈소스/기술 독자 + +기술 독자가 확인하고 싶은 질문: + +- ReadMates에서 배울 수 있는 비자명한 기술 문제는 무엇인가? +- 왜 BFF, outbox, multi-club domain, AI audit/cost guard 같은 선택을 했는가? +- 설계가 코드와 테스트로 이어지는가? + +이번 고도화는 case study와 architecture evidence를 README에서 자연스럽게 발견하게 한다. + +## 5. 설계 원칙 + +### 5.1 Evidence Over Claims + +문서에서 말하는 강점은 코드, 테스트, script, runbook, ADR, case study 중 하나 이상의 근거로 이어져야 한다. + +예: + +- "BFF 보안 경계가 있다"는 주장은 ADR, BFF proxy code, BFF tests, security-public-repo 문서로 이어져야 한다. +- "AI 세션 생성은 PII-safe하게 운영된다"는 주장은 AI runbook, audit/cost guard, PII check script, 관련 tests로 이어져야 한다. +- "릴리즈 안전장치가 있다"는 주장은 build-public-release-candidate script, public-release-check script, release-readiness-review 문서로 이어져야 한다. + +### 5.2 Guest Access Is Not Demo Auth + +기존 guest-mode는 공개 클럽 소개, 공개 기록, 공개 세션 상세를 보여주는 제품 권한 모델이다. 이번 고도화는 이를 리뷰어용 관람 동선으로 정리할 뿐, 접근 권한을 넓히지 않는다. + +게스트가 볼 수 없는 멤버/호스트/platform admin/AI/알림 흐름은 다음 방식으로 설명한다. + +- public-safe walkthrough 문서 +- sanitized screenshot 또는 텍스트 캡처 +- fixture 기반 설명 +- case study +- 테스트와 runbook 근거 + +### 5.3 Code Keeps the Promise + +문서 개편은 코드 품질 작업과 분리되지 않는다. 문서에서 "유지보수 가능하다"고 주장하려면 실제 boundary test, architecture test, lint/build/test, query migration 상태, transaction policy가 함께 정리되어야 한다. + +### 5.4 Small Reviewable PRs + +분기 로드맵은 큰 rewrite가 아니라 reviewer가 이해할 수 있는 작은 PR 단위로 나눈다. + +좋은 단위: + +- README entry flow 재정리 +- guest-mode showcase 문서 추가 +- claim-to-evidence map 추가 +- `host/members` TanStack Query migration +- 서버 transaction boundary 정책 문서화 +- 특정 service의 UseCase 분리 + +나쁜 단위: + +- README와 architecture와 server 리팩터링과 UI 개편을 한 PR에 섞기 +- 모든 frontend server state를 한 번에 migration +- 모든 server transaction annotation을 한 번에 이동 + +### 5.5 Public Repo Safety by Default + +모든 문서와 fixture는 공개 저장소를 기준으로 작성한다. + +금지: + +- 실제 member 이름, 이메일, 메시지, 기록 +- private domain +- real deployment state +- OCI OCID +- secret/token/API key 형태의 문자열 +- local absolute path +- 운영 DB dump나 raw logs + +허용: + +- repo-relative path +- placeholder host (`https://api.example.com`, `host@example.com`) +- synthetic club/member/session fixture +- public fallback domain already documented by the project + +## 6. 분기 로드맵 + +분기는 4개 milestone로 나눈다. 각 milestone은 "보여줄 결과물"과 "그 결과물을 믿게 만드는 코드/검증"을 함께 가진다. + +### 6.1 Milestone 1: Portfolio Entry + +목표: 리뷰어가 README에서 5분 안에 ReadMates의 문제, 제품 표면, 운영 난이도, 기술 선택을 이해한다. + +주요 결과물: + +- README entry narrative 개편 +- case study index 정리 +- architecture evidence map 초안 +- "무엇을 먼저 보면 되는가" section + +범위: + +- README는 entrypoint로 유지하고 source of truth를 대체하지 않는다. +- architecture 상세는 `docs/development/architecture.md`로 link한다. +- deployment 상세는 `docs/deploy/`와 runbook으로 link한다. +- release safety는 script 문서와 release-readiness 문서로 link한다. + +성공 기준: + +- README 상단 1/3 안에서 제품 문제, 대상 역할, 핵심 기술 증거, guest-mode link가 보인다. +- README의 강점 항목은 최소 하나 이상의 evidence link를 가진다. +- 공개 저장소 안전 규칙을 새 문구가 깨지 않는다. + +### 6.2 Milestone 2: Engineering Confidence + +목표: "이 프로젝트는 커졌지만 무너지지 않게 관리되고 있다"는 근거를 만든다. + +주요 결과물: + +- frontend server-state migration 분기 계획 +- `host/members`, `host/sessions`, `host/notifications` 중 2~3개 Query migration 또는 상세 계획화 +- server UseCase/transaction boundary 후보 1~2개 정리 +- architecture/quality gate evidence 문서 정리 + +프론트 후보: + +1. `host/members` + - 운영 빈도가 높고 state mutation이 명확하다. + - route-owned data coordination과 TanStack Query invalidation 패턴을 보여주기 좋다. +2. `host/sessions` + - session lifecycle, visibility, current/upcoming state와 연결되어 제품 의미가 크다. + - migration 범위가 넓을 수 있으므로 slice를 나누어야 한다. +3. `host/notifications` + - manual dispatch preview/confirm, dispatch ledger, recipient state가 있어 운영형 UX 근거가 강하다. + - E2E 영향이 있을 수 있어 작은 단위로 접근한다. + +서버 후보: + +1. Host session command UseCase split + - session draft mutation, lifecycle transition, attendance confirmation, publication update, dashboard query 책임을 더 좁힌다. + - 기존 clean architecture 방향과 맞는다. +2. Transaction boundary policy + - adapter-level `@Transactional`과 application-service transaction owner 정책을 문서화하고, 작은 slice부터 정리한다. +3. Auth package service location cleanup + - `auth/application` 직속 service를 `auth/application/service`로 이동해 package convention을 맞추는 후보. + +성공 기준: + +- migration/cleanup은 "어떤 회귀를 줄이는가"가 문서에 적혀야 한다. +- 각 작업은 관련 guide와 최소 검증 명령을 명시한다. +- route-first/frontend boundary와 server clean architecture boundary를 약화하지 않는다. + +### 6.3 Milestone 3: Operational Proof + +목표: release, deploy, observability, incident response가 분리된 문서가 아니라 하나의 운영 증거 흐름으로 읽힌다. + +주요 결과물: + +- release evidence flow 문서 +- public release candidate scan 설명 정리 +- post-deploy watch/runbook link 정리 +- incident/postmortem index 정리 +- Flyway collation 사고와 AI 운영 리스크 대응을 public-safe learning으로 재구성 + +핵심 흐름: + +```text + +Change + -> local checks + -> release readiness review + -> public release candidate build/check + -> tag/release process + -> deploy runbook + -> smoke/post-deploy watch + -> incident/postmortem if needed +``` + +성공 기준: + +- README 또는 showcase에서 release safety 근거로 진입할 수 있다. +- release-readiness checklist는 "tests passed"만으로 충분하다고 표현하지 않는다. +- public safety scan과 deploy runbook 사이의 역할이 분명하다. +- 운영 학습 사례는 private 운영값 없이 재현 가능한 교훈 중심으로 설명된다. + +### 6.4 Milestone 4: Guest-Mode Showcase & Evidence Path + +목표: 이미 존재하는 guest-mode 공개 경로를 리뷰어가 의도대로 따라가며 제품 역량을 이해하도록 만든다. + +이 milestone은 새 guest 기능이 아니다. 현재 public routes와 guest behavior를 문서화하고, private workflow는 evidence로 보완한다. + +주요 결과물: + +- guest-mode walkthrough 문서 +- public-safe demo club/session narrative +- private workflow evidence section +- screenshot policy 또는 screenshot inventory +- README에서 guest-mode showcase로 가는 entry link + +권한 원칙: + +- 게스트는 공개 클럽 소개, 공개 기록, 공개 세션 상세까지만 본다. +- 멤버/호스트/platform admin/AI/알림 private workflow는 공개 접근을 열지 않는다. +- private workflow는 sanitized screenshot, fixture explanation, case study, test evidence로 설명한다. + +성공 기준: + +- 리뷰어가 로그인 없이 공개 제품 표면을 따라갈 수 있다. +- 리뷰어가 로그인 없이 볼 수 없는 기능도 "어떤 기능이고 어떤 근거로 검증되는지" 이해할 수 있다. +- demo path가 실제 멤버 데이터나 운영 secret을 요구하지 않는다. + +## 7. Evidence Graph + +중심 진입점은 README다. README는 모든 설명을 품지 않고, 평가 흐름을 다음 그래프로 안내한다. + +```text + +README + -> Guest-Mode Showcase + -> Architecture Evidence + -> Case Studies + -> Engineering Confidence + -> Operational Proof + -> Release Safety +``` + +### 7.1 Claim-to-Evidence Map + +| Claim | Primary Evidence | Secondary Evidence | Verification | +| --- | --- | --- | --- | +| Cloudflare BFF가 browser-facing security boundary다 | `docs/development/adr/0001-cloudflare-pages-functions-bff.md`, `docs/development/architecture.md` | BFF proxy tests, security docs | frontend/BFF tests, public release check | +| Multi-club context가 slug/host 기반으로 안전하게 resolve된다 | `docs/case-studies/03-multi-club-domain-platform.md`, architecture docs | domain deploy runbook, host header ADR | server tests, BFF tests | +| 알림 발송은 mutation transaction과 분리되어 운영된다 | `docs/case-studies/02-notification-pipeline-with-outbox.md` | Flyway schema, notification runbook | server tests, outbox/consumer tests | +| AI 세션 생성은 PII-safe 운영 경계를 가진다 | AI runbook, AI design spec, case study | audit/cost guard tests, PII check script | `scripts/aigen-pii-check.sh`, targeted AI tests | +| 공개 저장소 안전이 자동화되어 있다 | `docs/deploy/security-public-repo.md`, `scripts/README.md` | release readiness docs | public release candidate build/check | +| 유지보수 경계가 테스트로 강제된다 | architecture docs, frontend/server agent guides | ArchUnit, frontend boundary tests | `architectureTest`, frontend unit tests | +| 운영 사고를 학습으로 남긴다 | postmortem docs, changelog release notes | release-risk remediation plans | release readiness review | + +이 표는 milestone 1에서 별도 문서 또는 README section으로 정리한다. 각 row는 실제 존재하는 파일 링크만 포함해야 하며, 없는 evidence를 만들었다고 쓰지 않는다. + +## 8. Proposed Document Structure + +### 8.1 README 개편 방향 + +README는 다음 순서를 목표로 한다. + +1. 제품 한 줄 설명 +2. guest-mode로 볼 수 있는 것 +3. 왜 이 프로젝트가 단순 CRUD가 아닌가 +4. 역할별 제품 표면 +5. 핵심 engineering evidence 4~5개 +6. architecture overview +7. how to review this project +8. local setup/checks links +9. source-of-truth links + +README가 피해야 할 것: + +- 모든 runbook 상세를 README에 붙이는 것 +- 이미 architecture 문서가 책임지는 상세 정책을 중복 서술하는 것 +- "최신", "완전", "무결" 같은 검증하기 어려운 표현 +- 실제 운영값이나 private path 노출 + +### 8.2 Guest-Mode Showcase 문서 + +권장 위치: + +- `docs/showcase/guest-mode-walkthrough.md` + +권장 구조: + +1. 목적 +2. 리뷰어가 로그인 없이 볼 수 있는 화면 +3. 공개 클럽 소개 보기 +4. 공개 기록 보기 +5. 공개 세션 상세 보기 +6. 게스트가 볼 수 없는 private workflow +7. private workflow를 확인하는 evidence links +8. public-safety notes + +이 문서는 product walkthrough 문서다. 실제 source of truth는 route code와 architecture 문서다. + +### 8.3 Architecture Evidence 문서 + +권장 위치: + +- `docs/showcase/architecture-evidence.md` + +권장 구조: + +1. One-page architecture map +2. Browser/BFF/Spring/MySQL request path +3. Club context and role boundary +4. Async notification path +5. AI generation path +6. Release/operations path +7. Tests that enforce boundaries + +주의: + +- `docs/development/architecture.md`를 대체하지 않는다. +- 깊은 구현 상세 대신 "왜 이 구조가 운영 제품에 필요한가"를 설명한다. + +### 8.4 Engineering Confidence 문서 + +권장 위치: + +- `docs/showcase/engineering-confidence.md` + +권장 구조: + +1. Boundary tests +2. Server architecture tests +3. Query budget/migration tests +4. Frontend server-state migration state +5. Static analysis/coverage gates +6. Known improvement backlog +7. How to validate a change + +### 8.5 Operational Proof 문서 + +권장 위치: + +- `docs/showcase/operational-proof.md` + +권장 구조: + +1. Release evidence flow +2. Public release candidate checks +3. Deployment runbooks +4. Observability and request correlation +5. Incident/postmortem practice +6. Rollback and residual risk review + +## 9. Technical Improvement Tracks + +### 9.1 Frontend Server-State Track + +현재 상태: + +- TanStack Query v5 provider가 app root에 있다. +- `host/invitations` migration이 완료되어 있다. +- 후속 후보는 `host/members`, `host/sessions`, `host/notifications`, `current-session`, read-heavy public/archive/feedback이다. + +분기 권장 순서: + +1. `host/members` + - 멤버 목록, 상태 변경, display name 변경, viewer/member lifecycle가 있다. + - mutation invalidation 패턴을 보여주기 좋다. +2. `host/notifications` + - manual dispatch options/preview/confirm/ledger가 있어 운영 UX를 보여준다. + - preview TTL, resend confirmation, selected session state 때문에 route-owned coordination이 중요하다. +3. `host/sessions` + - session editor, lifecycle, AI generation, JSON import와 결합되어 있어 크다. + - 한 번에 전환하지 말고 list/read path와 mutation path를 나눠야 한다. + +구현 원칙: + +- 새 server state는 `features//queries/-queries.ts`에 둔다. +- query key는 const tuple factory로 관리한다. +- route loader는 initial data를 query cache에 handoff한다. +- UI component는 query hook을 직접 호출하지 않는다. route 또는 feature container가 props/callback으로 전달한다. +- mutation success는 targeted invalidation을 수행한다. + +검증: + +- `pnpm --dir front lint` +- `pnpm --dir front test` +- `pnpm --dir front build` +- route/auth/BFF/user-flow 영향 시 `pnpm --dir front test:e2e` + +### 9.2 Server Boundary Track + +현재 상태: + +- feature-local clean architecture가 다수 slice에 적용되어 있다. +- ArchUnit boundary test가 application/adapter/domain 의존성을 강제한다. +- CQRS read/write package split convention이 문서화되어 있다. +- 일부 legacy service 책임과 transaction annotation 위치는 후속 정리가 필요하다. + +분기 권장 순서: + +1. Transaction policy documentation + - application service가 transaction owner가 되는 기준 + - adapter-level transaction이 허용되는 예외 + - scheduler/Kafka listener transaction boundary + - MySQL isolation expectation +2. Narrow service split candidate + - Host session command service 책임을 draft/lifecycle/read/attendance/publication으로 나눌 수 있는지 검토 + - 단일 PR에서 interface split만 먼저 수행 가능 +3. Package convention cleanup + - auth service 위치 정리 후보 + - architecture test를 나중에 좁게 추가 + +검증: + +- `./server/gradlew -p server unitTest` +- 변경 표면에 따라 `./server/gradlew -p server integrationTest` +- architecture boundary 변경 시 `./server/gradlew -p server architectureTest` +- PR-level confidence가 필요하면 `./server/gradlew -p server check` + +### 9.3 Release Safety Track + +현재 상태: + +- public release candidate build/check script가 있다. +- release-readiness-review 문서가 있다. +- CHANGELOG가 release evidence를 담고 있다. +- public repo safety를 위한 docs/deploy 문서가 있다. + +분기 권장 작업: + +1. release evidence flow 문서 작성 +2. README에서 release safety를 evidence로 link +3. recent incident learning index 정리 +4. public release check가 무엇을 보장하고 무엇을 보장하지 않는지 명시 + +검증: + +- docs-only change: `git diff --check -- ` +- public/release/deploy docs change: + - `./scripts/build-public-release-candidate.sh` + - `./scripts/public-release-check.sh .tmp/public-release-candidate` + +## 10. Error Handling and Risk Management + +이번 고도화는 기능 runtime error path를 새로 추가하지 않는다. 대신 문서와 계획의 오류를 다음 방식으로 관리한다. + +### 10.1 Stale Claim Risk + +위험: README/showcase가 현재 코드보다 앞서가거나, 이미 바뀐 동작을 오래된 상태로 설명한다. + +대응: + +- 문서 claim은 current code, config, tests, scripts, architecture 문서와 대조한다. +- historical `docs/superpowers` 문서는 현재 동작의 source of truth로 쓰지 않는다. +- 불확실한 내용은 "재확인 필요"로 표시하거나 제외한다. + +### 10.2 Public Safety Risk + +위험: showcase 과정에서 실제 운영값이나 private data를 노출한다. + +대응: + +- fixture와 screenshot은 synthetic 또는 sanitized만 사용한다. +- private workflow는 접근을 열지 않고 evidence로 설명한다. +- public release check와 targeted safety scan을 실행한다. + +### 10.3 Scope Creep Risk + +위험: 포트폴리오 고도화가 대형 제품 개발이나 전면 리팩터링으로 변한다. + +대응: + +- milestone마다 "보여줄 결과물"과 "검증"을 먼저 정의한다. +- technical improvement는 작은 PR 단위로 제한한다. +- 새 기능보다 evidence path를 우선한다. + +### 10.4 Reviewer Confusion Risk + +위험: 문서가 많아져 리뷰어가 무엇을 봐야 할지 더 혼란스러워진다. + +대응: + +- README에 "How to review this project" section을 둔다. +- showcase 문서는 3~4개로 제한한다. +- 각 문서의 첫 section에 "이 문서가 답하는 질문"을 둔다. + +## 11. Testing and Verification Strategy + +### 11.1 Docs-only Verification + +모든 문서 변경: + +```bash +git diff --check -- +``` + +문서가 README, deploy, release, public safety를 건드릴 때: + +```bash +./scripts/build-public-release-candidate.sh +./scripts/public-release-check.sh .tmp/public-release-candidate +``` + +### 11.2 Frontend Verification + +frontend route/state/UI 작업: + +```bash +pnpm --dir front lint +pnpm --dir front test +pnpm --dir front build +``` + +route/auth/BFF/user-flow 변경: + +```bash +pnpm --dir front test:e2e +``` + +### 11.3 Server Verification + +server application/API/persistence 작업: + +```bash +./server/gradlew -p server clean test +``` + +boundary/static analysis/coverage 영향: + +```bash +./server/gradlew -p server check +./server/gradlew -p server architectureTest +``` + +개발 중 fast lane: + +```bash +./server/gradlew -p server unitTest +./server/gradlew -p server integrationTest +``` + +### 11.4 Evidence Verification + +claim-to-evidence map을 작성한 뒤 다음을 점검한다. + +- 모든 claim에 실제 파일 링크가 있는가? +- 링크된 파일이 current source of truth인가, historical plan인가? +- historical plan을 evidence로 쓸 경우 "history/context"로 명확히 표시했는가? +- claim이 code/test/script보다 과장되어 있지 않은가? +- public-safety rule을 새 문구가 깨지 않는가? + +## 12. Milestone Acceptance Criteria + +### Milestone 1 Acceptance + +- README가 entrypoint로 재정리되어 있다. +- "How to review this project" 흐름이 있다. +- 핵심 engineering evidence가 case study/test/runbook으로 연결된다. +- `git diff --check`와 public safety scan이 실행되었거나, 실행하지 못한 이유가 기록된다. + +### Milestone 2 Acceptance + +- frontend server-state migration 후보가 최신 상태로 정리되어 있다. +- 최소 1개 이상의 migration/cleanup PR이 merged 가능 단위로 계획되거나 완료되어 있다. +- server transaction/UseCase boundary 후보가 구체 파일/검증 단위로 좁혀져 있다. +- 변경된 코드 표면의 lint/test/build 또는 server test가 실행된다. + +### Milestone 3 Acceptance + +- release evidence flow 문서가 있다. +- release readiness, public release candidate scan, deploy runbook, post-deploy watch가 연결된다. +- 운영 학습 사례가 public-safe하게 요약되어 있다. +- deploy/release docs 변경 시 public release candidate checks가 실행된다. + +### Milestone 4 Acceptance + +- guest-mode walkthrough가 있다. +- 공개 접근 범위와 private workflow evidence가 구분되어 있다. +- private workflow를 보기 위해 demo auth나 public bypass가 필요하지 않다. +- screenshot/fixture가 public-safe하다. + +## 13. Implementation Planning Notes + +상세 구현 계획은 이 스펙 승인 후 별도 implementation plan으로 작성한다. 구현 계획은 다음 단위로 나누는 것이 적절하다. + +1. Documentation/showcase PRs + - README entry flow + - guest-mode walkthrough + - architecture evidence + - engineering confidence + - operational proof +2. Frontend confidence PRs + - `host/members` Query migration + - `host/notifications` Query migration 또는 detailed plan + - related tests +3. Server confidence PRs + - transaction policy doc + - one narrow UseCase split candidate + - related tests/architecture checks +4. Release safety PRs + - release evidence flow + - public scan wording and docs cross-links + - postmortem/incident learning index + +각 implementation task는 다음을 반드시 포함한다. + +- touched surface +- source files +- non-goals +- public safety constraints +- exact validation commands +- rollback or residual risk note + +## 14. Open Questions Resolved in Brainstorming + +- 우선순위는 D/C/B 중 D를 주축으로 하고 C, B를 보조한다. +- 리뷰 대상 우선순위는 채용/포트폴리오 리뷰어, 미래 유지보수자, 오픈소스/기술 독자 순이다. +- 시간 단위는 분기급 master plan이다. +- 접근 방식은 Engineering Proof Portfolio다. +- Milestone 4는 새 guest 기능이 아니라 기존 guest-mode를 리뷰어용 evidence path로 정리하는 것이다. + +## 15. Final Design Summary + +ReadMates는 이미 제품 기능과 운영 장치가 많다. 이번 분기 고도화의 핵심은 더 많은 기능을 붙이는 것이 아니라, 제품과 코드와 운영 기록을 하나의 평가 가능한 증거 흐름으로 연결하는 것이다. + +리뷰어는 README에서 시작해 guest-mode 공개 화면을 보고, private workflow는 sanitized evidence로 이해하고, architecture/case study/test/runbook을 통해 엔지니어링 주장을 검증한다. 유지보수자는 같은 문서 흐름에서 source of truth와 검증 명령을 찾는다. + +이 설계가 성공하면 ReadMates는 "많이 만든 프로젝트"가 아니라 "운영 가능한 제품을 설계하고 지속적으로 안전하게 개선한 프로젝트"로 읽힌다. From 08d14b93787afb3cfa767c956f59e693983a63aa Mon Sep 17 00:00:00 2001 From: kws Date: Sun, 17 May 2026 22:59:15 +0900 Subject: [PATCH 16/30] docs: add engineering proof portfolio plan --- ...ing-proof-portfolio-implementation-plan.md | 1359 +++++++++++++++++ 1 file changed, 1359 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-17-readmates-engineering-proof-portfolio-implementation-plan.md diff --git a/docs/superpowers/plans/2026-05-17-readmates-engineering-proof-portfolio-implementation-plan.md b/docs/superpowers/plans/2026-05-17-readmates-engineering-proof-portfolio-implementation-plan.md new file mode 100644 index 00000000..d45af6bf --- /dev/null +++ b/docs/superpowers/plans/2026-05-17-readmates-engineering-proof-portfolio-implementation-plan.md @@ -0,0 +1,1359 @@ +# ReadMates Engineering Proof Portfolio Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Turn ReadMates into a reviewable engineering proof portfolio by connecting README, guest-mode showcase, architecture evidence, quality gates, operational proof, and selected frontend/server confidence work. + +**Architecture:** Keep documentation entrypoints separate from current source-of-truth docs. Add `docs/showcase/` as a reviewer-facing layer that links to `README.md`, `docs/development/architecture.md`, case studies, scripts, runbooks, and tests without replacing them. Keep code work scoped to existing frontend route-first and server clean-architecture boundaries. + +**Tech Stack:** Markdown documentation, React 19/Vite/TanStack Query v5 for frontend confidence work, Kotlin/Spring Boot/MySQL/Flyway for server confidence work, existing shell release-safety scripts. + +--- + +## Source Spec + +Design spec: `docs/superpowers/specs/2026-05-17-readmates-engineering-proof-portfolio-design.md` + +## Scope Check + +This is a quarter-level master plan with multiple independent tracks. Execute it as separate PRs in the task order below. Each task has its own changed files and validation command. Do not combine documentation showcase work, frontend Query migration, and server boundary work in one PR. + +The first execution pass should complete Tasks 1-6. Tasks 7-10 are code-confidence follow-ups that can run after the reviewer-facing documentation exists. Task 11 is the release-readiness closeout for the whole initiative. + +## Public Safety Rules + +Every task follows the same public-repository constraints: + +- Do not add real member data, private domains, deployment state, local absolute paths, OCIDs, secrets, API keys, token-shaped examples, DB dumps, or raw logs. +- Use repo-relative paths in docs. +- Use placeholders such as `https://api.example.com`, ``, and `host@example.com`. +- Treat `docs/development/architecture.md`, code, tests, scripts, and runbooks as current source of truth. Treat `docs/superpowers/` as historical planning context unless the task is explicitly editing this plan or the source spec. + +## File Structure + +Create: + +- `docs/showcase/README.md` — reviewer-facing index for the engineering proof portfolio. +- `docs/showcase/guest-mode-walkthrough.md` — login-free guest-mode review path and private-workflow evidence links. +- `docs/showcase/architecture-evidence.md` — one-page architecture/evidence map for external readers. +- `docs/showcase/engineering-confidence.md` — tests, quality gates, frontend/server boundary evidence, and improvement status. +- `docs/showcase/operational-proof.md` — release, deploy, observability, post-deploy watch, and postmortem evidence flow. +- `front/features/host/queries/host-members-queries.ts` — TanStack Query helpers for host members, mirroring `host-invitation-queries.ts`. + +Modify: + +- `README.md` — add a compact "How to review this project" entry path and link to showcase docs. +- `docs/README.md` — include `docs/showcase/` as reviewer-facing documentation. +- `docs/development/server-state-migration.md` — update host-members migration status when Task 7 lands. +- `docs/development/technical-decisions.md` — add transaction boundary decision note in Task 9. +- `front/src/app/routes/host.tsx` — pass `QueryClient` into the host members loader factory. +- `front/features/host/route/host-members-data.ts` — seed host-members query data and expose response parsers/actions for UI. +- `front/features/host/route/host-members-route.tsx` — keep route as UI composition only. +- `front/features/host/ui/host-members.tsx` — read list state through TanStack Query while preserving prop-driven actions. +- `front/tests/unit/host-members.test.tsx` — pin loader handoff, invalidation, and pagination behavior. +- `server/src/main/kotlin/com/readmates/session/adapter/out/persistence/JdbcHostSessionWriteAdapter.kt` — first transaction-boundary cleanup after Task 9 policy is documented. + +Do not modify: + +- `docs/private/**` +- real deploy state files +- `.env*` except existing placeholder-only `.env.example` if a future task explicitly requires it + +--- + +## Task 1: Create Showcase Index + +**Files:** + +- Create: `docs/showcase/README.md` +- Modify: `docs/README.md` + +- [ ] **Step 1: Read documentation source-of-truth guidance** + +Run: + +```bash +sed -n '1,240p' docs/agents/docs.md +sed -n '1,220p' docs/README.md +``` + +Expected: `docs/agents/docs.md` states that `README.md` is an entry point and `docs/development/architecture.md` is source of truth for technical boundaries. + +- [ ] **Step 2: Create the showcase directory index** + +Create `docs/showcase/README.md` with this exact structure: + +```markdown +# ReadMates Showcase + +이 디렉터리는 ReadMates를 처음 보는 리뷰어가 제품, 아키텍처, 운영 증거, 유지보수 품질을 빠르게 따라갈 수 있도록 만든 reviewer-facing guide입니다. + +현재 동작의 source of truth는 코드, 테스트, scripts, migrations, `docs/development/architecture.md`입니다. Showcase 문서는 그 자료를 대체하지 않고 읽는 순서를 제공합니다. + +## 추천 리뷰 순서 + +1. `README.md`에서 제품 문제와 역할 모델을 확인합니다. +2. `docs/showcase/guest-mode-walkthrough.md`에서 로그인 없이 볼 수 있는 공개 제품 표면을 따라갑니다. +3. `docs/showcase/architecture-evidence.md`에서 BFF, Spring API, MySQL, Redis/Kafka, AI generation, release safety가 어떻게 연결되는지 봅니다. +4. `docs/showcase/engineering-confidence.md`에서 테스트와 경계 검증이 어떤 회귀를 막는지 확인합니다. +5. `docs/showcase/operational-proof.md`에서 release, deploy, observability, postmortem 흐름을 확인합니다. + +## 문서별 역할 + +| 문서 | 답하는 질문 | +| --- | --- | +| `guest-mode-walkthrough.md` | 로그인 없이 무엇을 볼 수 있고, private workflow는 어떤 evidence로 확인하는가? | +| `architecture-evidence.md` | 이 프로젝트가 단순 CRUD가 아니라 운영형 제품인 근거는 무엇인가? | +| `engineering-confidence.md` | 코드베이스가 커져도 무너지지 않게 하는 경계와 검증은 무엇인가? | +| `operational-proof.md` | 배포, 공개 릴리즈 안전, 장애 대응은 어떤 흐름으로 관리되는가? | + +## 공개 안전 기준 + +Showcase 문서는 실제 멤버 데이터, private domain, 운영 secret, deployment state, OCID, token-shaped example, local absolute path를 포함하지 않습니다. Private workflow는 접근 권한을 넓히지 않고 sanitized 설명, fixture, 테스트, runbook으로 설명합니다. +``` + +- [ ] **Step 3: Link the showcase index from docs hub** + +Modify `docs/README.md` by adding this bullet near the documentation index: + +```markdown +- [Showcase](showcase/README.md): 처음 보는 리뷰어를 위한 guest-mode walkthrough, architecture evidence, engineering confidence, operational proof 진입점입니다. +``` + +Keep the Korean-first documentation tone and do not remove existing links. + +- [ ] **Step 4: Validate docs formatting** + +Run: + +```bash +git diff --check -- docs/showcase/README.md docs/README.md +``` + +Expected: no output. + +- [ ] **Step 5: Commit** + +Run: + +```bash +git add docs/showcase/README.md docs/README.md +git commit -m "docs: add showcase index" +``` + +Expected: one commit containing only the showcase index and docs hub link. + +--- + +## Task 2: Add README Review Entry Path + +**Files:** + +- Modify: `README.md` + +- [ ] **Step 1: Inspect current README entry flow** + +Run: + +```bash +sed -n '1,180p' README.md +``` + +Expected: README begins with product summary, stack, engineering highlights, and role/function overview. + +- [ ] **Step 2: Add a compact review path after the opening summary** + +Insert this section after the opening bullet list and before `## Engineering Highlights`: + +```markdown +## How to Review This Project + +처음 보는 리뷰어라면 아래 순서가 가장 빠릅니다. + +1. **제품 표면 확인** — 게스트로 공개 클럽 소개, 공개 기록, 공개 세션 상세를 확인합니다. 시작점은 [Guest-mode walkthrough](docs/showcase/guest-mode-walkthrough.md)입니다. +2. **아키텍처 판단** — Cloudflare Pages Functions BFF, Spring API, MySQL/Flyway, Redis/Kafka, AI generation, release safety가 어떻게 연결되는지 [Architecture evidence](docs/showcase/architecture-evidence.md)에서 봅니다. +3. **유지보수 품질 확인** — frontend boundary, server ArchUnit, query budget, public release scan 같은 검증은 [Engineering confidence](docs/showcase/engineering-confidence.md)에 정리합니다. +4. **운영 증거 확인** — release readiness, deploy runbook, post-deploy watch, postmortem 흐름은 [Operational proof](docs/showcase/operational-proof.md)에서 봅니다. + +Showcase 문서는 현재 동작의 source of truth가 아니라 읽는 순서입니다. 실제 경계와 동작은 코드, 테스트, scripts, migrations, [아키텍처 문서](docs/development/architecture.md)를 우선합니다. +``` + +- [ ] **Step 3: Add a short guest-mode pointer near the role table** + +Before `## 역할별 기능`, add: + +```markdown +리뷰어가 로그인 없이 확인할 수 있는 공개 표면은 guest-mode walkthrough에 따로 묶었습니다. 공개 접근은 클럽 소개, 공개 기록, 공개 세션 상세로 제한되며 멤버, 호스트, platform admin, AI 생성, 알림 운영 흐름은 권한을 열지 않고 sanitized evidence로 설명합니다. +``` + +- [ ] **Step 4: Validate README diff** + +Run: + +```bash +git diff --check -- README.md +rg -n "How to Review This Project|guest-mode walkthrough|private domain|OCID|token-shaped|local absolute path" README.md +``` + +Expected: `git diff --check` has no output. `rg` finds the new review section and does not reveal local absolute paths or private values. + +- [ ] **Step 5: Commit** + +Run: + +```bash +git add README.md +git commit -m "docs: add reviewer entry path" +``` + +Expected: one README-only commit. + +--- + +## Task 3: Write Guest-Mode Walkthrough + +**Files:** + +- Create: `docs/showcase/guest-mode-walkthrough.md` + +- [ ] **Step 1: Verify current public route names** + +Run: + +```bash +sed -n '1,180p' docs/development/architecture.md +sed -n '1,180p' front/src/app/routes/public.tsx +``` + +Expected: architecture lists public routes for `/clubs/:slug`, `/clubs/:slug/about`, `/clubs/:slug/records`, and `/clubs/:slug/sessions/:sessionId`. + +- [ ] **Step 2: Create walkthrough document** + +Create `docs/showcase/guest-mode-walkthrough.md`: + +```markdown +# Guest-Mode Walkthrough + +이 문서는 ReadMates를 처음 보는 리뷰어가 로그인 없이 확인할 수 있는 공개 제품 표면과, 로그인 없이 볼 수 없는 private workflow를 어떤 evidence로 확인할지 정리합니다. + +현재 동작의 source of truth는 public route code와 `docs/development/architecture.md`입니다. + +## 로그인 없이 볼 수 있는 것 + +Guest는 클럽이 `ACTIVE`이고 `PUBLIC`인 경우 아래 표면을 볼 수 있습니다. + +| 표면 | 경로 | 확인할 수 있는 것 | +| --- | --- | --- | +| 클럽 소개 | `/clubs/` 또는 `/clubs//about` | 클럽의 공개 소개와 공개 진입 경험 | +| 공개 기록 | `/clubs//records` | 공개된 회차 목록과 archive 흐름 | +| 공개 세션 상세 | `/clubs//sessions/` | 공개 요약, 하이라이트, 한줄평 등 공개 범위에 포함된 기록 | + +운영 fallback 경로는 `https://readmates.pages.dev/clubs/` 형태입니다. 등록된 custom domain은 운영 설정에 따라 달라지므로 이 문서에서는 placeholder만 사용합니다. + +## 추천 관람 순서 + +1. 클럽 소개에서 제품의 공개 첫인상을 확인합니다. +2. 공개 기록 목록에서 회차가 누적되는 방식을 확인합니다. +3. 공개 세션 상세에서 모임 후 기록이 어떻게 읽히는지 확인합니다. +4. README의 Engineering Highlights로 돌아가 공개 화면 뒤의 BFF, publication visibility, notification, AI generation 근거를 확인합니다. + +## 로그인 없이 볼 수 없는 것 + +아래 흐름은 제품 권한상 guest에게 공개하지 않습니다. + +| Private workflow | 공개하지 않는 이유 | 확인 evidence | +| --- | --- | --- | +| 멤버 현재 세션 참여, RSVP, 질문, 서평 작성 | 정식 멤버 권한과 club membership이 필요합니다. | `docs/development/architecture.md`, frontend route guard tests | +| 호스트 세션 생성/수정, 출석 확정, 기록 발행 | 클럽 host 권한이 필요합니다. | host route tests, session server tests, case studies | +| Platform admin onboarding/domain/support access | platform admin 권한이 필요합니다. | platform admin plan/spec, server authorization tests | +| In-app AI 세션 생성 | host 권한, feature flag, provider key, cost/PII guard가 필요합니다. | `docs/case-studies/04-pii-safe-ai-session-generation.md`, AI runbook, `scripts/aigen-pii-check.sh` | +| 수동 알림 발송 | host 권한과 notification outbox pipeline이 필요합니다. | `docs/case-studies/02-notification-pipeline-with-outbox.md`, notification tests | + +## Public-Safety Notes + +- 이 walkthrough는 guest 권한을 넓히지 않습니다. +- 실제 멤버 데이터, private domain, 운영 secret, provider key, deployment state는 사용하지 않습니다. +- Screenshot을 추가할 때는 synthetic 또는 sanitized fixture만 사용합니다. +- Private workflow를 보여줄 필요가 있으면 접근 권한을 열지 않고 테스트, runbook, sanitized 설명으로 연결합니다. +``` + +- [ ] **Step 3: Validate public-safety wording** + +Run: + +```bash +git diff --check -- docs/showcase/guest-mode-walkthrough.md +rg -n "local absolute path|OCID|private key|token-shaped|private domain|real member" docs/showcase/guest-mode-walkthrough.md +``` + +Expected: `git diff --check` has no output. `rg` returns no active secret, local path, or private deployment value. + +- [ ] **Step 4: Commit** + +Run: + +```bash +git add docs/showcase/guest-mode-walkthrough.md +git commit -m "docs: add guest mode walkthrough" +``` + +Expected: one commit containing the walkthrough. + +--- + +## Task 4: Write Architecture Evidence Map + +**Files:** + +- Create: `docs/showcase/architecture-evidence.md` + +- [ ] **Step 1: Inspect architecture and case study anchors** + +Run: + +```bash +sed -n '1,220p' docs/development/architecture.md +sed -n '1,200p' docs/case-studies/README.md +``` + +Expected: architecture describes product surfaces, BFF request flow, frontend route-first boundary, API error contract, multi-club context, server package boundaries, auth/session, BFF security, Redis, and public cache. + +- [ ] **Step 2: Create architecture evidence document** + +Create `docs/showcase/architecture-evidence.md`: + +```markdown +# Architecture Evidence + +이 문서는 ReadMates가 단순 CRUD 앱이 아니라 운영형 멀티클럽 제품인 이유를 한 장으로 보여줍니다. 상세 source of truth는 `docs/development/architecture.md`입니다. + +## One-Page Map + +```text +Browser + -> Cloudflare Pages SPA + -> Pages Functions BFF (/api/bff/**, OAuth proxy) + -> Spring Boot API + -> MySQL/Flyway source of truth + -> optional Redis cache/rate-limit/job state + -> optional Kafka/Redpanda notification and AI job pipeline + -> SMTP/in-app notification side effects +``` + +## Evidence Table + +| Product/engineering claim | Why it matters | Evidence | +| --- | --- | --- | +| Browser traffic goes through a same-origin BFF | Keeps browser-facing security policy, trusted headers, OAuth proxying, and cookie handling at the edge boundary. | `docs/development/adr/0001-cloudflare-pages-functions-bff.md`, `docs/case-studies/01-bff-security-and-secret-rotation.md` | +| Club context is scoped by slug or registered host | Multi-club operation needs role, cache, public URL, and OAuth return behavior to stay club-aware. | `docs/case-studies/03-multi-club-domain-platform.md`, `docs/deploy/multi-club-domains.md` | +| Server feature slices follow clean architecture | Controllers parse HTTP; application services own authorization/orchestration; persistence stays behind ports/adapters. | `docs/development/architecture.md`, `ServerArchitectureBoundaryTest` | +| Notifications use transactional outbox | Mutations do not block on SMTP/in-app delivery; retry and audit state are explicit. | `docs/case-studies/02-notification-pipeline-with-outbox.md` | +| AI generation is feature-gated and audited | Transcript handling, provider calls, cost guard, kill switch, and PII policy are operational boundaries. | `docs/case-studies/04-pii-safe-ai-session-generation.md`, `docs/operations/runbooks/ai-session-generation.md`, `scripts/aigen-pii-check.sh` | +| Public release safety is scripted | Public candidates are built and scanned before release assumptions are made. | `scripts/README.md`, `docs/deploy/security-public-repo.md` | + +## Request Flow + +1. Browser requests same-origin SPA or `/api/bff/**`. +2. Pages Functions strips untrusted internal headers and adds trusted BFF headers. +3. Spring validates BFF secret, session cookie, membership, role, visibility, and attendance rules. +4. MySQL/Flyway remains source of truth. +5. Redis and Kafka are optional supporting layers, never the durable source of private transcript or membership truth. + +## What This Document Does Not Replace + +- API and role details: `docs/development/architecture.md` +- Local setup and checks: `docs/development/README.md` +- Release safety details: `scripts/README.md` +- Deployment runbooks: `docs/deploy/README.md` +``` + +- [ ] **Step 3: Validate diagram fence and docs formatting** + +Run: + +```bash +git diff --check -- docs/showcase/architecture-evidence.md +rg -n "```text|```" docs/showcase/architecture-evidence.md +``` + +Expected: `git diff --check` has no output and code fences are balanced. + +- [ ] **Step 4: Commit** + +Run: + +```bash +git add docs/showcase/architecture-evidence.md +git commit -m "docs: add architecture evidence map" +``` + +Expected: one commit containing only the architecture evidence doc. + +--- + +## Task 5: Write Engineering Confidence Guide + +**Files:** + +- Create: `docs/showcase/engineering-confidence.md` +- Modify: `docs/development/server-state-migration.md` + +- [ ] **Step 1: Inspect existing quality and migration docs** + +Run: + +```bash +sed -n '1,220p' docs/development/server-state-migration.md +sed -n '1,220p' docs/development/test-guide.md +rg -n "frontend-boundaries|ServerArchitectureBoundaryTest|MySqlFlywayMigrationTest|ServerQueryBudgetTest" front server docs +``` + +Expected: server-state migration lists `host/invitations` as complete and `host/members` as the next candidate. + +- [ ] **Step 2: Create engineering confidence document** + +Create `docs/showcase/engineering-confidence.md`: + +```markdown +# Engineering Confidence + +이 문서는 ReadMates가 커진 뒤에도 변경 가능한 코드베이스로 남기 위해 사용하는 경계, 테스트, 품질 게이트를 정리합니다. + +## Boundary Evidence + +| Boundary | Guardrail | What it prevents | +| --- | --- | --- | +| Frontend route-first architecture | `front/tests/unit/frontend-boundaries.test.ts` | shared가 app/page/feature를 거꾸로 import하거나 feature UI가 route/API를 직접 잡는 회귀 | +| Server clean architecture | `ServerArchitectureBoundaryTest` | web adapter가 persistence/JDBC를 직접 잡거나 application package가 Spring Web/adapter에 의존하는 회귀 | +| CQRS read/write convention | `@ReadOnlyApplicationService` + ArchUnit rules | read-only service가 mutation port나 write transaction을 갖는 회귀 | +| Flyway migration compatibility | `MySqlFlywayMigrationTest` | MySQL-specific migration, collation, FK compatibility 회귀 | +| Query budget | `ServerQueryBudgetTest` | 주요 화면의 accidental N+1 query 회귀 | +| Public release safety | `scripts/build-public-release-candidate.sh`, `scripts/public-release-check.sh` | public candidate에 private state, local path, secret-shaped data가 포함되는 회귀 | + +## Frontend Server-State Migration + +Current source: `docs/development/server-state-migration.md` + +Completed: + +- `host/invitations` — list query, create/revoke mutation, loader handoff + +Next candidates: + +1. `host/members` +2. `host/notifications` +3. `host/sessions` + +Migration rule: route modules own loader/action coordination, UI components stay prop/callback driven, and new Query helpers live under `front/features//queries/`. + +## Server Boundary Follow-Ups + +The session package already has separate draft, lifecycle, attendance, publication, and query services. The next useful server confidence work is transaction boundary documentation and a narrow cleanup of adapter-level transaction annotations where application services already own the transaction. + +## Validation Commands + +Frontend: + +```bash +pnpm --dir front lint +pnpm --dir front test +pnpm --dir front build +``` + +Server: + +```bash +./server/gradlew -p server unitTest +./server/gradlew -p server architectureTest +./server/gradlew -p server check +``` + +Public release: + +```bash +./scripts/build-public-release-candidate.sh +./scripts/public-release-check.sh .tmp/public-release-candidate +``` +``` + +- [ ] **Step 3: Update server-state migration status** + +Modify `docs/development/server-state-migration.md` to add an "이번 분기 계획" section: + +```markdown +## 이번 분기 계획 + +Engineering proof portfolio 분기에서는 다음 순서로 server state migration을 진행합니다. + +1. `host/members` — 멤버 목록과 lifecycle/profile/viewer mutation을 Query invalidation 패턴으로 정리합니다. +2. `host/notifications` — 수동 알림 options/preview/confirm/dispatch ledger를 route-owned state와 Query cache로 분리합니다. +3. `host/sessions` — 세션 목록/read path부터 좁게 시작하고 editor mutation은 별도 pass로 나눕니다. + +각 migration은 UI 컴포넌트가 API를 직접 호출하지 않는다는 route-first 경계를 유지해야 합니다. +``` + +- [ ] **Step 4: Validate** + +Run: + +```bash +git diff --check -- docs/showcase/engineering-confidence.md docs/development/server-state-migration.md +``` + +Expected: no output. + +- [ ] **Step 5: Commit** + +Run: + +```bash +git add docs/showcase/engineering-confidence.md docs/development/server-state-migration.md +git commit -m "docs: document engineering confidence evidence" +``` + +Expected: one docs-only commit. + +--- + +## Task 6: Write Operational Proof Guide + +**Files:** + +- Create: `docs/showcase/operational-proof.md` + +- [ ] **Step 1: Inspect release and operations docs** + +Run: + +```bash +sed -n '1,220p' docs/development/release-readiness-review.md +sed -n '1,220p' scripts/README.md +sed -n '1,180p' docs/operations/README.md +sed -n '1,180p' docs/operations/runbooks/README.md +``` + +Expected: release readiness warns that passing tests is not proof that release risk is closed. + +- [ ] **Step 2: Create operational proof document** + +Create `docs/showcase/operational-proof.md`: + +```markdown +# Operational Proof + +이 문서는 ReadMates가 기능 구현 뒤 release, deploy, observability, incident learning까지 어떻게 닫는지 보여주는 reviewer-facing guide입니다. + +## Release Evidence Flow + +```text +Change + -> targeted local checks + -> release readiness review + -> public release candidate build/check + -> changelog/release note update + -> deploy runbook + -> smoke/post-deploy watch + -> postmortem when an incident occurs +``` + +## Evidence Links + +| Stage | Evidence | +| --- | --- | +| Release readiness | `docs/development/release-readiness-review.md` | +| Public release candidate | `scripts/build-public-release-candidate.sh`, `scripts/public-release-check.sh`, `scripts/README.md` | +| Public repository safety | `docs/deploy/security-public-repo.md` | +| Deploy runbooks | `docs/deploy/README.md`, `docs/deploy/release-publish-runbook.md` | +| Observability | `docs/operations/observability/README.md` | +| Post-deploy watch | `docs/operations/runbooks/post-deploy-watch.md` | +| Incident learning | `docs/operations/postmortems/README.md` | + +## Operating Principle + +Passing tests is evidence, not proof that release risk is closed. Release readiness review also checks changelog coverage, operator-facing behavior changes, CI/deploy script risks, security-code hygiene, architecture-test baselines, and public-release safety. + +## Public-Safe Incident Learning + +Incident writeups should explain: + +- trigger and customer/operator impact +- detection path +- rollback or mitigation +- root cause +- prevention added to code, tests, scripts, or runbooks + +Incident writeups must not include real member data, private domains, secrets, raw provider payloads, local paths, or deployment identifiers. +``` + +- [ ] **Step 3: Validate** + +Run: + +```bash +git diff --check -- docs/showcase/operational-proof.md +rg -n "local absolute path|OCID|token-shaped|private key|private domain" docs/showcase/operational-proof.md +``` + +Expected: no whitespace errors and no active private values. + +- [ ] **Step 4: Commit** + +Run: + +```bash +git add docs/showcase/operational-proof.md +git commit -m "docs: add operational proof guide" +``` + +Expected: one docs-only commit. + +--- + +## Task 7: Migrate Host Members to TanStack Query + +**Files:** + +- Create: `front/features/host/queries/host-members-queries.ts` +- Modify: `front/src/app/routes/host.tsx` +- Modify: `front/features/host/route/host-members-data.ts` +- Modify: `front/features/host/ui/host-members.tsx` +- Modify: `front/tests/unit/host-members.test.tsx` +- Modify: `docs/development/server-state-migration.md` + +- [ ] **Step 1: Read frontend guide and current invitations pattern** + +Run: + +```bash +sed -n '1,220p' docs/agents/front.md +sed -n '1,220p' front/features/host/queries/host-invitation-queries.ts +sed -n '1,160p' front/features/host/route/host-invitations-data.ts +``` + +Expected: host invitations uses `queryOptions`, `setQueryData`, and invalidates `hostInvitationKeys.all`. + +- [ ] **Step 2: Add host members query helper** + +Create `front/features/host/queries/host-members-queries.ts`: + +```typescript +import type { QueryClient } from "@tanstack/react-query"; +import { queryOptions, useMutation, useQueryClient } from "@tanstack/react-query"; +import { + fetchHostMembers, + submitHostMemberLifecycle, + submitHostMemberProfile, + submitHostViewerAction, +} from "@/features/host/api/host-api"; +import type { + HostMemberProfileResponse, + HostMemberListPage, + MemberLifecycleRequest, + MemberLifecycleResponse, + ViewerMember, +} from "@/features/host/api/host-contracts"; +import type { + HostMemberLifecyclePath, + HostViewerAction, +} from "@/features/host/route/host-members-actions"; +import type { ReadmatesApiContext } from "@/shared/api/client"; +import type { PageRequest } from "@/shared/model/paging"; + +type JsonResponse = Response & { json(): Promise }; + +export const hostMemberKeys = { + all: ["host", "members"] as const, + list: (page?: PageRequest) => [...hostMemberKeys.all, "list", page ?? {}] as const, +} as const; + +async function fetchHostMemberList( + context?: ReadmatesApiContext, + page?: PageRequest, +): Promise { + return fetchHostMembers(context, page); +} + +export function hostMemberListQuery(page?: PageRequest, context?: ReadmatesApiContext) { + return queryOptions({ + queryKey: hostMemberKeys.list(page), + queryFn: () => fetchHostMemberList(context, page), + }); +} + +export function invalidateHostMembers(client: QueryClient) { + return client.invalidateQueries({ queryKey: hostMemberKeys.all }); +} + +export function useHostMemberLifecycleMutation() { + const client = useQueryClient(); + return useMutation({ + mutationFn: async ({ + membershipId, + path, + body, + }: { + membershipId: string; + path: HostMemberLifecyclePath; + body?: MemberLifecycleRequest; + }) => submitHostMemberLifecycle(membershipId, path, body), + onSuccess: () => invalidateHostMembers(client), + }); +} + +export function useHostMemberProfileMutation() { + const client = useQueryClient(); + return useMutation({ + mutationFn: async ({ + membershipId, + displayName, + }: { + membershipId: string; + displayName: string; + }) => submitHostMemberProfile(membershipId, displayName), + onSuccess: () => invalidateHostMembers(client), + }); +} + +export function useHostViewerActionMutation() { + const client = useQueryClient(); + return useMutation({ + mutationFn: async ({ + membershipId, + action, + }: { + membershipId: string; + action: HostViewerAction; + }) => submitHostViewerAction(membershipId, action), + onSuccess: () => invalidateHostMembers(client), + }); +} + +export async function parseHostMemberLifecycleResponse( + response: JsonResponse, +): Promise { + return response.json(); +} + +export async function parseHostMemberProfileResponse( + response: JsonResponse, +): Promise { + return response.json(); +} + +export function parseHostViewerResponse(response: ViewerMember): ViewerMember { + return response; +} +``` + +- [ ] **Step 3: Convert loader to factory and seed query cache** + +Modify `front/features/host/route/host-members-data.ts` so it exports `hostMembersLoaderFactory(client: QueryClient)`: + +```typescript +import type { QueryClient } from "@tanstack/react-query"; +import { + fetchHostMembers, + submitHostMemberLifecycle, + submitHostMemberProfile, + submitHostViewerAction, +} from "@/features/host/api/host-api"; +import type { HostMembersActions } from "@/features/host/route/host-members-actions"; +import { hostMemberListQuery } from "@/features/host/queries/host-members-queries"; +import type { LoaderFunctionArgs } from "react-router-dom"; +import { requireHostLoaderAuth } from "./host-loader-auth"; +import { clubSlugFromLoaderArgs } from "@/shared/auth/member-app-loader"; + +const HOST_MEMBERS_PAGE_LIMIT = 50; + +export function hostMembersLoaderFactory(client: QueryClient) { + return async (args?: LoaderFunctionArgs) => { + await requireHostLoaderAuth(args); + + const page = await fetchHostMembers( + { clubSlug: clubSlugFromLoaderArgs(args) }, + { limit: HOST_MEMBERS_PAGE_LIMIT }, + ); + + client.setQueryData( + hostMemberListQuery({ limit: HOST_MEMBERS_PAGE_LIMIT }).queryKey, + page, + ); + + return page; + }; +} + +export const hostMembersActions = { + loadMembers: (page) => fetchHostMembers(undefined, page), + submitLifecycle: submitHostMemberLifecycle, + submitProfile: submitHostMemberProfile, + submitViewerAction: submitHostViewerAction, +} satisfies HostMembersActions; +``` + +- [ ] **Step 4: Pass query client from host routes** + +Modify the `members` route in `front/src/app/routes/host.tsx`: + +```typescript +{ + path: "members", + errorElement: , + hydrateFallbackElement: , + lazy: async () => { + const [{ HostMembersRouteElement }, { hostMembersLoaderFactory }] = await Promise.all([ + import("@/src/app/host-route-elements"), + import("@/features/host/route/host-members-data"), + ]); + return { + Component: HostMembersRouteElement, + loader: hostMembersLoaderFactory(queryClient), + }; + }, +} +``` + +- [ ] **Step 5: Wire `HostMembers` to Query without moving API calls into UI** + +In `front/features/host/ui/host-members.tsx`, import Query helpers: + +```typescript +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { hostMemberListQuery, invalidateHostMembers } from "@/features/host/queries/host-members-queries"; +``` + +Replace the local initial page/member source setup with the same pattern used by host invitations: + +```typescript +const initialPage = normalizeMemberPage(initialMembers); +const queryClient = useQueryClient(); +const listQuery = useQuery({ + ...hostMemberListQuery({ limit: 50 }), + queryFn: async () => normalizeMemberPage(await actions.loadMembers({ limit: 50 })), + initialData: initialPage, +}); +const queryMembers = listQuery.data?.items ?? []; +const [memberRowsState, setMemberRowsState] = useState(() => ({ + source: queryMembers, + members: queryMembers, +})); +const members = memberRowsState.source === queryMembers ? memberRowsState.members : queryMembers; +``` + +After each successful lifecycle, profile, and viewer action path that currently calls `refreshMembers()`, keep the existing UI refresh behavior and add: + +```typescript +await invalidateHostMembers(queryClient); +``` + +Do not call `fetchHostMembers` directly from UI. Continue using `actions.loadMembers`. + +- [ ] **Step 6: Add focused tests** + +Modify `front/tests/unit/host-members.test.tsx` with tests that assert: + +```typescript +it("seeds the host members list through the route loader", async () => { + const fetchMock = renderHostMembersPage(); + + expect(await screen.findByRole("tab", { name: "활성 멤버" })).toBeInTheDocument(); + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining("/api/bff/host/members?limit=50"), + expect.anything(), + ); +}); +``` + +And: + +```typescript +it("refreshes the Query-backed list after a member profile update", async () => { + const user = userEvent.setup(); + const updated = { ...members[0], displayName: "새이름", accountName: "안멤버1" } satisfies HostMemberListItem; + renderHostMembersPage([memberListItemResponse(updated)]); + + const row = within((await screen.findByText("멤버1")).closest("article") as HTMLElement); + await user.click(row.getByRole("button", { name: "이름 변경" })); + + const dialog = within(screen.getByRole("dialog", { name: "멤버1 이름 수정" })); + await user.clear(dialog.getByLabelText("이름")); + await user.type(dialog.getByLabelText("이름"), "새이름"); + await user.click(dialog.getByRole("button", { name: "저장" })); + + expect(await screen.findByText("새이름")).toBeInTheDocument(); + expect(screen.queryByText("멤버1")).not.toBeInTheDocument(); +}); +``` + +If the existing helper URL assertion differs because BFF path construction is mocked at a lower level, assert the exact current mocked URL used by `renderHostMembersPage()` rather than changing production code for the test. + +- [ ] **Step 7: Update migration status** + +Modify `docs/development/server-state-migration.md`: + +```markdown +## 완료 +- `host/invitations` — list query + create/revoke mutation + loader hand-off +- `host/members` — list query + lifecycle/profile/viewer mutation refresh + loader hand-off +``` + +Move `host/members` out of the 후속 후보 list. + +- [ ] **Step 8: Run frontend checks** + +Run: + +```bash +pnpm --dir front test -- host-members +pnpm --dir front lint +pnpm --dir front build +``` + +Expected: all commands pass. + +- [ ] **Step 9: Commit** + +Run: + +```bash +git add front/features/host/queries/host-members-queries.ts \ + front/src/app/routes/host.tsx \ + front/features/host/route/host-members-data.ts \ + front/features/host/ui/host-members.tsx \ + front/tests/unit/host-members.test.tsx \ + docs/development/server-state-migration.md +git commit -m "feat(front): migrate host members to query cache" +``` + +Expected: one frontend confidence commit. + +--- + +## Task 8: Plan Host Notifications Query Migration as a Separate Slice + +**Files:** + +- Create: `docs/superpowers/plans/2026-05-17-readmates-host-notifications-query-migration.md` +- Modify: `docs/development/server-state-migration.md` + +- [ ] **Step 1: Inspect current notification route and UI split** + +Run: + +```bash +sed -n '1,220p' front/features/host/route/host-notifications-data.ts +sed -n '1,220p' front/features/host/route/host-notifications-route.tsx +find front/features/host/ui/notifications -maxdepth 1 -type f | sort +``` + +Expected: notifications are larger than host members and need a separate migration plan. + +- [ ] **Step 2: Create a focused notification migration plan** + +Create `docs/superpowers/plans/2026-05-17-readmates-host-notifications-query-migration.md` with this header: + +```markdown +# ReadMates Host Notifications Query Migration Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Move host notification summary, event ledger, delivery ledger, manual options, preview, confirm, and dispatch ledger reads into TanStack Query without moving API calls into UI components. + +**Architecture:** Keep `front/features/host/route` responsible for loader/action coordination and keep `front/features/host/ui/notifications` prop/callback driven. Add `front/features/host/queries/host-notification-queries.ts` for query keys, queryOptions, and mutation invalidation helpers. + +**Tech Stack:** React 19, React Router 7, TanStack Query v5, Vitest, Testing Library. + +--- +``` + +Continue the new plan with this body: + +````markdown +## Task 1: Map Current Notification Data Flow + +**Files:** + +- Read: `front/features/host/route/host-notifications-data.ts` +- Read: `front/features/host/route/host-notifications-route.tsx` +- Read: `front/features/host/ui/host-notifications-page.tsx` +- Read: `front/features/host/ui/notifications/manual-notification-workbench.tsx` + +- [ ] **Step 1: Inspect existing route and UI data flow** + +Run: + +```bash +sed -n '1,240p' front/features/host/route/host-notifications-data.ts +sed -n '1,240p' front/features/host/route/host-notifications-route.tsx +sed -n '1,260p' front/features/host/ui/host-notifications-page.tsx +sed -n '1,260p' front/features/host/ui/notifications/manual-notification-workbench.tsx +``` + +Expected: route owns loader data, while UI coordinates several host notification reads and manual dispatch actions. + +## Task 2: Add Notification Query Keys + +**Files:** + +- Create: `front/features/host/queries/host-notification-queries.ts` + +- [ ] **Step 1: Create query key module** + +Create query keys for `summary`, `items(status,page)`, `events(page)`, `deliveries(page)`, `manualOptions(sessionId,search,page)`, and `manualDispatches(sessionId,eventType,page)`. Each key starts with `["host", "notifications"]`. + +- [ ] **Step 2: Add invalidation helpers** + +Add `invalidateHostNotifications(client)` for all host notification state and `invalidateManualNotificationState(client)` for manual options/dispatches. + +## Task 3: Seed Loader Data + +**Files:** + +- Modify: `front/src/app/routes/host.tsx` +- Modify: `front/features/host/route/host-notifications-data.ts` + +- [ ] **Step 1: Convert loader to factory** + +Follow the `hostMembersLoaderFactory(client)` pattern from the engineering proof portfolio plan. Seed summary, events, deliveries, and manual options into Query cache from loader data. + +## Task 4: Move Preview and Confirm to Query Mutations + +**Files:** + +- Modify: `front/features/host/ui/notifications/manual-notification-workbench.tsx` + +- [ ] **Step 1: Keep UI prop-driven** + +Use actions passed from the route for API calls. Do not import `host-api.ts` into UI. Use Query mutations only to track pending state and invalidation. + +- [ ] **Step 2: Preserve preview TTL and resend confirmation** + +After preview success, keep the preview token and selection hash state in the workbench. After confirm success, invalidate manual dispatches and notification summary. + +## Task 5: Test Notification Migration + +**Files:** + +- Modify: `front/tests/unit/host-notifications.test.tsx` + +- [ ] **Step 1: Add regression tests** + +Add these regression tests to `front/tests/unit/host-notifications.test.tsx`: + +```typescript +it("keeps manual preview state when notification queries invalidate", async () => { + // Arrange with the existing manual notification route fixture. + // Preview a manual notification. + // Trigger an invalidation through a successful confirm or process action. + // Assert the preview token, selected template, and target count remain visible until confirm resolves. +}); + +it("requires explicit resend confirmation after query migration", async () => { + // Arrange with a recent manual dispatch fixture for the same session/template. + // Preview the same dispatch. + // Assert confirm is blocked until the resend confirmation control is selected. +}); + +it("refreshes manual dispatch ledger after confirm", async () => { + // Arrange with an empty dispatch ledger. + // Confirm a preview. + // Assert the ledger query refetch shows the new dispatch row. +}); +``` + +Replace the comments with the existing test helper calls in that file; keep the three test names and assertions. + +- [ ] **Step 2: Run checks** + +Run: + +```bash +pnpm --dir front test -- host-notifications +pnpm --dir front lint +pnpm --dir front build +``` + +Expected: all commands pass. +```` + +- [ ] **Step 3: Update migration status** + +Modify `docs/development/server-state-migration.md` so `host/notifications` points to the new detailed plan: + +```markdown +2. `host/notifications` — detailed migration plan: `docs/superpowers/plans/2026-05-17-readmates-host-notifications-query-migration.md` +``` + +- [ ] **Step 4: Validate and commit** + +Run: + +```bash +git diff --check -- docs/superpowers/plans/2026-05-17-readmates-host-notifications-query-migration.md docs/development/server-state-migration.md +``` + +Commit: + +```bash +git add docs/superpowers/plans/2026-05-17-readmates-host-notifications-query-migration.md docs/development/server-state-migration.md +git commit -m "docs: plan host notifications query migration" +``` + +Expected: one planning commit with no frontend runtime changes. + +--- + +## Task 9: Document Server Transaction Boundary Policy + +**Files:** + +- Modify: `docs/development/technical-decisions.md` + +- [ ] **Step 1: Inspect current transaction ownership** + +Run: + +```bash +rg -n "@Transactional" server/src/main/kotlin/com/readmates/session server/src/main/kotlin/com/readmates/notification server/src/main/kotlin/com/readmates/club server/src/main/kotlin/com/readmates/auth +sed -n '1,140p' server/src/main/kotlin/com/readmates/session/application/service/HostSessionDraftCommandService.kt +sed -n '1,140p' server/src/main/kotlin/com/readmates/session/adapter/out/persistence/JdbcHostSessionWriteAdapter.kt +``` + +Expected: session application services own write transactions, while some persistence adapters still carry method-level `@Transactional`. + +- [ ] **Step 2: Add transaction policy note** + +Append this section to `docs/development/technical-decisions.md`: + +```markdown +## Transaction Boundary Policy + +Application services own business transaction boundaries. Controllers parse HTTP and call use cases; persistence adapters execute SQL and mapping. When an application service coordinates more than one write port, the service method owns the transaction so cache invalidation, notification event recording, and state mutation share one visible boundary. + +Adapter-level `@Transactional` is allowed only when the adapter is called by an inbound scheduler, Kafka listener, or other path that does not already pass through an application service transaction. If both service and adapter carry `@Transactional`, the service boundary is treated as the authoritative boundary and the adapter annotation should be removed in a narrow cleanup once tests pin the behavior. + +Isolation is specified only where the operation depends on claim/read-modify-write behavior that needs a non-default guarantee. Existing examples include session/login restoration and notification delivery claiming. New isolation choices must be explained in the service or adjacent decision record. +``` + +- [ ] **Step 3: Validate docs formatting** + +Run: + +```bash +git diff --check -- docs/development/technical-decisions.md +``` + +Expected: no output. + +- [ ] **Step 4: Commit** + +Run: + +```bash +git add docs/development/technical-decisions.md +git commit -m "docs: document transaction boundary policy" +``` + +Expected: one docs-only server-confidence commit. + +--- + +## Task 10: Remove Redundant Host Session Adapter Transactions + +**Files:** + +- Modify: `server/src/main/kotlin/com/readmates/session/adapter/out/persistence/JdbcHostSessionWriteAdapter.kt` + +- [ ] **Step 1: Confirm application services own session write transactions** + +Run: + +```bash +sed -n '1,120p' server/src/main/kotlin/com/readmates/session/application/service/HostSessionDraftCommandService.kt +sed -n '1,140p' server/src/main/kotlin/com/readmates/session/application/service/HostSessionLifecycleService.kt +sed -n '1,80p' server/src/main/kotlin/com/readmates/session/application/service/HostSessionAttendanceService.kt +sed -n '1,80p' server/src/main/kotlin/com/readmates/session/application/service/HostSessionPublicationService.kt +``` + +Expected: create, update, updateVisibility, open, close, publish, delete, confirmAttendance, and upsertPublication are already service-level `@Transactional` operations. + +- [ ] **Step 2: Run current host session tests before refactor** + +Run: + +```bash +./server/gradlew -p server unitTest --tests 'com.readmates.session.application.service.HostSessionServicesTest' +./server/gradlew -p server integrationTest --tests 'com.readmates.session.api.HostSessionControllerDbTest' +``` + +Expected: both commands pass before the refactor. + +- [ ] **Step 3: Remove adapter transaction annotations** + +Modify `server/src/main/kotlin/com/readmates/session/adapter/out/persistence/JdbcHostSessionWriteAdapter.kt`: + +```kotlin +// Remove this import: +import org.springframework.transaction.annotation.Transactional +``` + +Remove the `@Transactional` annotation immediately above each of these methods: + +```kotlin +override fun create(command: HostSessionCommand) +override fun update(command: UpdateHostSessionCommand) +override fun delete(command: HostSessionIdCommand) +override fun confirmAttendance(command: ConfirmAttendanceCommand) +override fun upsertPublication(command: UpsertPublicationCommand) +override fun updateVisibility(command: UpdateHostSessionVisibilityCommand) +override fun open(command: HostSessionIdCommand) +override fun close(command: HostSessionIdCommand) +override fun publish(command: HostSessionIdCommand) +``` + +Do not change SQL, ports, service signatures, cache invalidation, notification recording, or query methods. + +- [ ] **Step 4: Run server checks after refactor** + +Run: + +```bash +./server/gradlew -p server unitTest --tests 'com.readmates.session.application.service.HostSessionServicesTest' +./server/gradlew -p server integrationTest --tests 'com.readmates.session.api.HostSessionControllerDbTest' +./server/gradlew -p server architectureTest +``` + +Expected: all commands pass. + +- [ ] **Step 5: Commit** + +Run: + +```bash +git add server/src/main/kotlin/com/readmates/session/adapter/out/persistence/JdbcHostSessionWriteAdapter.kt +git commit -m "refactor(server): clarify session transaction boundary" +``` + +Expected: one server confidence commit with no API behavior change. + +--- + +## Task 11: Initiative Closeout and Release Readiness Review + +**Files:** + +- Modify: `README.md` if final links or wording need alignment +- Modify: `CHANGELOG.md` if user-visible docs/showcase or quality workflow changes should be noted +- Modify: `docs/showcase/*.md` only for consistency fixes + +- [ ] **Step 1: Review branch scope** + +Run: + +```bash +git status --short --branch +git log --oneline origin/main..HEAD +git diff --stat origin/main..HEAD +git diff --name-only origin/main..HEAD +``` + +Expected: only intended docs, frontend confidence, server confidence, and release-safety files are changed across the initiative branch. + +- [ ] **Step 2: Run release-readiness checks** + +Run: + +```bash +git diff --check origin/main..HEAD +rg -n "^## Unreleased|Engineering proof|showcase|guest-mode|server-state|transaction" CHANGELOG.md README.md docs +``` + +Expected: no whitespace errors. Findings show where the initiative is documented. + +- [ ] **Step 3: Run public release candidate checks** + +Run: + +```bash +./scripts/build-public-release-candidate.sh +./scripts/public-release-check.sh .tmp/public-release-candidate +``` + +Expected: candidate build succeeds and scanner reports no blocking public-safety finding. + +- [ ] **Step 4: Run code checks if Tasks 7 or 10 changed runtime code** + +Frontend code changed: + +```bash +pnpm --dir front lint +pnpm --dir front test +pnpm --dir front build +``` + +Server code changed: + +```bash +./server/gradlew -p server unitTest +./server/gradlew -p server architectureTest +``` + +Expected: touched-surface checks pass. If a command cannot run because local dependencies are unavailable, record the skipped command and exact reason in the final review note. + +- [ ] **Step 5: Produce final release-readiness note** + +Create a short final note in the PR description or release-readiness review comment with: + +```markdown +## Scope + +- Reviewer-facing showcase docs +- README review path +- Engineering confidence evidence +- Operational proof evidence +- Frontend Query migration work completed in this branch +- Server transaction boundary work completed in this branch + +## Validation + +- `git diff --check origin/main..HEAD` +- `./scripts/build-public-release-candidate.sh` +- `./scripts/public-release-check.sh .tmp/public-release-candidate` +- Frontend checks: `pnpm --dir front lint`, `pnpm --dir front test`, `pnpm --dir front build`; skipped commands are listed with the exact local blocker. +- Server checks: `./server/gradlew -p server unitTest`, `./server/gradlew -p server architectureTest`; skipped commands are listed with the exact local blocker. + +## Residual Risk + +- Showcase docs summarize current source-of-truth docs and can become stale if architecture changes without link updates. +- Guest-mode walkthrough depends on public route behavior staying aligned with `docs/development/architecture.md`. +- Host notifications Query migration remains tracked separately if Task 8 was planning-only. +``` + +- [ ] **Step 6: Commit closeout changes** + +Run: + +```bash +git add README.md CHANGELOG.md docs/showcase +git commit -m "docs: close engineering proof portfolio review" +``` + +Expected: commit only if Step 5 revealed actual file changes. If no files changed, do not create an empty commit. + +--- + +## Plan Self-Review + +Spec coverage: + +- Portfolio entry: Tasks 1-2 +- Guest-mode showcase: Task 3 +- Architecture evidence: Task 4 +- Engineering confidence: Tasks 5, 7, 8, 9, 10 +- Operational proof: Task 6 +- Release/public safety verification: Task 11 +- Public safety constraints: global rules plus task validation scans + +No task requires private data, public auth bypass, real deployment state, or external live provider keys. + +Execution rule: implement tasks in order and commit after each task. Do not batch Tasks 1-6 with Tasks 7-10. From 1d167022a99585334a86955b61f754073b8d3bbb Mon Sep 17 00:00:00 2001 From: kws Date: Sun, 17 May 2026 23:10:08 +0900 Subject: [PATCH 17/30] docs: align engineering proof portfolio plan and spec with code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - spec 6.2 frontend candidate order aligned with 9.1 and plan execution (members → notifications → sessions) - plan Task 7 drops unused parse helpers and avoids smuggling errorElement/hydrateFallbackElement into the host members route - plan Task 7 also reorders server-state-migration 후속 후보 so it stays consistent with Task 8's notifications-next premise Co-Authored-By: Claude Opus 4.7 --- ...ing-proof-portfolio-implementation-plan.md | 39 +++++++------------ ...ates-engineering-proof-portfolio-design.md | 8 ++-- 2 files changed, 18 insertions(+), 29 deletions(-) diff --git a/docs/superpowers/plans/2026-05-17-readmates-engineering-proof-portfolio-implementation-plan.md b/docs/superpowers/plans/2026-05-17-readmates-engineering-proof-portfolio-implementation-plan.md index d45af6bf..4c615fd0 100644 --- a/docs/superpowers/plans/2026-05-17-readmates-engineering-proof-portfolio-implementation-plan.md +++ b/docs/superpowers/plans/2026-05-17-readmates-engineering-proof-portfolio-implementation-plan.md @@ -646,11 +646,8 @@ import { submitHostViewerAction, } from "@/features/host/api/host-api"; import type { - HostMemberProfileResponse, HostMemberListPage, MemberLifecycleRequest, - MemberLifecycleResponse, - ViewerMember, } from "@/features/host/api/host-contracts"; import type { HostMemberLifecyclePath, @@ -659,8 +656,6 @@ import type { import type { ReadmatesApiContext } from "@/shared/api/client"; import type { PageRequest } from "@/shared/model/paging"; -type JsonResponse = Response & { json(): Promise }; - export const hostMemberKeys = { all: ["host", "members"] as const, list: (page?: PageRequest) => [...hostMemberKeys.all, "list", page ?? {}] as const, @@ -727,22 +722,6 @@ export function useHostViewerActionMutation() { onSuccess: () => invalidateHostMembers(client), }); } - -export async function parseHostMemberLifecycleResponse( - response: JsonResponse, -): Promise { - return response.json(); -} - -export async function parseHostMemberProfileResponse( - response: JsonResponse, -): Promise { - return response.json(); -} - -export function parseHostViewerResponse(response: ViewerMember): ViewerMember { - return response; -} ``` - [ ] **Step 3: Convert loader to factory and seed query cache** @@ -793,13 +772,11 @@ export const hostMembersActions = { - [ ] **Step 4: Pass query client from host routes** -Modify the `members` route in `front/src/app/routes/host.tsx`: +Modify the `members` route in `front/src/app/routes/host.tsx` to thread `queryClient` into the loader factory while keeping the surrounding route shape identical to the current code (no new `errorElement` or `hydrateFallbackElement` fields): ```typescript { path: "members", - errorElement: , - hydrateFallbackElement: , lazy: async () => { const [{ HostMembersRouteElement }, { hostMembersLoaderFactory }] = await Promise.all([ import("@/src/app/host-route-elements"), @@ -813,6 +790,8 @@ Modify the `members` route in `front/src/app/routes/host.tsx`: } ``` +The only change versus the current `host.tsx` member route is that `hostMembersLoader` becomes `hostMembersLoaderFactory(queryClient)`. Error/loading fallback wiring stays out of this task; if it should be added, do it in a separate route-UX PR. + - [ ] **Step 5: Wire `HostMembers` to Query without moving API calls into UI** In `front/features/host/ui/host-members.tsx`, import Query helpers: @@ -897,7 +876,17 @@ Modify `docs/development/server-state-migration.md`: - `host/members` — list query + lifecycle/profile/viewer mutation refresh + loader hand-off ``` -Move `host/members` out of the 후속 후보 list. +Also update the `## 후속 후보 (우선순위)` list so it (a) removes `host/members` and (b) reorders the remaining frontend candidates to match the design spec section 9.1 priority (`host/notifications` before `host/sessions`). The intended post-edit shape: + +```markdown +## 후속 후보 (우선순위) +1. `host/notifications` +2. `host/sessions` +3. `current-session` (actions 4개) +4. `archive`, `feedback`, `public` — 읽기 중심, loader 와 결합도 높음 +``` + +This keeps Task 8's premise (notifications is the next slice) consistent with the migration status doc. - [ ] **Step 8: Run frontend checks** diff --git a/docs/superpowers/specs/2026-05-17-readmates-engineering-proof-portfolio-design.md b/docs/superpowers/specs/2026-05-17-readmates-engineering-proof-portfolio-design.md index 7955d352..edf06304 100644 --- a/docs/superpowers/specs/2026-05-17-readmates-engineering-proof-portfolio-design.md +++ b/docs/superpowers/specs/2026-05-17-readmates-engineering-proof-portfolio-design.md @@ -199,12 +199,12 @@ ReadMates는 이미 단순 CRUD 애플리케이션 범위를 넘어섰다. 현 1. `host/members` - 운영 빈도가 높고 state mutation이 명확하다. - route-owned data coordination과 TanStack Query invalidation 패턴을 보여주기 좋다. -2. `host/sessions` - - session lifecycle, visibility, current/upcoming state와 연결되어 제품 의미가 크다. - - migration 범위가 넓을 수 있으므로 slice를 나누어야 한다. -3. `host/notifications` +2. `host/notifications` - manual dispatch preview/confirm, dispatch ledger, recipient state가 있어 운영형 UX 근거가 강하다. - E2E 영향이 있을 수 있어 작은 단위로 접근한다. +3. `host/sessions` + - session lifecycle, visibility, current/upcoming state와 연결되어 제품 의미가 크다. + - migration 범위가 넓을 수 있으므로 slice를 나누어야 한다. 서버 후보: From 7ff91922aaec08bb3a7f792cc3ee25879b2ec941 Mon Sep 17 00:00:00 2001 From: kws Date: Sun, 17 May 2026 23:22:52 +0900 Subject: [PATCH 18/30] docs: add showcase index Task: task_1 Risk: low Files: docs/showcase/README.md, docs/README.md --- docs/README.md | 1 + docs/showcase/README.md | 26 ++++++++++++++++++++++++++ 2 files changed, 27 insertions(+) create mode 100644 docs/showcase/README.md diff --git a/docs/README.md b/docs/README.md index c91320bd..7ed7f0aa 100644 --- a/docs/README.md +++ b/docs/README.md @@ -18,6 +18,7 @@ ReadMates 문서의 진입점입니다. 어떤 일을 할 때 어디 문서를 ## 디렉터리 의미 +- [Showcase](showcase/README.md): 처음 보는 리뷰어를 위한 guest-mode walkthrough, architecture evidence, engineering confidence, operational proof 진입점입니다. - [`development/`](development) — 현재 동작 기준의 정전 가이드 (architecture, local setup, test, technical decisions, versioning, release management). 코드와 충돌하면 코드와 함께 갱신합니다. - [`../design/`](../design) — 재사용 UI source package와 정적 디자인 catalog. 제품 코드가 공유하는 디자인 primitive와 pattern preview를 확인합니다. - [`deploy/`](deploy) — 운영 배포 runbook. Cloudflare Pages, OCI Compose stack, OCI MySQL HeatWave, multi-club domain, public repo safety. diff --git a/docs/showcase/README.md b/docs/showcase/README.md new file mode 100644 index 00000000..a2a23490 --- /dev/null +++ b/docs/showcase/README.md @@ -0,0 +1,26 @@ +# ReadMates Showcase + +이 디렉터리는 ReadMates를 처음 보는 리뷰어가 제품, 아키텍처, 운영 증거, 유지보수 품질을 빠르게 따라갈 수 있도록 만든 reviewer-facing guide입니다. + +현재 동작의 source of truth는 코드, 테스트, scripts, migrations, `docs/development/architecture.md`입니다. Showcase 문서는 그 자료를 대체하지 않고 읽는 순서를 제공합니다. + +## 추천 리뷰 순서 + +1. `README.md`에서 제품 문제와 역할 모델을 확인합니다. +2. `docs/showcase/guest-mode-walkthrough.md`에서 로그인 없이 볼 수 있는 공개 제품 표면을 따라갑니다. +3. `docs/showcase/architecture-evidence.md`에서 BFF, Spring API, MySQL, Redis/Kafka, AI generation, release safety가 어떻게 연결되는지 봅니다. +4. `docs/showcase/engineering-confidence.md`에서 테스트와 경계 검증이 어떤 회귀를 막는지 확인합니다. +5. `docs/showcase/operational-proof.md`에서 release, deploy, observability, postmortem 흐름을 확인합니다. + +## 문서별 역할 + +| 문서 | 답하는 질문 | +| --- | --- | +| `guest-mode-walkthrough.md` | 로그인 없이 무엇을 볼 수 있고, private workflow는 어떤 evidence로 확인하는가? | +| `architecture-evidence.md` | 이 프로젝트가 단순 CRUD가 아니라 운영형 제품인 근거는 무엇인가? | +| `engineering-confidence.md` | 코드베이스가 커져도 무너지지 않게 하는 경계와 검증은 무엇인가? | +| `operational-proof.md` | 배포, 공개 릴리즈 안전, 장애 대응은 어떤 흐름으로 관리되는가? | + +## 공개 안전 기준 + +Showcase 문서는 실제 멤버 데이터, private domain, 운영 secret, deployment state, OCID, token-shaped example, local absolute path를 포함하지 않습니다. Private workflow는 접근 권한을 넓히지 않고 sanitized 설명, fixture, 테스트, runbook으로 설명합니다. From 1bbeb8ac12548121fcacea1b00811899b29490d5 Mon Sep 17 00:00:00 2001 From: kws Date: Sun, 17 May 2026 23:26:20 +0900 Subject: [PATCH 19/30] docs: add guest mode walkthrough Task: task_3 Risk: low Files: docs/showcase/guest-mode-walkthrough.md --- docs/showcase/guest-mode-walkthrough.md | 43 +++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 docs/showcase/guest-mode-walkthrough.md diff --git a/docs/showcase/guest-mode-walkthrough.md b/docs/showcase/guest-mode-walkthrough.md new file mode 100644 index 00000000..9db50268 --- /dev/null +++ b/docs/showcase/guest-mode-walkthrough.md @@ -0,0 +1,43 @@ +# Guest-Mode Walkthrough + +이 문서는 ReadMates를 처음 보는 리뷰어가 로그인 없이 확인할 수 있는 공개 제품 표면과, 로그인 없이 볼 수 없는 private workflow를 어떤 evidence로 확인할지 정리합니다. + +현재 동작의 source of truth는 public route code와 `docs/development/architecture.md`입니다. + +## 로그인 없이 볼 수 있는 것 + +Guest는 클럽이 `ACTIVE`이고 `PUBLIC`인 경우 아래 표면을 볼 수 있습니다. + +| 표면 | 경로 | 확인할 수 있는 것 | +| --- | --- | --- | +| 클럽 소개 | `/clubs/` 또는 `/clubs//about` | 클럽의 공개 소개와 공개 진입 경험 | +| 공개 기록 | `/clubs//records` | 공개된 회차 목록과 archive 흐름 | +| 공개 세션 상세 | `/clubs//sessions/` | 공개 요약, 하이라이트, 한줄평 등 공개 범위에 포함된 기록 | + +운영 fallback 경로는 `https://readmates.pages.dev/clubs/` 형태입니다. 등록된 custom domain은 운영 설정에 따라 달라지므로 이 문서에서는 placeholder만 사용합니다. + +## 추천 관람 순서 + +1. 클럽 소개에서 제품의 공개 첫인상을 확인합니다. +2. 공개 기록 목록에서 회차가 누적되는 방식을 확인합니다. +3. 공개 세션 상세에서 모임 후 기록이 어떻게 읽히는지 확인합니다. +4. README의 Engineering Highlights로 돌아가 공개 화면 뒤의 BFF, publication visibility, notification, AI generation 근거를 확인합니다. + +## 로그인 없이 볼 수 없는 것 + +아래 흐름은 제품 권한상 guest에게 공개하지 않습니다. + +| Private workflow | 공개하지 않는 이유 | 확인 evidence | +| --- | --- | --- | +| 멤버 현재 세션 참여, RSVP, 질문, 서평 작성 | 정식 멤버 권한과 club membership이 필요합니다. | `docs/development/architecture.md`, frontend route guard tests | +| 호스트 세션 생성/수정, 출석 확정, 기록 발행 | 클럽 host 권한이 필요합니다. | host route tests, session server tests, case studies | +| Platform admin onboarding/domain/support access | platform admin 권한이 필요합니다. | platform admin plan/spec, server authorization tests | +| In-app AI 세션 생성 | host 권한, feature flag, provider key, cost/PII guard가 필요합니다. | `docs/case-studies/04-pii-safe-ai-session-generation.md`, AI runbook, `scripts/aigen-pii-check.sh` | +| 수동 알림 발송 | host 권한과 notification outbox pipeline이 필요합니다. | `docs/case-studies/02-notification-pipeline-with-outbox.md`, notification tests | + +## Public-Safety Notes + +- 이 walkthrough는 guest 권한을 넓히지 않습니다. +- 실제 멤버 데이터, private domain, 운영 secret, provider key, deployment state는 사용하지 않습니다. +- Screenshot을 추가할 때는 synthetic 또는 sanitized fixture만 사용합니다. +- Private workflow를 보여줄 필요가 있으면 접근 권한을 열지 않고 테스트, runbook, sanitized 설명으로 연결합니다. From 35554da0c876a89dcd0ee1b3186f46483c1615bb Mon Sep 17 00:00:00 2001 From: kws Date: Sun, 17 May 2026 23:26:36 +0900 Subject: [PATCH 20/30] docs: add architecture evidence map Task: task_4 Risk: low Files: docs/showcase/architecture-evidence.md --- docs/showcase/architecture-evidence.md | 42 ++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 docs/showcase/architecture-evidence.md diff --git a/docs/showcase/architecture-evidence.md b/docs/showcase/architecture-evidence.md new file mode 100644 index 00000000..5b7d9af4 --- /dev/null +++ b/docs/showcase/architecture-evidence.md @@ -0,0 +1,42 @@ +# Architecture Evidence + +이 문서는 ReadMates가 단순 CRUD 앱이 아니라 운영형 멀티클럽 제품인 이유를 한 장으로 보여줍니다. 상세 source of truth는 `docs/development/architecture.md`입니다. + +## One-Page Map + +```text +Browser + -> Cloudflare Pages SPA + -> Pages Functions BFF (/api/bff/**, OAuth proxy) + -> Spring Boot API + -> MySQL/Flyway source of truth + -> optional Redis cache/rate-limit/job state + -> optional Kafka/Redpanda notification and AI job pipeline + -> SMTP/in-app notification side effects +``` + +## Evidence Table + +| Product/engineering claim | Why it matters | Evidence | +| --- | --- | --- | +| Browser traffic goes through a same-origin BFF | Keeps browser-facing security policy, trusted headers, OAuth proxying, and cookie handling at the edge boundary. | `docs/development/adr/0001-cloudflare-pages-functions-bff.md`, `docs/case-studies/01-bff-security-and-secret-rotation.md` | +| Club context is scoped by slug or registered host | Multi-club operation needs role, cache, public URL, and OAuth return behavior to stay club-aware. | `docs/case-studies/03-multi-club-domain-platform.md`, `docs/deploy/multi-club-domains.md` | +| Server feature slices follow clean architecture | Controllers parse HTTP; application services own authorization/orchestration; persistence stays behind ports/adapters. | `docs/development/architecture.md`, `ServerArchitectureBoundaryTest` | +| Notifications use transactional outbox | Mutations do not block on SMTP/in-app delivery; retry and audit state are explicit. | `docs/case-studies/02-notification-pipeline-with-outbox.md` | +| AI generation is feature-gated and audited | Transcript handling, provider calls, cost guard, kill switch, and PII policy are operational boundaries. | `docs/case-studies/04-pii-safe-ai-session-generation.md`, `docs/operations/runbooks/ai-session-generation.md`, `scripts/aigen-pii-check.sh` | +| Public release safety is scripted | Public candidates are built and scanned before release assumptions are made. | `scripts/README.md`, `docs/deploy/security-public-repo.md` | + +## Request Flow + +1. Browser requests same-origin SPA or `/api/bff/**`. +2. Pages Functions strips untrusted internal headers and adds trusted BFF headers. +3. Spring validates BFF secret, session cookie, membership, role, visibility, and attendance rules. +4. MySQL/Flyway remains source of truth. +5. Redis and Kafka are optional supporting layers, never the durable source of private transcript or membership truth. + +## What This Document Does Not Replace + +- API and role details: `docs/development/architecture.md` +- Local setup and checks: `docs/development/README.md` +- Release safety details: `scripts/README.md` +- Deployment runbooks: `docs/deploy/README.md` From dacc89276b7c45ae286c970eff675fab8fe48169 Mon Sep 17 00:00:00 2001 From: kws Date: Sun, 17 May 2026 23:28:48 +0900 Subject: [PATCH 21/30] docs: document engineering confidence evidence Task: task_5 Risk: low Files: docs/showcase/engineering-confidence.md, docs/development/server-state-migration.md --- docs/development/server-state-migration.md | 10 ++++ docs/showcase/engineering-confidence.md | 59 ++++++++++++++++++++++ 2 files changed, 69 insertions(+) create mode 100644 docs/showcase/engineering-confidence.md diff --git a/docs/development/server-state-migration.md b/docs/development/server-state-migration.md index 70744c54..64740e9c 100644 --- a/docs/development/server-state-migration.md +++ b/docs/development/server-state-migration.md @@ -2,6 +2,16 @@ 본 문서는 TanStack Query 마이그레이션 진행 상황을 추적합니다. +## 이번 분기 계획 + +Engineering proof portfolio 분기에서는 다음 순서로 server state migration을 진행합니다. + +1. `host/members` — 멤버 목록과 lifecycle/profile/viewer mutation을 Query invalidation 패턴으로 정리합니다. +2. `host/notifications` — 수동 알림 options/preview/confirm/dispatch ledger를 route-owned state와 Query cache로 분리합니다. +3. `host/sessions` — 세션 목록/read path부터 좁게 시작하고 editor mutation은 별도 pass로 나눕니다. + +각 migration은 UI 컴포넌트가 API를 직접 호출하지 않는다는 route-first 경계를 유지해야 합니다. + ## 완료 - `host/invitations` — list query + create/revoke mutation + loader hand-off diff --git a/docs/showcase/engineering-confidence.md b/docs/showcase/engineering-confidence.md new file mode 100644 index 00000000..a2460dd5 --- /dev/null +++ b/docs/showcase/engineering-confidence.md @@ -0,0 +1,59 @@ +# Engineering Confidence + +이 문서는 ReadMates가 커진 뒤에도 변경 가능한 코드베이스로 남기 위해 사용하는 경계, 테스트, 품질 게이트를 정리합니다. + +## Boundary Evidence + +| Boundary | Guardrail | What it prevents | +| --- | --- | --- | +| Frontend route-first architecture | `front/tests/unit/frontend-boundaries.test.ts` | shared가 app/page/feature를 거꾸로 import하거나 feature UI가 route/API를 직접 잡는 회귀 | +| Server clean architecture | `ServerArchitectureBoundaryTest` | web adapter가 persistence/JDBC를 직접 잡거나 application package가 Spring Web/adapter에 의존하는 회귀 | +| CQRS read/write convention | `@ReadOnlyApplicationService` + ArchUnit rules | read-only service가 mutation port나 write transaction을 갖는 회귀 | +| Flyway migration compatibility | `MySqlFlywayMigrationTest` | MySQL-specific migration, collation, FK compatibility 회귀 | +| Query budget | `ServerQueryBudgetTest` | 주요 화면의 accidental N+1 query 회귀 | +| Public release safety | `scripts/build-public-release-candidate.sh`, `scripts/public-release-check.sh` | public candidate에 private state, local path, secret-shaped data가 포함되는 회귀 | + +## Frontend Server-State Migration + +Current source: `docs/development/server-state-migration.md` + +Completed: + +- `host/invitations` — list query, create/revoke mutation, loader handoff + +Next candidates: + +1. `host/members` +2. `host/notifications` +3. `host/sessions` + +Migration rule: route modules own loader/action coordination, UI components stay prop/callback driven, and new Query helpers live under `front/features//queries/`. + +## Server Boundary Follow-Ups + +The session package already has separate draft, lifecycle, attendance, publication, and query services. The next useful server confidence work is transaction boundary documentation and a narrow cleanup of adapter-level transaction annotations where application services already own the transaction. + +## Validation Commands + +Frontend: + +```bash +pnpm --dir front lint +pnpm --dir front test +pnpm --dir front build +``` + +Server: + +```bash +./server/gradlew -p server unitTest +./server/gradlew -p server architectureTest +./server/gradlew -p server check +``` + +Public release: + +```bash +./scripts/build-public-release-candidate.sh +./scripts/public-release-check.sh .tmp/public-release-candidate +``` From 45c0e4d81441256adcf7733e473f702211d1fb89 Mon Sep 17 00:00:00 2001 From: kws Date: Sun, 17 May 2026 23:28:56 +0900 Subject: [PATCH 22/30] docs: add operational proof guide Task: task_6 Risk: low Files: docs/showcase/operational-proof.md --- docs/showcase/operational-proof.md | 44 ++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 docs/showcase/operational-proof.md diff --git a/docs/showcase/operational-proof.md b/docs/showcase/operational-proof.md new file mode 100644 index 00000000..e706a633 --- /dev/null +++ b/docs/showcase/operational-proof.md @@ -0,0 +1,44 @@ +# Operational Proof + +이 문서는 ReadMates가 기능 구현 뒤 release, deploy, observability, incident learning까지 어떻게 닫는지 보여주는 reviewer-facing guide입니다. + +## Release Evidence Flow + +```text +Change + -> targeted local checks + -> release readiness review + -> public release candidate build/check + -> changelog/release note update + -> deploy runbook + -> smoke/post-deploy watch + -> postmortem when an incident occurs +``` + +## Evidence Links + +| Stage | Evidence | +| --- | --- | +| Release readiness | `docs/development/release-readiness-review.md` | +| Public release candidate | `scripts/build-public-release-candidate.sh`, `scripts/public-release-check.sh`, `scripts/README.md` | +| Public repository safety | `docs/deploy/security-public-repo.md` | +| Deploy runbooks | `docs/deploy/README.md`, `docs/deploy/release-publish-runbook.md` | +| Observability | `docs/operations/observability/README.md` | +| Post-deploy watch | `docs/operations/runbooks/post-deploy-watch.md` | +| Incident learning | `docs/operations/postmortems/README.md` | + +## Operating Principle + +Passing tests is evidence, not proof that release risk is closed. Release readiness review also checks changelog coverage, operator-facing behavior changes, CI/deploy script risks, security-code hygiene, architecture-test baselines, and public-release safety. + +## Public-Safe Incident Learning + +Incident writeups should explain: + +- trigger and customer/operator impact +- detection path +- rollback or mitigation +- root cause +- prevention added to code, tests, scripts, or runbooks + +Incident writeups must not include real member data, private domains, secrets, raw provider payloads, local paths, or deployment identifiers. From 21970c346322411333c8104df3f419346d9731f7 Mon Sep 17 00:00:00 2001 From: kws Date: Sun, 17 May 2026 23:29:03 +0900 Subject: [PATCH 23/30] docs: document transaction boundary policy Task: task_9 Risk: low Files: docs/development/technical-decisions.md --- docs/development/technical-decisions.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/development/technical-decisions.md b/docs/development/technical-decisions.md index 05071cd5..96261365 100644 --- a/docs/development/technical-decisions.md +++ b/docs/development/technical-decisions.md @@ -129,3 +129,11 @@ boot 시 Kafka listener bean이 등록되지 않는지 log 확인. **Trade-off:** token bucket이 주 경계에서 reset되는 의도된 부작용이 있습니다. 율 제한은 단기(분~시간 단위) 정책이므로 실질적인 영향은 없습니다. 토큰·세션 ID 해시에는 여전히 `stableHash`(salt 없음)를 사용해 주 경계 영향을 받지 않습니다. **관련 문서와 검증:** `./server/gradlew -p server test --tests '*ClientIpHashing*'` + +## Transaction Boundary Policy + +Application services own business transaction boundaries. Controllers parse HTTP and call use cases; persistence adapters execute SQL and mapping. When an application service coordinates more than one write port, the service method owns the transaction so cache invalidation, notification event recording, and state mutation share one visible boundary. + +Adapter-level `@Transactional` is allowed only when the adapter is called by an inbound scheduler, Kafka listener, or other path that does not already pass through an application service transaction. If both service and adapter carry `@Transactional`, the service boundary is treated as the authoritative boundary and the adapter annotation should be removed in a narrow cleanup once tests pin the behavior. + +Isolation is specified only where the operation depends on claim/read-modify-write behavior that needs a non-default guarantee. Existing examples include session/login restoration and notification delivery claiming. New isolation choices must be explained in the service or adjacent decision record. From 52659e1a456b486a7d59a4d09f408d3c770ee6f6 Mon Sep 17 00:00:00 2001 From: kws Date: Sun, 17 May 2026 23:32:11 +0900 Subject: [PATCH 24/30] docs: add reviewer entry path Task: task_2 Risk: low Files: README.md --- README.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/README.md b/README.md index b8521680..c60e375c 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,17 @@ ReadMates는 여러 정기 독서모임의 세션 준비, 참여 관리, 기록 이 저장소는 외부 공개를 전제로 정리되어 있습니다. 운영 secret, 실제 멤버 데이터, private deployment state, DB dump, 로컬 경로, OCI OCID는 문서와 예시에 포함하지 않습니다. +## How to Review This Project + +처음 보는 리뷰어라면 아래 순서가 가장 빠릅니다. + +1. **제품 표면 확인** — 게스트로 공개 클럽 소개, 공개 기록, 공개 세션 상세를 확인합니다. 시작점은 [Guest-mode walkthrough](docs/showcase/guest-mode-walkthrough.md)입니다. +2. **아키텍처 판단** — Cloudflare Pages Functions BFF, Spring API, MySQL/Flyway, Redis/Kafka, AI generation, release safety가 어떻게 연결되는지 [Architecture evidence](docs/showcase/architecture-evidence.md)에서 봅니다. +3. **유지보수 품질 확인** — frontend boundary, server ArchUnit, query budget, public release scan 같은 검증은 [Engineering confidence](docs/showcase/engineering-confidence.md)에 정리합니다. +4. **운영 증거 확인** — release readiness, deploy runbook, post-deploy watch, postmortem 흐름은 [Operational proof](docs/showcase/operational-proof.md)에서 봅니다. + +Showcase 문서는 현재 동작의 source of truth가 아니라 읽는 순서입니다. 실제 경계와 동작은 코드, 테스트, scripts, migrations, [아키텍처 문서](docs/development/architecture.md)를 우선합니다. + ## Engineering Highlights 운영 중인 서비스에서 풀어낸 비자명한 문제들입니다. 각 항목은 deep-dive로 연결됩니다. @@ -36,6 +47,8 @@ README는 제품과 아키텍처의 첫 진입점입니다. 실제 작업에서 ReadMates는 이 문제를 단순 게시판이나 CRUD 목록으로 풀지 않습니다. 공개 사이트, 멤버 앱, 호스트 운영 도구, 공개 기록, 참석자 전용 피드백 문서를 하나의 제품 흐름으로 연결해 세션 전후의 실제 운영을 줄이는 데 초점을 둡니다. +리뷰어가 로그인 없이 확인할 수 있는 공개 표면은 guest-mode walkthrough에 따로 묶었습니다. 공개 접근은 클럽 소개, 공개 기록, 공개 세션 상세로 제한되며 멤버, 호스트, platform admin, AI 생성, 알림 운영 흐름은 권한을 열지 않고 sanitized evidence로 설명합니다. + ## 역할별 기능 | 역할 | 할 수 있는 일 | From 0ee172a6756d42837396f6019e7060ddba9b8328 Mon Sep 17 00:00:00 2001 From: kws Date: Sun, 17 May 2026 23:35:26 +0900 Subject: [PATCH 25/30] refactor(server): clarify session transaction boundary Task: task_10 Risk: mid Files: server/src/main/kotlin/com/readmates/session/adapter/out/persistence/JdbcHostSessionWriteAdapter.kt --- .../out/persistence/JdbcHostSessionWriteAdapter.kt | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/server/src/main/kotlin/com/readmates/session/adapter/out/persistence/JdbcHostSessionWriteAdapter.kt b/server/src/main/kotlin/com/readmates/session/adapter/out/persistence/JdbcHostSessionWriteAdapter.kt index bfa5b33e..2c267105 100644 --- a/server/src/main/kotlin/com/readmates/session/adapter/out/persistence/JdbcHostSessionWriteAdapter.kt +++ b/server/src/main/kotlin/com/readmates/session/adapter/out/persistence/JdbcHostSessionWriteAdapter.kt @@ -21,7 +21,6 @@ import com.readmates.shared.paging.PageRequest import com.readmates.shared.security.CurrentMember import org.springframework.jdbc.core.JdbcTemplate import org.springframework.stereotype.Repository -import org.springframework.transaction.annotation.Transactional @Repository class JdbcHostSessionWriteAdapter( @@ -36,7 +35,6 @@ class JdbcHostSessionWriteAdapter( private val queries = HostSessionQueries() private val writeOperations = HostSessionWriteOperations(queries) - @Transactional override fun create(command: HostSessionCommand) = writeOperations.createDraftSession(jdbcTemplate, command.host, command) override fun list( @@ -48,34 +46,26 @@ class JdbcHostSessionWriteAdapter( override fun detail(command: HostSessionIdCommand) = queries.findHostSession(jdbcTemplate, command.host, command.sessionId) - @Transactional override fun update(command: UpdateHostSessionCommand) = writeOperations.updateHostSession(jdbcTemplate, command.host, command.sessionId, command.session) override fun deletionPreview(command: HostSessionIdCommand) = deletionQueries.previewOpenSessionDeletion(command.host, command.sessionId) - @Transactional override fun delete(command: HostSessionIdCommand) = deletionQueries.deleteOpenHostSession(command.host, command.sessionId) - @Transactional override fun confirmAttendance(command: ConfirmAttendanceCommand) = writeOperations.confirmHostAttendance(jdbcTemplate, command) - @Transactional override fun upsertPublication(command: UpsertPublicationCommand) = writeOperations.upsertHostPublication(jdbcTemplate, command) override fun dashboard(host: CurrentMember) = queries.hostDashboard(jdbcTemplate, host) - @Transactional override fun updateVisibility(command: UpdateHostSessionVisibilityCommand): HostSessionDetailResponse = writeOperations.updateVisibility(jdbcTemplate, command) - @Transactional override fun open(command: HostSessionIdCommand): HostSessionTransitionResult = writeOperations.open(jdbcTemplate, command) - @Transactional override fun close(command: HostSessionIdCommand): HostSessionTransitionResult = writeOperations.close(jdbcTemplate, command) - @Transactional override fun publish(command: HostSessionIdCommand): HostSessionTransitionResult = writeOperations.publish(jdbcTemplate, command) } From 71a8190140b981584211944e298268d52677f8b7 Mon Sep 17 00:00:00 2001 From: kws Date: Sun, 17 May 2026 23:49:57 +0900 Subject: [PATCH 26/30] feat(front): migrate host members to query cache Task: task_7 Risk: high Files: front/features/host/queries/host-members-queries.ts, front/src/app/routes/host.tsx, front/features/host/route/host-members-data.ts, front/features/host/ui/host-members.tsx, front/tests/unit/host-members.test.tsx, docs/development/server-state-migration.md Co-Authored-By: Claude Opus 4.7 --- docs/development/server-state-migration.md | 8 +- front/features/host/index.ts | 2 +- .../host/queries/host-members-queries.ts | 85 +++++++++++++++++++ .../features/host/route/host-members-data.ts | 32 +++++-- front/features/host/ui/host-members.tsx | 50 +++++++---- front/src/app/routes/host.tsx | 7 +- front/tests/unit/host-dashboard.test.tsx | 4 +- front/tests/unit/host-members.test.tsx | 69 +++++++++++++-- 8 files changed, 222 insertions(+), 35 deletions(-) create mode 100644 front/features/host/queries/host-members-queries.ts diff --git a/docs/development/server-state-migration.md b/docs/development/server-state-migration.md index 64740e9c..7fc0d0a7 100644 --- a/docs/development/server-state-migration.md +++ b/docs/development/server-state-migration.md @@ -14,6 +14,7 @@ Engineering proof portfolio 분기에서는 다음 순서로 server state migrat ## 완료 - `host/invitations` — list query + create/revoke mutation + loader hand-off +- `host/members` — list query + lifecycle/profile/viewer mutation refresh + loader hand-off ## 패턴 - query: `features//queries/-queries.ts` 에 `queryOptions` + `useXxxMutation` export @@ -22,8 +23,7 @@ Engineering proof portfolio 분기에서는 다음 순서로 server state migrat - 컴포넌트는 actions props 인터페이스를 유지 — 테스트는 wrapper + mock actions 로 동일하게 작성 ## 후속 후보 (우선순위) -1. `host/members` +1. `host/notifications` 2. `host/sessions` -3. `host/notifications` -4. `current-session` (actions 4개) -5. `archive`, `feedback`, `public` — 읽기 중심, loader 와 결합도 높음 +3. `current-session` (actions 4개) +4. `archive`, `feedback`, `public` — 읽기 중심, loader 와 결합도 높음 diff --git a/front/features/host/index.ts b/front/features/host/index.ts index 22d1f740..d54e2e31 100644 --- a/front/features/host/index.ts +++ b/front/features/host/index.ts @@ -39,7 +39,7 @@ export { } from "@/features/host/route/host-members-route"; export { hostMembersActions, - hostMembersLoader, + hostMembersLoaderFactory, } from "@/features/host/route/host-members-data"; export { HostInvitationsRoute, diff --git a/front/features/host/queries/host-members-queries.ts b/front/features/host/queries/host-members-queries.ts new file mode 100644 index 00000000..923e364f --- /dev/null +++ b/front/features/host/queries/host-members-queries.ts @@ -0,0 +1,85 @@ +import type { QueryClient } from "@tanstack/react-query"; +import { queryOptions, useMutation, useQueryClient } from "@tanstack/react-query"; +import { + fetchHostMembers, + submitHostMemberLifecycle, + submitHostMemberProfile, + submitHostViewerAction, +} from "@/features/host/api/host-api"; +import type { + HostMemberListPage, + MemberLifecycleRequest, +} from "@/features/host/api/host-contracts"; +import type { + HostMemberLifecyclePath, + HostViewerAction, +} from "@/features/host/route/host-members-actions"; +import type { ReadmatesApiContext } from "@/shared/api/client"; +import type { PageRequest } from "@/shared/model/paging"; + +export const hostMemberKeys = { + all: ["host", "members"] as const, + list: (page?: PageRequest) => [...hostMemberKeys.all, "list", page ?? {}] as const, +} as const; + +async function fetchHostMemberList( + context?: ReadmatesApiContext, + page?: PageRequest, +): Promise { + return fetchHostMembers(context, page); +} + +export function hostMemberListQuery(page?: PageRequest, context?: ReadmatesApiContext) { + return queryOptions({ + queryKey: hostMemberKeys.list(page), + queryFn: () => fetchHostMemberList(context, page), + }); +} + +export function invalidateHostMembers(client: QueryClient) { + return client.invalidateQueries({ queryKey: hostMemberKeys.all }); +} + +export function useHostMemberLifecycleMutation() { + const client = useQueryClient(); + return useMutation({ + mutationFn: async ({ + membershipId, + path, + body, + }: { + membershipId: string; + path: HostMemberLifecyclePath; + body?: MemberLifecycleRequest; + }) => submitHostMemberLifecycle(membershipId, path, body), + onSuccess: () => invalidateHostMembers(client), + }); +} + +export function useHostMemberProfileMutation() { + const client = useQueryClient(); + return useMutation({ + mutationFn: async ({ + membershipId, + displayName, + }: { + membershipId: string; + displayName: string; + }) => submitHostMemberProfile(membershipId, displayName), + onSuccess: () => invalidateHostMembers(client), + }); +} + +export function useHostViewerActionMutation() { + const client = useQueryClient(); + return useMutation({ + mutationFn: async ({ + membershipId, + action, + }: { + membershipId: string; + action: HostViewerAction; + }) => submitHostViewerAction(membershipId, action), + onSuccess: () => invalidateHostMembers(client), + }); +} diff --git a/front/features/host/route/host-members-data.ts b/front/features/host/route/host-members-data.ts index db29d76a..fe946883 100644 --- a/front/features/host/route/host-members-data.ts +++ b/front/features/host/route/host-members-data.ts @@ -1,18 +1,40 @@ +import type { QueryClient } from "@tanstack/react-query"; +import type { LoaderFunctionArgs } from "react-router-dom"; import { fetchHostMembers, submitHostMemberLifecycle, submitHostMemberProfile, submitHostViewerAction, } from "@/features/host/api/host-api"; +import type { HostMemberListItem, HostMemberListPage } from "@/features/host/api/host-contracts"; +import { hostMemberListQuery } from "@/features/host/queries/host-members-queries"; import type { HostMembersActions } from "@/features/host/route/host-members-actions"; -import type { LoaderFunctionArgs } from "react-router-dom"; -import { requireHostLoaderAuth } from "./host-loader-auth"; import { clubSlugFromLoaderArgs } from "@/shared/auth/member-app-loader"; +import { requireHostLoaderAuth } from "./host-loader-auth"; + +const HOST_MEMBERS_PAGE_LIMIT = 50; + +function normalizeMemberPage(value: HostMemberListPage | HostMemberListItem[]): HostMemberListPage { + return Array.isArray(value) ? { items: value, nextCursor: null } : value; +} + +export function hostMembersLoaderFactory(client: QueryClient) { + return async (args?: LoaderFunctionArgs) => { + await requireHostLoaderAuth(args); + + const raw = await fetchHostMembers( + { clubSlug: clubSlugFromLoaderArgs(args) }, + { limit: HOST_MEMBERS_PAGE_LIMIT }, + ); + const page = normalizeMemberPage(raw); -export async function hostMembersLoader(args?: LoaderFunctionArgs) { - await requireHostLoaderAuth(args); + client.setQueryData( + hostMemberListQuery({ limit: HOST_MEMBERS_PAGE_LIMIT }).queryKey, + page, + ); - return fetchHostMembers({ clubSlug: clubSlugFromLoaderArgs(args) }); + return page; + }; } export const hostMembersActions = { diff --git a/front/features/host/ui/host-members.tsx b/front/features/host/ui/host-members.tsx index 8a0d8d60..0ec0d307 100644 --- a/front/features/host/ui/host-members.tsx +++ b/front/features/host/ui/host-members.tsx @@ -1,5 +1,6 @@ import { type CSSProperties, useMemo, useRef, useState } from "react"; import { useInRouterContext, useLocation } from "react-router-dom"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import type { CurrentSessionPolicy, HostMemberProfileErrorCode, @@ -10,6 +11,10 @@ import type { MemberLifecycleResponse, ViewerMember, } from "@/features/host/model/host-view-types"; +import { + hostMemberKeys, + hostMemberListQuery, +} from "@/features/host/queries/host-members-queries"; import type { PageRequest } from "@/shared/model/paging"; import { scopedAppLinkTarget } from "@/shared/routing/scoped-app-link-target"; import { LifecyclePolicyDialog } from "./members/member-approval-actions"; @@ -100,7 +105,31 @@ async function hostProfileErrorCodeFromResponse(response: Response): Promise normalizeMemberPage(await actions.loadMembers({ limit: 50 })), + initialData: propPage, + }); + // Track the prop and query page identities we have already consumed. When + // either changes identity we move the source-of-truth forward. + const queryPage = listQuery.data ?? propPage; + const [seen, setSeen] = useState<{ prop: HostMemberListItem[]; query: HostMemberListItem[]; active: HostMemberListPage }>(() => ({ + prop: propPage.items, + query: queryPage.items, + active: queryPage, + })); + let nextSeen = seen; + if (propPage.items !== seen.prop) { + nextSeen = { prop: propPage.items, query: queryPage.items, active: propPage }; + } else if (queryPage.items !== seen.query) { + nextSeen = { prop: propPage.items, query: queryPage.items, active: queryPage }; + } + if (nextSeen !== seen) { + setSeen(nextSeen); + } + const initialPage = nextSeen.active; const [memberRowsState, setMemberRowsState] = useState(() => ({ source: initialPage.items, members: initialPage.items, @@ -117,7 +146,6 @@ export default function HostMembers({ initialMembers, actions, LinkComponent = D const [message, setMessage] = useState(null); const pendingActionsRef = useRef>(new Set()); const dialogTriggerRef = useRef(null); - const refreshRequestIdRef = useRef(0); const setMembers = (update: MemberRowsUpdate) => { setMemberRowsState((current) => { @@ -187,20 +215,10 @@ export default function HostMembers({ initialMembers, actions, LinkComponent = D }; const refreshMembers = async () => { - const requestId = refreshRequestIdRef.current + 1; - refreshRequestIdRef.current = requestId; - - try { - const nextPage = normalizeMemberPage(await actions.loadMembers({ limit: 50 })); - if (requestId === refreshRequestIdRef.current) { - setMembers(nextPage.items); - setNextCursor(nextPage.nextCursor); - } - } catch (error) { - if (requestId === refreshRequestIdRef.current) { - throw error; - } - } + await queryClient.invalidateQueries( + { queryKey: hostMemberKeys.all }, + { throwOnError: true }, + ); }; const loadMoreMembers = async () => { diff --git a/front/src/app/routes/host.tsx b/front/src/app/routes/host.tsx index 98fbfd67..1efd37b0 100644 --- a/front/src/app/routes/host.tsx +++ b/front/src/app/routes/host.tsx @@ -27,11 +27,14 @@ function hostAppRoutes(queryClient: QueryClient): RouteObject[] { errorElement: , hydrateFallbackElement: , lazy: async () => { - const [{ HostMembersRouteElement }, { hostMembersLoader }] = await Promise.all([ + const [{ HostMembersRouteElement }, { hostMembersLoaderFactory }] = await Promise.all([ import("@/src/app/host-route-elements"), import("@/features/host/route/host-members-data"), ]); - return { Component: HostMembersRouteElement, loader: hostMembersLoader }; + return { + Component: HostMembersRouteElement, + loader: hostMembersLoaderFactory(queryClient), + }; }, }, { diff --git a/front/tests/unit/host-dashboard.test.tsx b/front/tests/unit/host-dashboard.test.tsx index 02a29971..265cac07 100644 --- a/front/tests/unit/host-dashboard.test.tsx +++ b/front/tests/unit/host-dashboard.test.tsx @@ -7,7 +7,7 @@ import HostDashboard from "@/features/host/ui/host-dashboard"; import { hostDashboardLoader, hostInvitationsLoaderFactory, - hostMembersLoader, + hostMembersLoaderFactory, hostSessionEditorLoader, } from "@/features/host"; import { QueryClient } from "@tanstack/react-query"; @@ -328,7 +328,7 @@ function hostSessionEditorLoaderForTest() { const hostLoaderCases: Array<[string, () => Promise, string]> = [ ["dashboard", () => hostDashboardLoader(), "/login"], - ["members", () => hostMembersLoader(), "/login"], + ["members", () => hostMembersLoaderFactory(new QueryClient())(), "/login"], ["invitations", () => hostInvitationsLoaderFactory(new QueryClient())(), "/login"], ["session editor", hostSessionEditorLoaderForTest, "/login?returnTo=%2Fapp%2Fhost%2Fsessions%2Fsession-7%2Fedit"], ]; diff --git a/front/tests/unit/host-members.test.tsx b/front/tests/unit/host-members.test.tsx index d671ed61..e551fb5e 100644 --- a/front/tests/unit/host-members.test.tsx +++ b/front/tests/unit/host-members.test.tsx @@ -2,13 +2,23 @@ import userEvent from "@testing-library/user-event"; import { act, cleanup, render, screen, waitFor, within } from "@testing-library/react"; import { createMemoryRouter, RouterProvider } from "react-router-dom"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { HostMembersActions } from "@/features/host/route/host-members-actions"; import HostMembers from "@/features/host/ui/host-members"; -import { hostMembersActions, hostMembersLoader } from "@/features/host"; +import { hostMembersActions, hostMembersLoaderFactory } from "@/features/host"; import HostMembersPage from "@/src/pages/host-members"; import type { HostMemberListItem } from "@/features/host/api/host-contracts"; import type { AuthMeResponse } from "@/shared/auth/auth-contracts"; +function createTestQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: 0, staleTime: Number.POSITIVE_INFINITY }, + mutations: { retry: false }, + }, + }); +} + const members: HostMemberListItem[] = [ { membershipId: "membership-active", @@ -126,9 +136,21 @@ type HostMembersProps = Parameters[0]; function HostMembersForTest({ actions, + initialMembers, ...props }: Omit & { actions?: HostMembersActions }) { - return ; + // Fresh client per render so rerender-with-new-initialMembers tests + // observe the new prop instead of cache from prior render. + const client = createTestQueryClient(); + return ( + + + + ); } function lifecycleResponse(member: HostMemberListItem) { @@ -195,19 +217,24 @@ function renderHostMembersPage(extraResponses: Array, - loader: hostMembersLoader, + loader: hostMembersLoaderFactory(queryClient), hydrateFallbackElement:
멤버 목록을 불러오는 중
, }, ], { initialEntries: ["/app/host/members"] }, ); - render(); + render( + + + , + ); return fetchMock; } @@ -233,7 +260,7 @@ describe("HostMembersPage", () => { expect(screen.getByLabelText("멤버 운영 요약")).toHaveTextContent("이번 세션"); expect(within(screen.getByText("멤버1").closest("article") as HTMLElement).getByText("이번 세션 참여")).toBeInTheDocument(); expect(within(screen.getByText("새").closest("article") as HTMLElement).getByText("이번 세션 미포함")).toBeInTheDocument(); - expect(fetchMock).toHaveBeenCalledWith("/api/bff/api/host/members", expect.objectContaining({ cache: "no-store" })); + expect(fetchMock).toHaveBeenCalledWith("/api/bff/api/host/members?limit=50", expect.objectContaining({ cache: "no-store" })); }); it("renders each member row with identity, status, and current-session state", async () => { @@ -1004,4 +1031,36 @@ describe("HostMembersPage", () => { expect(activeRow.getByRole("button", { name: "세션 제외" })).toBeDisabled(); expect(activeRow.queryByRole("button", { name: "이번 세션 추가" })).not.toBeInTheDocument(); }); + + it("seeds the host members list through the route loader", async () => { + const fetchMock = renderHostMembersPage(); + + expect(await screen.findByText("멤버1")).toBeInTheDocument(); + expect(fetchMock).toHaveBeenCalledWith( + "/api/bff/api/host/members?limit=50", + expect.objectContaining({ cache: "no-store" }), + ); + }); + + it("refreshes the Query-backed list after a member profile update", async () => { + const user = userEvent.setup(); + const updated = { ...members[0], displayName: "갱신된 이름" } satisfies HostMemberListItem; + const fetchMock = renderHostMembersPage([ + memberListItemResponse(updated), + memberListResponse(members.map((m) => (m.membershipId === updated.membershipId ? updated : m))), + ]); + + const row = within((await screen.findByText("멤버1")).closest("article") as HTMLElement); + await user.click(row.getByRole("button", { name: "이름 변경" })); + const dialog = within(screen.getByRole("dialog", { name: "멤버1 이름 수정" })); + await user.clear(dialog.getByLabelText("이름")); + await user.type(dialog.getByLabelText("이름"), "갱신된 이름"); + await user.click(dialog.getByRole("button", { name: "이름 저장" })); + + expect(await screen.findByText("갱신된 이름")).toBeInTheDocument(); + expect(fetchMock).toHaveBeenCalledWith( + "/api/bff/api/host/members/membership-active/profile", + expect.objectContaining({ method: "PATCH" }), + ); + }); }); From 6e663321d71679ae48d9019d9469a43acfa9ffea Mon Sep 17 00:00:00 2001 From: kws Date: Sun, 17 May 2026 23:53:12 +0900 Subject: [PATCH 27/30] docs: plan host notifications query migration Task: task_8 Risk: mid Files: docs/superpowers/plans/2026-05-17-readmates-host-notifications-query-migration.md, docs/development/server-state-migration.md --- docs/development/server-state-migration.md | 2 +- ...ates-host-notifications-query-migration.md | 117 ++++++++++++++++++ 2 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 docs/superpowers/plans/2026-05-17-readmates-host-notifications-query-migration.md diff --git a/docs/development/server-state-migration.md b/docs/development/server-state-migration.md index 7fc0d0a7..a69c4df2 100644 --- a/docs/development/server-state-migration.md +++ b/docs/development/server-state-migration.md @@ -23,7 +23,7 @@ Engineering proof portfolio 분기에서는 다음 순서로 server state migrat - 컴포넌트는 actions props 인터페이스를 유지 — 테스트는 wrapper + mock actions 로 동일하게 작성 ## 후속 후보 (우선순위) -1. `host/notifications` +1. `host/notifications` — detailed migration plan: `docs/superpowers/plans/2026-05-17-readmates-host-notifications-query-migration.md` 2. `host/sessions` 3. `current-session` (actions 4개) 4. `archive`, `feedback`, `public` — 읽기 중심, loader 와 결합도 높음 diff --git a/docs/superpowers/plans/2026-05-17-readmates-host-notifications-query-migration.md b/docs/superpowers/plans/2026-05-17-readmates-host-notifications-query-migration.md new file mode 100644 index 00000000..257f44cd --- /dev/null +++ b/docs/superpowers/plans/2026-05-17-readmates-host-notifications-query-migration.md @@ -0,0 +1,117 @@ +# ReadMates Host Notifications Query Migration Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Move host notification summary, event ledger, delivery ledger, manual options, preview, confirm, and dispatch ledger reads into TanStack Query without moving API calls into UI components. + +**Architecture:** Keep `front/features/host/route` responsible for loader/action coordination and keep `front/features/host/ui/notifications` prop/callback driven. Add `front/features/host/queries/host-notification-queries.ts` for query keys, queryOptions, and mutation invalidation helpers. + +**Tech Stack:** React 19, React Router 7, TanStack Query v5, Vitest, Testing Library. + +--- + +## Task 1: Map Current Notification Data Flow + +**Files:** + +- Read: `front/features/host/route/host-notifications-data.ts` +- Read: `front/features/host/route/host-notifications-route.tsx` +- Read: `front/features/host/ui/host-notifications-page.tsx` +- Read: `front/features/host/ui/notifications/manual-notification-workbench.tsx` + +- [ ] **Step 1: Inspect existing route and UI data flow** + +Run: + +```bash +sed -n '1,240p' front/features/host/route/host-notifications-data.ts +sed -n '1,240p' front/features/host/route/host-notifications-route.tsx +sed -n '1,260p' front/features/host/ui/host-notifications-page.tsx +sed -n '1,260p' front/features/host/ui/notifications/manual-notification-workbench.tsx +``` + +Expected: route owns loader data, while UI coordinates several host notification reads and manual dispatch actions. + +## Task 2: Add Notification Query Keys + +**Files:** + +- Create: `front/features/host/queries/host-notification-queries.ts` + +- [ ] **Step 1: Create query key module** + +Create query keys for `summary`, `items(status,page)`, `events(page)`, `deliveries(page)`, `manualOptions(sessionId,search,page)`, and `manualDispatches(sessionId,eventType,page)`. Each key starts with `["host", "notifications"]`. + +- [ ] **Step 2: Add invalidation helpers** + +Add `invalidateHostNotifications(client)` for all host notification state and `invalidateManualNotificationState(client)` for manual options/dispatches. + +## Task 3: Seed Loader Data + +**Files:** + +- Modify: `front/src/app/routes/host.tsx` +- Modify: `front/features/host/route/host-notifications-data.ts` + +- [ ] **Step 1: Convert loader to factory** + +Follow the `hostMembersLoaderFactory(client)` pattern from the engineering proof portfolio plan. Seed summary, events, deliveries, and manual options into Query cache from loader data. + +## Task 4: Move Preview and Confirm to Query Mutations + +**Files:** + +- Modify: `front/features/host/ui/notifications/manual-notification-workbench.tsx` + +- [ ] **Step 1: Keep UI prop-driven** + +Use actions passed from the route for API calls. Do not import `host-api.ts` into UI. Use Query mutations only to track pending state and invalidation. + +- [ ] **Step 2: Preserve preview TTL and resend confirmation** + +After preview success, keep the preview token and selection hash state in the workbench. After confirm success, invalidate manual dispatches and notification summary. + +## Task 5: Test Notification Migration + +**Files:** + +- Modify: `front/tests/unit/host-notifications.test.tsx` + +- [ ] **Step 1: Add regression tests** + +Add these regression tests to `front/tests/unit/host-notifications.test.tsx`: + +```typescript +it("keeps manual preview state when notification queries invalidate", async () => { + // Arrange with the existing manual notification route fixture. + // Preview a manual notification. + // Trigger an invalidation through a successful confirm or process action. + // Assert the preview token, selected template, and target count remain visible until confirm resolves. +}); + +it("requires explicit resend confirmation after query migration", async () => { + // Arrange with a recent manual dispatch fixture for the same session/template. + // Preview the same dispatch. + // Assert confirm is blocked until the resend confirmation control is selected. +}); + +it("refreshes manual dispatch ledger after confirm", async () => { + // Arrange with an empty dispatch ledger. + // Confirm a preview. + // Assert the ledger query refetch shows the new dispatch row. +}); +``` + +Replace the comments with the existing test helper calls in that file; keep the three test names and assertions. + +- [ ] **Step 2: Run checks** + +Run: + +```bash +pnpm --dir front test -- host-notifications +pnpm --dir front lint +pnpm --dir front build +``` + +Expected: all commands pass. From 08087e5c36aab59153e8d0358d68cbfe148d3d13 Mon Sep 17 00:00:00 2001 From: kws Date: Sun, 17 May 2026 23:56:57 +0900 Subject: [PATCH 28/30] docs: close engineering proof portfolio review Task: task_11 Risk: mid Files: CHANGELOG.md --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b2bf4adc..533e3a17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,15 @@ ReadMates는 Git tag와 GitHub Releases를 함께 사용합니다. 이 파일은 - **호스트 세션 기록 완성 UX 정리**: 호스트 세션 편집기에서 단독 피드백 문서 업로드 경로를 제거하고, AI 생성 기본 경로와 외부 JSON fallback을 하나의 `세션 기록 완성` 패널로 통합했습니다. 새 피드백 문서 저장은 세션 기록 패키지 commit을 통해서만 발생하며, 기존 `FEEDBACK_DOCUMENT_PUBLISHED` 알림 이벤트는 JSON import와 AI commit 경로에서 동일하게 기록됩니다. - **platform-admin:** 플랫폼 운영자용 triage 콘솔(`/admin`) — 온보딩 큐, 클럽 디렉터리, 클럽 상세 + Support access grant 패널을 단일 워크벤치로 통합. OWNER 전용 support access, 라이프사이클 우선 정렬, 온보딩 결과의 즉시 선택 반영. +### Engineering Proof Portfolio + +- Add reviewer-facing showcase index, guest-mode walkthrough, architecture evidence, engineering confidence, and operational proof docs under `docs/showcase/`. +- Add a "How to Review This Project" entry point to `README.md` pointing at the showcase set. +- Migrate `host/members` server state to TanStack Query (route loader factory seeds query cache; mutations invalidate on success; UI remains prop-driven). Documented in `docs/development/server-state-migration.md`. +- Plan host notifications query migration as a separate slice in `docs/superpowers/plans/2026-05-17-readmates-host-notifications-query-migration.md`. +- Document the server transaction boundary policy (application-service-owned `@Transactional`; adapters stay non-transactional) in `docs/development/technical-decisions.md`. +- Refactor `JdbcHostSessionWriteAdapter` to drop redundant adapter-level `@Transactional` annotations, aligning with the documented policy. + ## v1.10.2 - 2026-05-17 ### Highlights From a521aed4f4040a97697bf5655e4788e4964c483c Mon Sep 17 00:00:00 2001 From: kws Date: Mon, 18 May 2026 00:26:32 +0900 Subject: [PATCH 29/30] fix(server): wrap host session write adapter expression bodies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 10에서 메서드 레벨 @Transactional 9개를 제거한 결과 표현식 본문이 120자 detekt MaxLineLength 한도를 초과했다. 시그니처와 본문을 분리하고 인자를 줄바꿈하여 두 줄 규칙(detekt MaxLineLength 120, ktlint multiline-expression-wrapping)을 동시에 만족시킨다. Co-Authored-By: Claude Opus 4.7 --- .../JdbcHostSessionWriteAdapter.kt | 47 +++++++++++++++---- 1 file changed, 38 insertions(+), 9 deletions(-) diff --git a/server/src/main/kotlin/com/readmates/session/adapter/out/persistence/JdbcHostSessionWriteAdapter.kt b/server/src/main/kotlin/com/readmates/session/adapter/out/persistence/JdbcHostSessionWriteAdapter.kt index 2c267105..e2ac0cb7 100644 --- a/server/src/main/kotlin/com/readmates/session/adapter/out/persistence/JdbcHostSessionWriteAdapter.kt +++ b/server/src/main/kotlin/com/readmates/session/adapter/out/persistence/JdbcHostSessionWriteAdapter.kt @@ -35,7 +35,12 @@ class JdbcHostSessionWriteAdapter( private val queries = HostSessionQueries() private val writeOperations = HostSessionWriteOperations(queries) - override fun create(command: HostSessionCommand) = writeOperations.createDraftSession(jdbcTemplate, command.host, command) + override fun create(command: HostSessionCommand) = + writeOperations.createDraftSession( + jdbcTemplate, + command.host, + command, + ) override fun list( host: CurrentMember, @@ -52,20 +57,44 @@ class JdbcHostSessionWriteAdapter( override fun deletionPreview(command: HostSessionIdCommand) = deletionQueries.previewOpenSessionDeletion(command.host, command.sessionId) - override fun delete(command: HostSessionIdCommand) = deletionQueries.deleteOpenHostSession(command.host, command.sessionId) + override fun delete(command: HostSessionIdCommand) = + deletionQueries.deleteOpenHostSession( + command.host, + command.sessionId, + ) - override fun confirmAttendance(command: ConfirmAttendanceCommand) = writeOperations.confirmHostAttendance(jdbcTemplate, command) + override fun confirmAttendance(command: ConfirmAttendanceCommand) = + writeOperations.confirmHostAttendance( + jdbcTemplate, + command, + ) - override fun upsertPublication(command: UpsertPublicationCommand) = writeOperations.upsertHostPublication(jdbcTemplate, command) + override fun upsertPublication(command: UpsertPublicationCommand) = + writeOperations.upsertHostPublication( + jdbcTemplate, + command, + ) override fun dashboard(host: CurrentMember) = queries.hostDashboard(jdbcTemplate, host) override fun updateVisibility(command: UpdateHostSessionVisibilityCommand): HostSessionDetailResponse = writeOperations.updateVisibility(jdbcTemplate, command) - override fun open(command: HostSessionIdCommand): HostSessionTransitionResult = writeOperations.open(jdbcTemplate, command) - - override fun close(command: HostSessionIdCommand): HostSessionTransitionResult = writeOperations.close(jdbcTemplate, command) - - override fun publish(command: HostSessionIdCommand): HostSessionTransitionResult = writeOperations.publish(jdbcTemplate, command) + override fun open(command: HostSessionIdCommand): HostSessionTransitionResult = + writeOperations.open( + jdbcTemplate, + command, + ) + + override fun close(command: HostSessionIdCommand): HostSessionTransitionResult = + writeOperations.close( + jdbcTemplate, + command, + ) + + override fun publish(command: HostSessionIdCommand): HostSessionTransitionResult = + writeOperations.publish( + jdbcTemplate, + command, + ) } From 8e0e2b2fe1f5e11e9552b8d20a0ad362318ae5aa Mon Sep 17 00:00:00 2001 From: kws Date: Mon, 18 May 2026 00:53:50 +0900 Subject: [PATCH 30/30] build(server): complete ktlint baseline rollout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit commit b55a672f("adopt ktlint with baseline for incremental rollout")이 ktlint를 도입하면서 baseline 파일과 wiring을 빠뜨렸다. main CI는 stale Gradle build cache 덕에 통과해 왔지만 PR 빌드는 cache-read-only로 실제 스캔이 실행되어 잠재 violation 356건이 표면화되었다. - server/config/ktlint/baseline.xml: ./gradlew ktlintGenerateBaseline로 생성한 뒤 경로를 projectDir 상대(`src/main/...`, `src/test/...`)로 정규화했다. 기존 violation을 잠금 처리해 신규 violation만 차단한다. - server/build.gradle.kts: ktlint { baseline.set(...) }로 GenerateReports 태스크가 baseline 파일을 읽도록 wire했다. 미래의 cleanup PR은 baseline 항목을 점진적으로 줄이는 형태로 진행한다. Co-Authored-By: Claude Opus 4.7 --- server/build.gradle.kts | 1 + server/config/ktlint/baseline.xml | 115 +++++++++++++----------------- 2 files changed, 52 insertions(+), 64 deletions(-) diff --git a/server/build.gradle.kts b/server/build.gradle.kts index 04308a82..4893e10f 100644 --- a/server/build.gradle.kts +++ b/server/build.gradle.kts @@ -191,6 +191,7 @@ ktlint { version.set("1.7.1") android.set(false) ignoreFailures.set(false) + baseline.set(file("$projectDir/config/ktlint/baseline.xml")) filter { exclude("**/generated/**") } diff --git a/server/config/ktlint/baseline.xml b/server/config/ktlint/baseline.xml index bccdf779..f05e8574 100644 --- a/server/config/ktlint/baseline.xml +++ b/server/config/ktlint/baseline.xml @@ -278,18 +278,12 @@ - - - - - - @@ -461,8 +455,6 @@ - - @@ -569,57 +561,57 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -814,11 +806,6 @@ - - - - -