feat(desktop+web): Phase 3.3 Auto push on write + transcript fallback + dashboard Link (빅뱅 Phase 5 Part 3)
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 실시간 발화 → 웹에서 전사/메모 렌더 확인 (사용자 "잘 전사와 메모가 올라왔어")
This commit is contained in:
parent
4e6280bc69
commit
046ac857cb
8 changed files with 344 additions and 85 deletions
|
|
@ -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<void> {
|
||||
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<string, unknown> {
|
||||
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<string, unknown> {
|
||||
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<string, unknown> {
|
||||
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<string, unknown> {
|
||||
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<string, unknown> {
|
||||
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<number>,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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<React.ReactElement> {
|
|||
</MetalCard>
|
||||
) : (
|
||||
recentMeetings.map((meeting) => (
|
||||
<MetalCard key={meeting.id} sx={{ p: 3 }}>
|
||||
<Box sx={{ color: d3roPalette.text.primary, ...typoSx("body") }}>{meeting.title}</Box>
|
||||
<Box sx={{ color: d3roPalette.text.muted, fontSize: 12, mt: 0.5 }}>
|
||||
{new Date(meeting.started_at).toLocaleString('ko-KR')}
|
||||
</Box>
|
||||
</MetalCard>
|
||||
<Link
|
||||
key={meeting.id}
|
||||
href={`/meetings/${meeting.id}`}
|
||||
style={{ textDecoration: 'none' }}
|
||||
>
|
||||
<MetalCard
|
||||
sx={{
|
||||
p: 3,
|
||||
cursor: 'pointer',
|
||||
transition: 'transform 120ms ease, box-shadow 120ms ease',
|
||||
'&:hover': { transform: 'translateY(-1px)' }
|
||||
}}
|
||||
>
|
||||
<Box sx={{ color: d3roPalette.text.primary, ...typoSx("body") }}>{meeting.title}</Box>
|
||||
<Box sx={{ color: d3roPalette.text.muted, fontSize: 12, mt: 0.5 }}>
|
||||
{new Date(meeting.started_at).toLocaleString('ko-KR')}
|
||||
</Box>
|
||||
</MetalCard>
|
||||
</Link>
|
||||
))
|
||||
)}
|
||||
</Stack>
|
||||
|
|
|
|||
|
|
@ -68,15 +68,33 @@ export default async function MeetingDetailPage({ params }: PageProps): Promise<
|
|||
<MeetingAudioPlayer storageKey={meeting.audio_storage_key ?? null} />
|
||||
</MetalCard>
|
||||
|
||||
{/* Transcript — Realtime 구독 */}
|
||||
{/* Transcript — Realtime 구독 or raw_transcript fallback */}
|
||||
<MetalCard sx={{ p: 3 }}>
|
||||
<PhosphorText variant="heading" sx={{ mb: 2 }}>
|
||||
TRANSCRIPT (LIVE)
|
||||
TRANSCRIPT {meeting.status === 'recording' ? '(LIVE)' : ''}
|
||||
</PhosphorText>
|
||||
<LiveTranscriptList
|
||||
meetingId={id}
|
||||
initial={(transcripts ?? []) as unknown as TranscriptRow[]}
|
||||
/>
|
||||
{(transcripts ?? []).length > 0 || meeting.status === 'recording' ? (
|
||||
<LiveTranscriptList
|
||||
meetingId={id}
|
||||
initial={(transcripts ?? []) as unknown as TranscriptRow[]}
|
||||
/>
|
||||
) : meeting.edited_transcript || meeting.raw_transcript ? (
|
||||
<Box
|
||||
sx={{
|
||||
whiteSpace: 'pre-wrap',
|
||||
color: d3roPalette.text.primary,
|
||||
fontSize: 13,
|
||||
lineHeight: 1.6,
|
||||
fontFamily: 'inherit'
|
||||
}}
|
||||
>
|
||||
{meeting.edited_transcript ?? meeting.raw_transcript}
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ color: d3roPalette.text.muted, fontSize: 13 }}>
|
||||
전사가 없습니다.
|
||||
</Box>
|
||||
)}
|
||||
</MetalCard>
|
||||
|
||||
{/* Memos */}
|
||||
|
|
|
|||
|
|
@ -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`가 `<Link>`로 감싸져 있지 않아 클릭 인터랙션 자체가 없었음.
|
||||
- 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 전원 통과.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue