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>,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue