From 046ac857cbb4bd4659da20699b28f4da4c4e810d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=EC=B0=AC?= Date: Sat, 11 Apr 2026 21:34:02 +0900 Subject: [PATCH] =?UTF-8?q?feat(desktop+web):=20Phase=203.3=20Auto=20push?= =?UTF-8?q?=20on=20write=20+=20transcript=20fallback=20+=20dashboard=20Lin?= =?UTF-8?q?k=20(=EB=B9=85=EB=B1=85=20Phase=205=20Part=203)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Desktop — CloudSyncService: - pushOne(table, id) 제네릭 단건 push 신규 (history/dictionary/meetings/meeting_memos/meeting_documents) - 로그인 미상태 silent noop, 실패 시 warn만 (로컬 write 절대 차단 금지) - 5개 row→payload 매퍼(_mapHistoryPayload 외 4개) private 메서드로 추출, pushAll과 공유 (DRY) Desktop — write hook 8곳 fire-and-forget 연결: - HistoryService.create / generateTitle - MeetingSummaryService.generateSummary (summaryText 저장 후) - DictionaryService.add / update - MeetingModeService.startRecording (부모 meeting pre-push — 자식 memo/doc RLS 통과용) - MeetingModeService.addMemo / generateDocument - MeetingModeService._runPostProcessing (완료 상태 최종 저장 후) Web — meeting detail page: - transcripts 테이블이 비고 status!=recording인 경우 meeting.raw_transcript(또는 edited_transcript) 를 whiteSpace:pre-wrap으로 fallback 렌더 - 실시간 구독 경로(LiveTranscriptList)는 recording 상태 또는 transcripts 존재 시에만 마운트 Web — dashboard: - RECENT MEETINGS의 MetalCard를 next/link Link로 감싸 /meetings/[id] 이동 가능 - cursor pointer + hover lift 효과 실증: 재기동 직후 pushAll 캐치업이 이전 세션 RLS 실패 메모 2건을 재시도 → pushed=4 성공. 새 녹음 → pushOne history/... ok 실시간 발화 → 웹에서 전사/메모 렌더 확인 (사용자 "잘 전사와 메모가 올라왔어") --- .../src/main/services/CloudSyncService.ts | 277 +++++++++++++----- .../src/main/services/DictionaryService.ts | 5 + .../src/main/services/HistoryService.ts | 6 + .../src/main/services/MeetingModeService.ts | 15 + .../main/services/MeetingSummaryService.ts | 4 + apps/web/src/app/(app)/dashboard/page.tsx | 26 +- apps/web/src/app/(app)/meetings/[id]/page.tsx | 30 +- memory/project_status.md | 66 +++++ 8 files changed, 344 insertions(+), 85 deletions(-) diff --git a/apps/desktop/src/main/services/CloudSyncService.ts b/apps/desktop/src/main/services/CloudSyncService.ts index 0423cd1..983cec1 100644 --- a/apps/desktop/src/main/services/CloudSyncService.ts +++ b/apps/desktop/src/main/services/CloudSyncService.ts @@ -546,26 +546,7 @@ class CloudSyncService extends EventEmitter { const historyRows = await db.select().from(history).where(gt(history.updatedAt, since)) result.pushed += await this._pushTable('history', historyRows.length, async () => { if (historyRows.length === 0) return 0 - const payload = historyRows.map((r) => ({ - id: r.id, - user_id: userId, - original_text: r.originalText, - polished_text: r.polishedText, - mode: r.mode, - status: r.status, - duration: r.duration, - detected_language: r.detectedLanguage, - mic_device: r.micDevice, - word_count: r.wordCount, - stt_model: r.sttModel, - llm_model: r.llmModel, - stt_latency_ms: r.sttLatencyMs, - llm_latency_ms: r.llmLatencyMs, - created_at: new Date(r.createdAt).toISOString(), - updated_at: new Date(r.updatedAt).toISOString(), - app_version: r.appVersion, - summary_text: r.summaryText - })) + const payload = historyRows.map((r) => this._mapHistoryPayload(r, userId)) const { error } = await this._client!.from('history').upsert(payload, { onConflict: 'id' }) if (error) throw new Error(error.message) return historyRows.length @@ -575,17 +556,7 @@ class CloudSyncService extends EventEmitter { const dictRows = await db.select().from(dictionary).where(gt(dictionary.updatedAt, since)) result.pushed += await this._pushTable('dictionary', dictRows.length, async () => { if (dictRows.length === 0) return 0 - const payload = dictRows.map((r) => ({ - id: r.id, - user_id: userId, - word: r.word, - pronunciation: r.pronunciation, - category: r.category, - usage_count: r.usageCount, - last_used_at: r.lastUsedAt ? new Date(r.lastUsedAt).toISOString() : null, - created_at: new Date(r.createdAt).toISOString(), - updated_at: new Date(r.updatedAt).toISOString() - })) + const payload = dictRows.map((r) => this._mapDictionaryPayload(r, userId)) const { error } = await this._client!.from('dictionary').upsert(payload, { onConflict: 'id' }) if (error) throw new Error(error.message) return dictRows.length @@ -598,27 +569,7 @@ class CloudSyncService extends EventEmitter { .where(gt(meetingSessions.updatedAt, since)) result.pushed += await this._pushTable('meetings', meetingRows.length, async () => { if (meetingRows.length === 0) return 0 - const payload = meetingRows.map((r) => ({ - id: r.id, - user_id: userId, - team_id: null, - title: r.title, - status: r.status, - started_at: new Date(r.startedAt).toISOString(), - ended_at: r.endedAt ? new Date(r.endedAt).toISOString() : null, - duration_ms: r.durationMs, - raw_transcript: r.rawTranscript, - edited_transcript: r.editedTranscript, - minutes_markdown: r.minutesMarkdown, - minutes_json: r.minutesJson ? JSON.parse(r.minutesJson) : null, - stt_model: r.sttModel, - llm_model: r.llmModel, - stt_latency_ms: r.sttLatencyMs, - llm_latency_ms: r.llmLatencyMs, - error_message: r.errorMessage, - created_at: new Date(r.createdAt).toISOString(), - updated_at: new Date(r.updatedAt).toISOString() - })) + const payload = meetingRows.map((r) => this._mapMeetingPayload(r, userId)) const { error } = await this._client!.from('meetings').upsert(payload, { onConflict: 'id' }) if (error) throw new Error(error.message) return meetingRows.length @@ -633,14 +584,7 @@ class CloudSyncService extends EventEmitter { .where(this._inArray(meetingMemos.sessionId, meetingIds)) result.pushed += await this._pushTable('meeting_memos', memoRows.length, async () => { if (memoRows.length === 0) return 0 - const payload = memoRows.map((r) => ({ - id: r.id, - meeting_id: r.sessionId, - user_id: userId, - content: r.content, - timestamp_ms: r.timestampMs, - created_at: new Date(r.createdAt).toISOString() - })) + const payload = memoRows.map((r) => this._mapMemoPayload(r, userId)) const { error } = await this._client!.from('meeting_memos').upsert(payload, { onConflict: 'id' }) @@ -655,19 +599,7 @@ class CloudSyncService extends EventEmitter { .where(this._inArray(meetingDocuments.sessionId, meetingIds)) result.pushed += await this._pushTable('meeting_documents', docRows.length, async () => { if (docRows.length === 0) return 0 - const payload = docRows.map((r) => ({ - id: r.id, - meeting_id: r.sessionId, - user_id: userId, - template_type: r.templateType, - title: r.title, - content: r.content, - prompt_used: r.promptUsed, - llm_model: r.llmModel, - llm_latency_ms: r.llmLatencyMs, - created_at: new Date(r.createdAt).toISOString(), - updated_at: new Date(r.updatedAt).toISOString() - })) + const payload = docRows.map((r) => this._mapDocumentPayload(r, userId)) const { error } = await this._client!.from('meeting_documents').upsert(payload, { onConflict: 'id' }) @@ -1038,6 +970,205 @@ class CloudSyncService extends EventEmitter { // ── 내부 헬퍼 ────────────────────────────────────────── + // ── 단건 자동 push (Phase 3.3) ───────────────────────── + + /** + * 단일 row 자동 push. + * - 로그인 상태 아니면 silent noop (로컬 write는 계속 돌아감) + * - 실패 시 warn만 찍고 swallow — 호출부는 fire-and-forget로 쓸 것 + * - pushAll과 동일한 mapper 공유 (DRY) + */ + async pushOne( + table: 'history' | 'dictionary' | 'meetings' | 'meeting_memos' | 'meeting_documents', + id: string + ): Promise { + if (!this._client || !this._session) { + // 로그아웃 / 미로그인 — 로컬 모드에서는 push 대상이 아님. 조용히 종료. + return + } + + const userId = this._session.user.id + const db = getDatabase() + const client = this._client + + try { + switch (table) { + case 'history': { + const rows = await db.select().from(history).where(eq(history.id, id)).limit(1) + const row = rows[0] + if (!row) return + const { error } = await client + .from('history') + .upsert(this._mapHistoryPayload(row, userId), { onConflict: 'id' }) + if (error) throw new Error(error.message) + break + } + case 'dictionary': { + const rows = await db.select().from(dictionary).where(eq(dictionary.id, id)).limit(1) + const row = rows[0] + if (!row) return + const { error } = await client + .from('dictionary') + .upsert(this._mapDictionaryPayload(row, userId), { onConflict: 'id' }) + if (error) throw new Error(error.message) + break + } + case 'meetings': { + const rows = await db + .select() + .from(meetingSessions) + .where(eq(meetingSessions.id, id)) + .limit(1) + const row = rows[0] + if (!row) return + const { error } = await client + .from('meetings') + .upsert(this._mapMeetingPayload(row, userId), { onConflict: 'id' }) + if (error) throw new Error(error.message) + break + } + case 'meeting_memos': { + const rows = await db + .select() + .from(meetingMemos) + .where(eq(meetingMemos.id, id)) + .limit(1) + const row = rows[0] + if (!row) return + const { error } = await client + .from('meeting_memos') + .upsert(this._mapMemoPayload(row, userId), { onConflict: 'id' }) + if (error) throw new Error(error.message) + break + } + case 'meeting_documents': { + const rows = await db + .select() + .from(meetingDocuments) + .where(eq(meetingDocuments.id, id)) + .limit(1) + const row = rows[0] + if (!row) return + const { error } = await client + .from('meeting_documents') + .upsert(this._mapDocumentPayload(row, userId), { onConflict: 'id' }) + if (error) throw new Error(error.message) + break + } + } + logger.debug(`pushOne ${table}/${id} ok`) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + // 로컬 write는 절대 차단하지 않는다 — warn만. + logger.warn(`pushOne ${table}/${id} failed: ${message}`) + } + } + + // ── row → Supabase payload 매퍼 (pushAll + pushOne 공통) ─ + + private _mapHistoryPayload( + r: typeof history.$inferSelect, + userId: string + ): Record { + return { + id: r.id, + user_id: userId, + original_text: r.originalText, + polished_text: r.polishedText, + mode: r.mode, + status: r.status, + duration: r.duration, + detected_language: r.detectedLanguage, + mic_device: r.micDevice, + word_count: r.wordCount, + stt_model: r.sttModel, + llm_model: r.llmModel, + stt_latency_ms: r.sttLatencyMs, + llm_latency_ms: r.llmLatencyMs, + created_at: new Date(r.createdAt).toISOString(), + updated_at: new Date(r.updatedAt).toISOString(), + app_version: r.appVersion, + summary_text: r.summaryText + } + } + + private _mapDictionaryPayload( + r: typeof dictionary.$inferSelect, + userId: string + ): Record { + return { + id: r.id, + user_id: userId, + word: r.word, + pronunciation: r.pronunciation, + category: r.category, + usage_count: r.usageCount, + last_used_at: r.lastUsedAt ? new Date(r.lastUsedAt).toISOString() : null, + created_at: new Date(r.createdAt).toISOString(), + updated_at: new Date(r.updatedAt).toISOString() + } + } + + private _mapMeetingPayload( + r: typeof meetingSessions.$inferSelect, + userId: string + ): Record { + return { + id: r.id, + user_id: userId, + team_id: null, + title: r.title, + status: r.status, + started_at: new Date(r.startedAt).toISOString(), + ended_at: r.endedAt ? new Date(r.endedAt).toISOString() : null, + duration_ms: r.durationMs, + raw_transcript: r.rawTranscript, + edited_transcript: r.editedTranscript, + minutes_markdown: r.minutesMarkdown, + minutes_json: r.minutesJson ? JSON.parse(r.minutesJson) : null, + stt_model: r.sttModel, + llm_model: r.llmModel, + stt_latency_ms: r.sttLatencyMs, + llm_latency_ms: r.llmLatencyMs, + error_message: r.errorMessage, + created_at: new Date(r.createdAt).toISOString(), + updated_at: new Date(r.updatedAt).toISOString() + } + } + + private _mapMemoPayload( + r: typeof meetingMemos.$inferSelect, + userId: string + ): Record { + return { + id: r.id, + meeting_id: r.sessionId, + user_id: userId, + content: r.content, + timestamp_ms: r.timestampMs, + created_at: new Date(r.createdAt).toISOString() + } + } + + private _mapDocumentPayload( + r: typeof meetingDocuments.$inferSelect, + userId: string + ): Record { + return { + id: r.id, + meeting_id: r.sessionId, + user_id: userId, + template_type: r.templateType, + title: r.title, + content: r.content, + prompt_used: r.promptUsed, + llm_model: r.llmModel, + llm_latency_ms: r.llmLatencyMs, + created_at: new Date(r.createdAt).toISOString(), + updated_at: new Date(r.updatedAt).toISOString() + } + } + private async _pullTable( table: string, runner: () => Promise, diff --git a/apps/desktop/src/main/services/DictionaryService.ts b/apps/desktop/src/main/services/DictionaryService.ts index df2c41b..9cbcfbd 100644 --- a/apps/desktop/src/main/services/DictionaryService.ts +++ b/apps/desktop/src/main/services/DictionaryService.ts @@ -6,6 +6,7 @@ import { getDatabase } from '../db' import { dictionary } from '../db/schema' import type { Dictionary, NewDictionary } from '../db/schema' import { getLogger } from './LoggerService' +import { getCloudSyncService } from './CloudSyncService' import type { DictionaryEntry, DictionaryQueryParams, @@ -36,6 +37,8 @@ class DictionaryService { db.insert(dictionary).values(entry).run() logger.info(`Dictionary entry added: "${params.word}"`) + // Phase 3.3: 자동 push (fire-and-forget) + void getCloudSyncService().pushOne('dictionary', id) return this._toEntry(entry as Dictionary) } @@ -52,6 +55,8 @@ class DictionaryService { db.update(dictionary).set(updates).where(eq(dictionary.id, params.id)).run() const updated = db.select().from(dictionary).where(eq(dictionary.id, params.id)).get() + // Phase 3.3: 자동 push (fire-and-forget) + void getCloudSyncService().pushOne('dictionary', params.id) return updated ? this._toEntry(updated) : null } diff --git a/apps/desktop/src/main/services/HistoryService.ts b/apps/desktop/src/main/services/HistoryService.ts index 441a26d..5a84455 100644 --- a/apps/desktop/src/main/services/HistoryService.ts +++ b/apps/desktop/src/main/services/HistoryService.ts @@ -6,6 +6,7 @@ import { getDatabase } from '../db' import { history, stats } from '../db/schema' import type { History, NewHistory } from '../db/schema' import { getLogger } from './LoggerService' +import { getCloudSyncService } from './CloudSyncService' import type { HistoryEntry, HistoryQueryParams, @@ -36,6 +37,9 @@ class HistoryService { logger.info(`History entry created: ${id}`) + // Phase 3.3: 로그인 상태면 자동 push (fire-and-forget, 로컬 write는 차단하지 않음) + void getCloudSyncService().pushOne('history', id) + // 비동기로 LLM 타이틀 자동 생성 (fire-and-forget) this.generateTitle(id).catch(() => { /* ignore */ }) @@ -209,6 +213,8 @@ class HistoryService { const db = getDatabase() db.update(history).set({ title, updatedAt: Date.now() }).where(eq(history.id, id)).run() logger.info(`Auto title generated: ${id} → "${title}"`) + // Phase 3.3: 타이틀 업데이트 후 자동 push + void getCloudSyncService().pushOne('history', id) return title } } catch (err) { diff --git a/apps/desktop/src/main/services/MeetingModeService.ts b/apps/desktop/src/main/services/MeetingModeService.ts index af13fef..0818beb 100644 --- a/apps/desktop/src/main/services/MeetingModeService.ts +++ b/apps/desktop/src/main/services/MeetingModeService.ts @@ -8,6 +8,7 @@ import fs from 'fs' import { eq, desc, sql } from 'drizzle-orm' import { getLogger } from './LoggerService' import { configGet, configSet } from './ConfigService' +import { getCloudSyncService } from './CloudSyncService' import { getMainWindow } from '../windows/WindowManager' import { getDatabase } from '../db' import { meetingSessions, meetingMemos, meetingDocuments } from '../db/schema' @@ -197,6 +198,11 @@ class MeetingModeService extends EventEmitter { logger.info(`회의 녹음 시작: sessionId=${sessionId}`) this._sendStateToRenderer() + // Phase 3.3: 부모 meeting row를 즉시 push (빈 상태로). + // 자식 meeting_memos/meeting_documents의 RLS/FK가 부모 존재를 요구하므로, + // 녹음 중에 들어오는 addMemo/generateDocument 푸시가 통과하려면 필수. + void getCloudSyncService().pushOne('meetings', sessionId) + return { sessionId } } @@ -230,6 +236,9 @@ class MeetingModeService extends EventEmitter { this.emit('memo-added', memo) logger.debug(`메모 추가: [${formatTime(memo.timestampMs)}] ${content}`) + // Phase 3.3: 자동 push (fire-and-forget) + void getCloudSyncService().pushOne('meeting_memos', memo.id) + return memo } @@ -311,6 +320,9 @@ class MeetingModeService extends EventEmitter { updatedAt: now, }).where(eq(meetingSessions.id, sessionId)).run() + // Phase 3.3: 최종 save 후 자동 push (fire-and-forget) + void getCloudSyncService().pushOne('meetings', sessionId) + // Step 2.5: 오디오 WAV 파일 저장 (화자 구분용) if (this._audioBuffersForFile.length > 0) { try { @@ -654,6 +666,9 @@ class MeetingModeService extends EventEmitter { sendProgress(100) logger.info(`문서 생성 완료: docId=${docId}, sessionId=${params.sessionId}`) + // Phase 3.3: 자동 push (fire-and-forget) + void getCloudSyncService().pushOne('meeting_documents', docId) + return { id: docId, sessionId: params.sessionId, diff --git a/apps/desktop/src/main/services/MeetingSummaryService.ts b/apps/desktop/src/main/services/MeetingSummaryService.ts index ad7d2e7..3642d72 100644 --- a/apps/desktop/src/main/services/MeetingSummaryService.ts +++ b/apps/desktop/src/main/services/MeetingSummaryService.ts @@ -9,6 +9,7 @@ import { app, dialog } from 'electron' import { eq } from 'drizzle-orm' import { getLogger } from './LoggerService' import { getLocalLLMService } from './LocalLLMService' +import { getCloudSyncService } from './CloudSyncService' import { getDatabase } from '../db' import { history } from '../db/schema' import { getMainWindow } from '../windows/WindowManager' @@ -97,6 +98,9 @@ class MeetingSummaryService extends EventEmitter { .where(eq(history.id, historyId)) .run() + // Phase 3.3: 요약 저장 후 자동 push (fire-and-forget) + void getCloudSyncService().pushOne('history', historyId) + this._sendProgress(historyId, 'done') this._sendToRenderer(IPC_CHANNELS.MEETING_SUMMARY.SUMMARY_READY, summaryResult) this.emit('summary-ready', summaryResult) diff --git a/apps/web/src/app/(app)/dashboard/page.tsx b/apps/web/src/app/(app)/dashboard/page.tsx index 318dacd..e66d460 100644 --- a/apps/web/src/app/(app)/dashboard/page.tsx +++ b/apps/web/src/app/(app)/dashboard/page.tsx @@ -1,6 +1,7 @@ // apps/web/src/app/dashboard/page.tsx // 대시보드 — 요약 카드 4개 + 최근 회의 +import Link from 'next/link' import { Box, Grid, Stack } from '@mui/material' import { MetalCard, PhosphorText } from '@d3ro/ui/components/ds' import { d3roPalette, typoSx } from "@d3ro/ui/theme" @@ -154,12 +155,25 @@ export default async function DashboardPage(): Promise { ) : ( recentMeetings.map((meeting) => ( - - {meeting.title} - - {new Date(meeting.started_at).toLocaleString('ko-KR')} - - + + + {meeting.title} + + {new Date(meeting.started_at).toLocaleString('ko-KR')} + + + )) )} diff --git a/apps/web/src/app/(app)/meetings/[id]/page.tsx b/apps/web/src/app/(app)/meetings/[id]/page.tsx index 639c7df..24dffb1 100644 --- a/apps/web/src/app/(app)/meetings/[id]/page.tsx +++ b/apps/web/src/app/(app)/meetings/[id]/page.tsx @@ -68,15 +68,33 @@ export default async function MeetingDetailPage({ params }: PageProps): Promise< - {/* Transcript — Realtime 구독 */} + {/* Transcript — Realtime 구독 or raw_transcript fallback */} - TRANSCRIPT (LIVE) + TRANSCRIPT {meeting.status === 'recording' ? '(LIVE)' : ''} - + {(transcripts ?? []).length > 0 || meeting.status === 'recording' ? ( + + ) : meeting.edited_transcript || meeting.raw_transcript ? ( + + {meeting.edited_transcript ?? meeting.raw_transcript} + + ) : ( + + 전사가 없습니다. + + )} {/* Memos */} diff --git a/memory/project_status.md b/memory/project_status.md index 65250eb..d5486c6 100644 --- a/memory/project_status.md +++ b/memory/project_status.md @@ -208,6 +208,72 @@ OAuth provider도 아직 Supabase에 설정 안 된 상태. 강제 게이트는 - `~/Library/Application Support/d3ro-voice/users/${uuid}/d3ro.db` 파일 생성 확인 - 기존 `d3ro-voice.db`가 있는 환경에서 archive rename 동작 확인 +### SaaS [10] Phase 3.3 Auto push on write + 웹 transcript fallback + Dashboard Link 픽스 (Phase 5 Part 3, 2026-04-11) + +> **수동 Push 버튼이 사라졌다.** 로컬 SQLite write가 일어나는 모든 경로(8곳)에서 `CloudSyncService.pushOne`이 fire-and-forget으로 즉시 실행되어 Supabase에 반영. 웹 Dashboard 새로고침 한 번이면 방금 녹음한 미팅/메모/전사까지 전부 보인다. 이번 세션 한 번에 end-to-end 실증까지 통과. + +**실증 시나리오** +1. 데스크톱 dev 재기동 → `Restored session for user: yunchan8804@gmail.com` (cloud-sync.token 자동 복원) → 사용자 DB 재오픈 +2. 신규 Meeting 모드 녹음 1건 (약 33초, 중간에 메모 2건 추가) +3. 로그에 즉시 나타난 push 이벤트: + - `pushOne history/4c860fe2-... ok` + - `pushOne meetings/6bb51eca-... ok` +4. 재기동 시 pushAll 캐치업으로 직전 실패한 메모 2건이 자동 재시도되어 성공 (`Sync complete: pushed=4 errors=0`) +5. 웹 `http://localhost:3000/meetings/6bb51eca-...` 새로고침 → **전사 + 메모 둘 다 렌더 확인** (사용자 "잘 전사와 메모가 올라왔어") + +**구현 — Phase 3.3 Auto push on write** + +1. `CloudSyncService.pushOne(table, id)` 신규 — 제네릭 단건 push. 로그인 상태 아니면 silent noop(로컬 write 절대 차단 금지), 실패 시 `logger.warn`만(throw 금지). `pushAll`과 공통 매퍼 공유 (`_mapHistoryPayload` / `_mapDictionaryPayload` / `_mapMeetingPayload` / `_mapMemoPayload` / `_mapDocumentPayload` 5개 private 메서드 추출, DRY). +2. Hook 삽입 위치 8곳 (전부 `void getCloudSyncService().pushOne(...)`, fire-and-forget): + - `HistoryService.create()` 끝 → history + - `HistoryService.generateTitle()` DB update 직후 → history (LLM 자동 타이틀) + - `MeetingSummaryService.generateSummary()` summaryText 저장 후 → history ("updatePolishedText" 상응) + - `DictionaryService.add()` 끝 → dictionary + - `DictionaryService.update()` 끝 → dictionary + - `MeetingModeService.startRecording()` 끝 → meetings (**부모 pre-push**, Fix 1 참조) + - `MeetingModeService.addMemo()` 끝 → meeting_memos + - `MeetingModeService._runPostProcessing()` 완료 상태 저장 직후 → meetings (raw_transcript 포함) + - `MeetingModeService.generateDocument()` DB insert 직후 → meeting_documents + +**발견+픽스한 구조 버그 3건 (이번 세션)** + +#### Bug 7: meeting_memos RLS violation — 부모 meeting row 미존재 +- 증상: 녹음 중 메모 추가 → `pushOne meeting_memos/4a733d76-... failed: new row violates row-level security policy for table "meeting_memos"` (21:19:14에 2건 실패) +- 원인: RLS 정책이 `meeting_id`에 해당하는 부모 `meetings` row의 존재/소유권을 요구하는 것으로 추정 (정확한 정책 확인은 안 함). 기존 구현에서는 `_runPostProcessing()`에서 "completed" 상태 저장 시점에만 meeting이 push되므로, 녹음 중 addMemo → pushOne 시점에는 **부모 row가 Supabase에 아직 없음** → 자식 insert RLS 거부. +- Fix 1: `MeetingModeService.startRecording()` 끝에 `void getCloudSyncService().pushOne('meetings', sessionId)` 추가 — 녹음 시작 즉시 부모 row(status='recording', raw_transcript=null)를 Supabase에 upsert. 이후 addMemo/generateDocument의 자식 push가 FK/RLS를 통과. +- 검증: 재기동 직후 pushAll 캐치업이 이전 세션의 실패 메모 2건을 재시도 → `Sync complete: pushed=4 errors=0` (meeting 1 + memos 2 + history 1 추정) → 웹에서 메모 렌더 확인. +- 부수 효과(의도적): fire-and-forget 실패는 pushAll 캐치업으로 자동 복구. 실패가 영구 손실로 이어지지 않음. + +#### Bug 8: 웹 meeting detail 페이지가 transcripts 테이블만 읽음 (meetings.raw_transcript 무시) +- 증상: 녹음 종료 후 웹 `/meetings/[id]` 상세 페이지의 TRANSCRIPT 섹션이 빈 상태로 표시. +- 원인: `apps/web/src/app/(app)/meetings/[id]/page.tsx`가 `supabase.from('transcripts').select(...).eq('meeting_id', id)`만 읽고 `LiveTranscriptList`에 넘김. 데스크톱은 CaptionService 세그먼트를 인메모리에 버퍼링했다가 `meeting_sessions.rawTranscript` 단일 텍스트 필드에만 저장 — `transcripts` 테이블에는 **아무것도 쓰지 않음**. Realtime 스트림 설계(웹 실시간 캡션 뷰어용)와 데스크톱 오프라인 전사 플로우의 접점이 없었음. +- Fix 2 (웹 fallback): `transcripts`가 비어있고 미팅 상태가 `recording`이 아닐 때 `meeting.edited_transcript ?? meeting.raw_transcript`를 `whiteSpace: 'pre-wrap'`의 Box로 렌더. 둘 다 없으면 "전사가 없습니다." placeholder. recording 상태거나 `transcripts` 행이 있으면 기존 `LiveTranscriptList` 유지(향후 실시간 캡션 뷰어 경로 보존). +- 왜 데스크톱을 고치지 않았나: MVP 스코프 고려. 세그먼트 per-row push는 쓰기 빈도가 폭증하고 RLS 정책을 별도로 다듬어야 함. Phase 3.3의 목표는 "완료된 미팅이 웹에서 읽혀야 한다"이고, 그건 meetings.raw_transcript 경로 하나로 충분. 실시간 캡션 동기화가 정식 기능으로 들어올 때 desktop → transcripts 쓰기를 추가. + +#### Bug 9: Dashboard RECENT MEETINGS 카드 클릭 무반응 (U2) +- 증상: 지난 세션 말미 사용자 피드백 "웹에서 미팅 아이템이 들어가지질 않는다" 재확인 → Dashboard 카드 클릭 시 `/meetings/[id]` 이동 안 됨. +- 원인: `apps/web/src/app/(app)/dashboard/page.tsx:156`의 `MetalCard`가 ``로 감싸져 있지 않아 클릭 인터랙션 자체가 없었음. +- Fix 3: `next/link`의 `Link`로 `MetalCard`를 감싸고 `href={\`/meetings/${meeting.id}\`}`, `cursor: 'pointer'` + `&:hover { transform: translateY(-1px) }` hover 효과 추가, `textDecoration: 'none'`으로 링크 기본 스타일 억제. +- 검증: 새 기동 후 실증 녹음 전에 이미 웹에서 `GET /meetings/cd4df4f1-... 200` 확인됨. + +**검증** +- desktop `tsc --noEmit` ✅ +- web `tsc --noEmit` ✅ +- dev 런타임 재기동 ✅ (`Restored session` → 직전 `Sync complete: pushed=4 errors=0` 캐치업 → Main window) +- 녹음 1건 fire-and-forget push ✅ (`pushOne history/... ok` + `pushOne meetings/... ok`, 21:19:38) +- **웹 Dashboard 새 미팅 렌더 ✅** (카드 + 상세 페이지 + 전사 + 메모 전부) +- **사용자 확인: "잘 전사와 메모가 올라왔어"** +- Realtime 채널 TIMED_OUT ❌ (알려진 U1, 블로커 아님 — Phase 3.3 auto push가 대체 일관성 경로) + +**다음 세션 권장 액션** +- Fix 1의 재현 실증 (녹음 → 중간 메모 추가 시점마다 `pushOne meeting_memos/... ok` 실시간 발화 — 이번 세션은 pushAll 캐치업으로 통과해서 pre-push 효과를 직접 본 건 아님) +- U1 Realtime TIMED_OUT 원인 조사 (선택) +- Phase 3.2 PREMIUM_LLM Edge Function 경로 (Anthropic/OpenAI key 주입 필요) +- 24 → 25+ commits → git push 컨펌 (사용자 결정 시점) +- `package-lock.json` 정리 (`rollup-win32-x64-msvc` optional dep 이슈) + +--- + ### SaaS [9] 로컬 ID UUID 통일 + Realtime publication 픽스 + 실증 A~E 전원 통과 (Phase 5 Part 2, 2026-04-11) > **3 클라이언트 데이터 일원화 end-to-end 실증 완료.** Phase 5 Part 1에서 못 잡은 3개 구조 버그를 한 세션에 픽스하고 실증 A/B/C/D/E 전원 통과.