From c4972342302d6b647b1c535cb8763baaa1e159bf Mon Sep 17 00:00:00 2001 From: samtiz Date: Wed, 9 Jul 2025 21:17:16 +0900 Subject: [PATCH] firestore + full end-to-end encryption --- PLAN.md | 51 ----- repository_api_report.md | 77 ------- src/common/config/schema.js | 3 +- src/common/repositories/firestoreConverter.js | 11 +- .../preset/firebase.repository.js | 29 ++- .../session/firebase.repository.js | 28 ++- src/common/repositories/session/index.js | 14 +- .../repositories/user/firebase.repository.js | 10 +- .../repositories/user/sqlite.repository.js | 11 + src/common/services/authService.js | 21 ++ src/common/services/firebaseClient.js | 17 +- src/common/services/migrationService.js | 192 ++++++++++++++++++ src/features/ask/askService.js | 19 +- .../ask/repositories/firebase.repository.js | 7 +- .../stt/repositories/firebase.repository.js | 7 +- .../repositories/firebase.repository.js | 16 +- .../repositories/firebase.repository.js | 29 ++- src/index.js | 6 +- 18 files changed, 363 insertions(+), 185 deletions(-) delete mode 100644 PLAN.md delete mode 100644 repository_api_report.md create mode 100644 src/common/services/migrationService.js diff --git a/PLAN.md b/PLAN.md deleted file mode 100644 index 643845a..0000000 --- a/PLAN.md +++ /dev/null @@ -1,51 +0,0 @@ -# Project Plan: Firebase Integration & Encryption - -This document outlines the plan to integrate Firebase Firestore as a remote database for logged-in users and implement end-to-end encryption for user data. - -## Phase 1: `encryptionService` and Secure Key Management - -The goal of this phase is to create a centralized service for data encryption and decryption, with secure management of encryption keys. - -1. **Install `keytar`**: Add the `keytar` package to the project to securely store encryption keys in the OS keychain. -2. **Create `encryptionService.js`**: - - Location: `src/common/services/encryptionService.js` - - Implement `encrypt(text)` and `decrypt(encrypted)` functions using Node.js `crypto` with `AES-256-GCM`. -3. **Implement Key Management**: - - Create an `initializeKey(userId)` function within the service. - - This function will first attempt to retrieve the encryption key from `keytar`. - - If `keytar` fails or no key is found, it will generate a secure, session-only key in memory as a fallback. It will **not** save the key to an insecure location like `electron-store`. - -## Phase 2: Automatic Encryption/Decryption via Firestore Converter - -This phase aims to abstract away the encryption/decryption logic from the repository layer, making it automatic. - -1. **Create `firestoreConverter.js`**: - - Location: `src/common/repositories/firestoreConverter.js` - - Implement a factory function `createEncryptedConverter(fieldsToEncrypt = [])`. - - This function will return a Firestore converter object with `toFirestore` and `fromFirestore` methods. - - `toFirestore`: Will automatically encrypt the specified fields before writing data to Firestore. - - `fromFirestore`: Will automatically decrypt the specified fields after reading data from Firestore. - -## Phase 3: Implement Firebase Repositories - -With the encryption layer ready, we will create the Firebase equivalents of the existing SQLite repositories. - -1. **Create `session/firebase.repository.js`**: - - Location: `src/common/repositories/session/firebase.repository.js` - - Use the `createEncryptedConverter` to encrypt fields like `title`. - - Implement all functions from the SQLite counterpart (`create`, `getById`, `getOrCreateActive`, etc.) using Firestore APIs. -2. **Create `ask/repositories/firebase.repository.js`**: - - Location: `src/features/ask/repositories/firebase.repository.js` - - Use the `createEncryptedConverter` to encrypt the `content` field of AI messages. - - Implement all functions from the SQLite counterpart (`addAiMessage`, `getAllAiMessagesBySessionId`). - -## Phase 4: Integrate Repository Strategy Pattern - -This final phase will activate the logic that switches between local and remote databases based on user authentication status. - -1. **Update `getRepository()` functions**: - - Modify `src/common/repositories/session/index.js` and `src/features/ask/repositories/index.js`. - - In the `getRepository()` function, use `authService.getCurrentUser()` to check if the user is logged in (`user.isLoggedIn`). - - If logged in, return the `firebaseRepository`. - - Otherwise, return the `sqliteRepository`. - - Uncomment the `require` statements for the newly created Firebase repositories. \ No newline at end of file diff --git a/repository_api_report.md b/repository_api_report.md deleted file mode 100644 index a9a6231..0000000 --- a/repository_api_report.md +++ /dev/null @@ -1,77 +0,0 @@ -# Repository API Report - -이 문서는 각 리포지토리 모듈의 공개 API 명세를 정리합니다. 모든 서비스 레이어는 여기에 명시된 함수 시그니처를 따라야 합니다. `uid`는 어댑터 레이어에서 자동으로 주입되므로 서비스 레이어에서 전달해서는 안 됩니다. - ---- - -### Session Repository -**Path:** `src/common/repositories/session/` - -- `getById(id: string)` -- `create(type: 'ask' | 'listen' = 'ask')` -- `getAllByUserId()` -- `updateTitle(id: string, title: string)` -- `deleteWithRelatedData(id:string)` -- `end(id: string)` -- `updateType(id: string, type: 'ask' | 'listen')` -- `touch(id: string)` -- `getOrCreateActive(requestedType: 'ask' | 'listen' = 'ask')` -- `endAllActiveSessions()` - ---- - -### User Repository -**Path:** `src/common/repositories/user/` - -- `findOrCreate(user: object)` -- `getById()` -- `saveApiKey(apiKey: string, provider: string)` -- `update(updateData: object)` -- `deleteById()` - ---- - -### Preset Repository -**Path:** `src/common/repositories/preset/` - -- `getPresets()` -- `getPresetTemplates()` -- `create(options: { title: string, prompt: string })` -- `update(id: string, options: { title: string, prompt: string })` -- `delete(id: string)` - ---- - -### Ask Repository (AI Messages) -**Path:** `src/features/ask/repositories/` - -- `addAiMessage(options: { sessionId: string, role: string, content: string, model?: string })` -- `getAllAiMessagesBySessionId(sessionId: string)` - ---- - -### STT Repository (Transcripts) -**Path:** `src/features/listen/stt/repositories/` - -- `addTranscript(options: { sessionId: string, speaker: string, text: string })` -- `getAllTranscriptsBySessionId(sessionId: string)` - ---- - -### Summary Repository -**Path:** `src/features/listen/summary/repositories/` - -- `saveSummary(options: { sessionId: string, tldr: string, text: string, bullet_json: string, action_json: string, model?: string })` -- `getSummaryBySessionId(sessionId: string)` - ---- - -### Settings Repository (Presets) -**Path:** `src/features/settings/repositories/` -*(Note: This is largely a duplicate of the main Preset Repository and might be a candidate for future refactoring.)* - -- `getPresets()` -- `getPresetTemplates()` -- `createPreset(options: { title: string, prompt: string })` -- `updatePreset(id: string, options: { title: string, prompt: string })` -- `deletePreset(id: string)` \ No newline at end of file diff --git a/src/common/config/schema.js b/src/common/config/schema.js index 00b58f7..1db6de3 100644 --- a/src/common/config/schema.js +++ b/src/common/config/schema.js @@ -7,7 +7,8 @@ const LATEST_SCHEMA = { { name: 'created_at', type: 'INTEGER' }, { name: 'api_key', type: 'TEXT' }, { name: 'provider', type: 'TEXT DEFAULT \'openai\'' }, - { name: 'auto_update_enabled', type: 'INTEGER DEFAULT 1' } + { name: 'auto_update_enabled', type: 'INTEGER DEFAULT 1' }, + { name: 'has_migrated_to_firebase', type: 'INTEGER DEFAULT 0' } ] }, sessions: { diff --git a/src/common/repositories/firestoreConverter.js b/src/common/repositories/firestoreConverter.js index cb1532c..695f039 100644 --- a/src/common/repositories/firestoreConverter.js +++ b/src/common/repositories/firestoreConverter.js @@ -1,4 +1,5 @@ const encryptionService = require('../services/encryptionService'); +const { Timestamp } = require('firebase/firestore'); /** * Creates a Firestore converter that automatically encrypts and decrypts specified fields. @@ -19,7 +20,7 @@ function createEncryptedConverter(fieldsToEncrypt = []) { } } // Ensure there's a timestamp for the last modification - firestoreData.updated_at = Math.floor(Date.now() / 1000); + firestoreData.updated_at = Timestamp.now(); return firestoreData; }, /** @@ -35,6 +36,14 @@ function createEncryptedConverter(fieldsToEncrypt = []) { appObject[field] = encryptionService.decrypt(appObject[field]); } } + + // Convert Firestore Timestamps back to Unix timestamps (seconds) for app-wide consistency + for (const key in appObject) { + if (appObject[key] instanceof Timestamp) { + appObject[key] = appObject[key].seconds; + } + } + return appObject; } }; diff --git a/src/common/repositories/preset/firebase.repository.js b/src/common/repositories/preset/firebase.repository.js index 3baee39..c9c2f6f 100644 --- a/src/common/repositories/preset/firebase.repository.js +++ b/src/common/repositories/preset/firebase.repository.js @@ -1,5 +1,7 @@ -const { getFirestore, collection, doc, addDoc, getDoc, getDocs, updateDoc, deleteDoc, query, where, orderBy } = require('firebase/firestore'); +const { collection, doc, addDoc, getDoc, getDocs, updateDoc, deleteDoc, query, where, orderBy, Timestamp } = require('firebase/firestore'); +const { getFirestoreInstance } = require('../../services/firebaseClient'); const { createEncryptedConverter } = require('../firestoreConverter'); +const encryptionService = require('../../services/encryptionService'); const userPresetConverter = createEncryptedConverter(['prompt', 'title']); @@ -12,13 +14,14 @@ const defaultPresetConverter = { }; function userPresetsCol() { - const db = getFirestore(); + const db = getFirestoreInstance(); return collection(db, 'prompt_presets').withConverter(userPresetConverter); } function defaultPresetsCol() { - const db = getFirestore(); - return collection(db, 'defaults/prompt_presets').withConverter(defaultPresetConverter); + const db = getFirestoreInstance(); + // Path must have an odd number of segments. 'v1' is a placeholder document. + return collection(db, 'defaults/v1/prompt_presets').withConverter(defaultPresetConverter); } async function getPresets(uid) { @@ -49,12 +52,12 @@ async function getPresetTemplates() { } async function create({ uid, title, prompt }) { - const now = Math.floor(Date.now() / 1000); + const now = Timestamp.now(); const newPreset = { uid: uid, title, prompt, - is_default: false, + is_default: 0, created_at: now, }; const docRef = await addDoc(userPresetsCol(), newPreset); @@ -68,8 +71,18 @@ async function update(id, { title, prompt }, uid) { if (!docSnap.exists() || docSnap.data().uid !== uid || docSnap.data().is_default) { throw new Error("Preset not found or permission denied to update."); } - - await updateDoc(docRef, { title, prompt }); + + // Encrypt sensitive fields before sending to Firestore because `updateDoc` bypasses converters. + const updates = {}; + if (title !== undefined) { + updates.title = encryptionService.encrypt(title); + } + if (prompt !== undefined) { + updates.prompt = encryptionService.encrypt(prompt); + } + updates.updated_at = Timestamp.now(); + + await updateDoc(docRef, updates); return { changes: 1 }; } diff --git a/src/common/repositories/session/firebase.repository.js b/src/common/repositories/session/firebase.repository.js index 00e1411..632d3ad 100644 --- a/src/common/repositories/session/firebase.repository.js +++ b/src/common/repositories/session/firebase.repository.js @@ -1,16 +1,18 @@ -const { getFirestore, doc, getDoc, collection, addDoc, query, where, getDocs, writeBatch, orderBy, limit, runTransaction, updateDoc } = require('firebase/firestore'); +const { doc, getDoc, collection, addDoc, query, where, getDocs, writeBatch, orderBy, limit, updateDoc, Timestamp } = require('firebase/firestore'); +const { getFirestoreInstance } = require('../../services/firebaseClient'); const { createEncryptedConverter } = require('../firestoreConverter'); +const encryptionService = require('../../services/encryptionService'); const sessionConverter = createEncryptedConverter(['title']); function sessionsCol() { - const db = getFirestore(); + const db = getFirestoreInstance(); return collection(db, 'sessions').withConverter(sessionConverter); } // Sub-collection references are now built from the top-level function subCollections(sessionId) { - const db = getFirestore(); + const db = getFirestoreInstance(); const sessionPath = `sessions/${sessionId}`; return { transcripts: collection(db, `${sessionPath}/transcripts`), @@ -26,7 +28,7 @@ async function getById(id) { } async function create(uid, type = 'ask') { - const now = Math.floor(Date.now() / 1000); + const now = Timestamp.now(); const newSession = { uid: uid, members: [uid], // For future sharing functionality @@ -49,12 +51,15 @@ async function getAllByUserId(uid) { async function updateTitle(id, title) { const docRef = doc(sessionsCol(), id); - await updateDoc(docRef, { title }); + await updateDoc(docRef, { + title: encryptionService.encrypt(title), + updated_at: Timestamp.now() + }); return { changes: 1 }; } async function deleteWithRelatedData(id) { - const db = getFirestore(); + const db = getFirestoreInstance(); const batch = writeBatch(db); const { transcripts, ai_messages, summary } = subCollections(id); @@ -77,7 +82,7 @@ async function deleteWithRelatedData(id) { async function end(id) { const docRef = doc(sessionsCol(), id); - await updateDoc(docRef, { ended_at: Math.floor(Date.now() / 1000) }); + await updateDoc(docRef, { ended_at: Timestamp.now() }); return { changes: 1 }; } @@ -89,7 +94,7 @@ async function updateType(id, type) { async function touch(id) { const docRef = doc(sessionsCol(), id); - await updateDoc(docRef, { updated_at: Math.floor(Date.now() / 1000) }); + await updateDoc(docRef, { updated_at: Timestamp.now() }); return { changes: 1 }; } @@ -111,7 +116,7 @@ async function getOrCreateActive(uid, requestedType = 'ask') { console.log(`[Repo] Found active Firebase session ${activeSession.id}`); - const updates = { updated_at: Math.floor(Date.now() / 1000) }; + const updates = { updated_at: Timestamp.now() }; if (activeSession.session_type === 'ask' && requestedType === 'listen') { updates.session_type = 'listen'; console.log(`[Repo] Promoted Firebase session ${activeSession.id} to 'listen' type.`); @@ -131,9 +136,10 @@ async function endAllActiveSessions(uid) { if (snapshot.empty) return { changes: 0 }; - const batch = writeBatch(getFirestore()); + const batch = writeBatch(getFirestoreInstance()); + const now = Timestamp.now(); snapshot.forEach(d => { - batch.update(d.ref, { ended_at: Math.floor(Date.now() / 1000) }); + batch.update(d.ref, { ended_at: now }); }); await batch.commit(); diff --git a/src/common/repositories/session/index.js b/src/common/repositories/session/index.js index 4f7b540..da91036 100644 --- a/src/common/repositories/session/index.js +++ b/src/common/repositories/session/index.js @@ -1,8 +1,18 @@ const sqliteRepository = require('./sqlite.repository'); const firebaseRepository = require('./firebase.repository'); -const authService = require('../../../common/services/authService'); + +let authService = null; + +function setAuthService(service) { + authService = service; +} function getBaseRepository() { + if (!authService) { + // Fallback or error if authService is not set, to prevent crashes. + // During initial load, it might not be set, so we default to sqlite. + return sqliteRepository; + } const user = authService.getCurrentUser(); if (user && user.isLoggedIn) { return firebaseRepository; @@ -12,6 +22,8 @@ function getBaseRepository() { // The adapter layer that injects the UID const sessionRepositoryAdapter = { + setAuthService, // Expose the setter + getById: (id) => getBaseRepository().getById(id), create: (type = 'ask') => { diff --git a/src/common/repositories/user/firebase.repository.js b/src/common/repositories/user/firebase.repository.js index c8a7b3f..3867108 100644 --- a/src/common/repositories/user/firebase.repository.js +++ b/src/common/repositories/user/firebase.repository.js @@ -1,10 +1,12 @@ -const { getFirestore, doc, getDoc, setDoc, deleteDoc, writeBatch, query, where, getDocs, collection } = require('firebase/firestore'); +const { doc, getDoc, setDoc, deleteDoc, writeBatch, query, where, getDocs, collection, Timestamp } = require('firebase/firestore'); +const { getFirestoreInstance } = require('../../services/firebaseClient'); const { createEncryptedConverter } = require('../firestoreConverter'); +const encryptionService = require('../../services/encryptionService'); const userConverter = createEncryptedConverter(['api_key']); function usersCol() { - const db = getFirestore(); + const db = getFirestoreInstance(); return collection(db, 'users').withConverter(userConverter); } @@ -14,7 +16,7 @@ function usersCol() { async function findOrCreate(user) { if (!user || !user.uid) throw new Error('User object and uid are required'); const { uid, displayName, email } = user; - const now = Math.floor(Date.now() / 1000); + const now = Timestamp.now(); const docRef = doc(usersCol(), uid); const docSnap = await getDoc(docRef); @@ -49,7 +51,7 @@ async function update({ uid, displayName }) { } async function deleteById(uid) { - const db = getFirestore(); + const db = getFirestoreInstance(); const batch = writeBatch(db); // 1. Delete all sessions owned by the user diff --git a/src/common/repositories/user/sqlite.repository.js b/src/common/repositories/user/sqlite.repository.js index 556fa40..d443f47 100644 --- a/src/common/repositories/user/sqlite.repository.js +++ b/src/common/repositories/user/sqlite.repository.js @@ -58,6 +58,16 @@ function update({ uid, displayName }) { return { changes: result.changes }; } +function setMigrationComplete(uid) { + const db = sqliteClient.getDb(); + const stmt = db.prepare('UPDATE users SET has_migrated_to_firebase = 1 WHERE uid = ?'); + const result = stmt.run(uid); + if (result.changes > 0) { + console.log(`[Repo] Marked migration as complete for user ${uid}.`); + } + return result; +} + function deleteById(uid) { const db = sqliteClient.getDb(); const userSessions = db.prepare('SELECT id FROM sessions WHERE uid = ?').all(uid); @@ -88,5 +98,6 @@ module.exports = { getById, saveApiKey, update, + setMigrationComplete, deleteById }; \ No newline at end of file diff --git a/src/common/services/authService.js b/src/common/services/authService.js index ed47d5f..55862ff 100644 --- a/src/common/services/authService.js +++ b/src/common/services/authService.js @@ -3,6 +3,8 @@ const { BrowserWindow } = require('electron'); const { getFirebaseAuth } = require('./firebaseClient'); const fetch = require('node-fetch'); const encryptionService = require('./encryptionService'); +const migrationService = require('./migrationService'); +const sessionRepository = require('../repositories/session'); async function getVirtualKeyByEmail(email, idToken) { if (!idToken) { @@ -47,6 +49,12 @@ class AuthService { initialize() { if (this.isInitialized) return this.initializationPromise; + // --- Break the circular dependency --- + // Inject this authService instance into the session repository so it can be used + // without a direct `require` cycle. + sessionRepository.setAuthService(this); + // --- End of dependency injection --- + this.initializationPromise = new Promise((resolve) => { const auth = getFirebaseAuth(); onAuthStateChanged(auth, async (user) => { @@ -59,9 +67,16 @@ class AuthService { this.currentUserId = user.uid; this.currentUserMode = 'firebase'; + // Clean up any zombie sessions from a previous run for this user. + await sessionRepository.endAllActiveSessions(); + // ** Initialize encryption key for the logged-in user ** await encryptionService.initializeKey(user.uid); + // ** Check for and run data migration for the user ** + // No 'await' here, so it runs in the background without blocking startup. + migrationService.checkAndRunMigration(user); + // Start background task to fetch and save virtual key (async () => { @@ -92,6 +107,9 @@ class AuthService { this.currentUserId = 'default_user'; this.currentUserMode = 'local'; + // End active sessions for the local/default user as well. + await sessionRepository.endAllActiveSessions(); + // ** Initialize encryption key for the default/local user ** await encryptionService.initializeKey(this.currentUserId); } @@ -123,6 +141,9 @@ class AuthService { async signOut() { const auth = getFirebaseAuth(); try { + // End all active sessions for the current user BEFORE signing out. + await sessionRepository.endAllActiveSessions(); + await signOut(auth); console.log('[AuthService] User sign-out initiated successfully.'); // onAuthStateChanged will handle the state update and broadcast, diff --git a/src/common/services/firebaseClient.js b/src/common/services/firebaseClient.js index eee7cfe..99ca063 100644 --- a/src/common/services/firebaseClient.js +++ b/src/common/services/firebaseClient.js @@ -1,9 +1,9 @@ const { initializeApp } = require('firebase/app'); const { initializeAuth } = require('firebase/auth'); const Store = require('electron-store'); -const { setLogLevel } = require('firebase/firestore'); +const { getFirestore, setLogLevel } = require('firebase/firestore'); -setLogLevel('debug'); +// setLogLevel('debug'); /** * Firebase Auth expects the `persistence` option passed to `initializeAuth()` to be *classes*, @@ -69,6 +69,7 @@ const firebaseConfig = { let firebaseApp = null; let firebaseAuth = null; +let firestoreInstance = null; // To hold the specific DB instance function initializeFirebase() { if (firebaseApp) { @@ -87,7 +88,11 @@ function initializeFirebase() { persistence: [ElectronStorePersistence], }); + // Initialize Firestore with the specific database ID + firestoreInstance = getFirestore(firebaseApp, 'pickle-glass'); + console.log('[FirebaseClient] Firebase initialized successfully with class-based electron-store persistence.'); + console.log('[FirebaseClient] Firestore instance is targeting the "pickle-glass" database.'); } catch (error) { console.error('[FirebaseClient] Firebase initialization failed:', error); } @@ -100,7 +105,15 @@ function getFirebaseAuth() { return firebaseAuth; } +function getFirestoreInstance() { + if (!firestoreInstance) { + throw new Error("Firestore has not been initialized. Call initializeFirebase() first."); + } + return firestoreInstance; +} + module.exports = { initializeFirebase, getFirebaseAuth, + getFirestoreInstance, }; \ No newline at end of file diff --git a/src/common/services/migrationService.js b/src/common/services/migrationService.js new file mode 100644 index 0000000..8fe13c3 --- /dev/null +++ b/src/common/services/migrationService.js @@ -0,0 +1,192 @@ +const { doc, writeBatch, Timestamp } = require('firebase/firestore'); +const { getFirestoreInstance } = require('../services/firebaseClient'); +const encryptionService = require('../services/encryptionService'); + +const sqliteSessionRepo = require('../repositories/session/sqlite.repository'); +const sqlitePresetRepo = require('../repositories/preset/sqlite.repository'); +const sqliteUserRepo = require('../repositories/user/sqlite.repository'); +const sqliteSttRepo = require('../../features/listen/stt/repositories/sqlite.repository'); +const sqliteSummaryRepo = require('../../features/listen/summary/repositories/sqlite.repository'); +const sqliteAiMessageRepo = require('../../features/ask/repositories/sqlite.repository'); + +const MAX_BATCH_OPERATIONS = 500; + +async function checkAndRunMigration(firebaseUser) { + if (!firebaseUser || !firebaseUser.uid) { + console.log('[Migration] No user, skipping migration check.'); + return; + } + + console.log(`[Migration] Checking for user ${firebaseUser.uid}...`); + + const localUser = sqliteUserRepo.getById(firebaseUser.uid); + if (!localUser || localUser.has_migrated_to_firebase) { + console.log(`[Migration] User ${firebaseUser.uid} is not eligible or already migrated.`); + return; + } + + console.log(`[Migration] Starting data migration for user ${firebaseUser.uid}...`); + + try { + const db = getFirestoreInstance(); + + // --- Phase 1: Migrate Parent Documents (Presets & Sessions) --- + console.log('[Migration Phase 1] Migrating parent documents...'); + let phase1Batch = writeBatch(db); + let phase1OpCount = 0; + const phase1Promises = []; + + const localPresets = (await sqlitePresetRepo.getPresets(firebaseUser.uid)).filter(p => !p.is_default); + console.log(`[Migration Phase 1] Found ${localPresets.length} custom presets.`); + for (const preset of localPresets) { + const presetRef = doc(db, 'prompt_presets', preset.id); + const cleanPreset = { + uid: preset.uid, + title: encryptionService.encrypt(preset.title ?? ''), + prompt: encryptionService.encrypt(preset.prompt ?? ''), + is_default: preset.is_default ?? 0, + created_at: preset.created_at ? Timestamp.fromMillis(preset.created_at * 1000) : null, + updated_at: preset.updated_at ? Timestamp.fromMillis(preset.updated_at * 1000) : null + }; + phase1Batch.set(presetRef, cleanPreset); + phase1OpCount++; + if (phase1OpCount >= MAX_BATCH_OPERATIONS) { + phase1Promises.push(phase1Batch.commit()); + phase1Batch = writeBatch(db); + phase1OpCount = 0; + } + } + + const localSessions = await sqliteSessionRepo.getAllByUserId(firebaseUser.uid); + console.log(`[Migration Phase 1] Found ${localSessions.length} sessions.`); + for (const session of localSessions) { + const sessionRef = doc(db, 'sessions', session.id); + const cleanSession = { + uid: session.uid, + members: session.members ?? [session.uid], + title: encryptionService.encrypt(session.title ?? ''), + session_type: session.session_type ?? 'ask', + started_at: session.started_at ? Timestamp.fromMillis(session.started_at * 1000) : null, + ended_at: session.ended_at ? Timestamp.fromMillis(session.ended_at * 1000) : null, + updated_at: session.updated_at ? Timestamp.fromMillis(session.updated_at * 1000) : null + }; + phase1Batch.set(sessionRef, cleanSession); + phase1OpCount++; + if (phase1OpCount >= MAX_BATCH_OPERATIONS) { + phase1Promises.push(phase1Batch.commit()); + phase1Batch = writeBatch(db); + phase1OpCount = 0; + } + } + + if (phase1OpCount > 0) { + phase1Promises.push(phase1Batch.commit()); + } + + if (phase1Promises.length > 0) { + await Promise.all(phase1Promises); + console.log(`[Migration Phase 1] Successfully committed ${phase1Promises.length} batches of parent documents.`); + } else { + console.log('[Migration Phase 1] No parent documents to migrate.'); + } + + // --- Phase 2: Migrate Child Documents (sub-collections) --- + console.log('[Migration Phase 2] Migrating child documents for all sessions...'); + let phase2Batch = writeBatch(db); + let phase2OpCount = 0; + const phase2Promises = []; + + for (const session of localSessions) { + const transcripts = await sqliteSttRepo.getAllTranscriptsBySessionId(session.id); + for (const t of transcripts) { + const transcriptRef = doc(db, `sessions/${session.id}/transcripts`, t.id); + const cleanTranscript = { + uid: firebaseUser.uid, + session_id: t.session_id, + start_at: t.start_at ? Timestamp.fromMillis(t.start_at * 1000) : null, + end_at: t.end_at ? Timestamp.fromMillis(t.end_at * 1000) : null, + speaker: t.speaker ?? null, + text: encryptionService.encrypt(t.text ?? ''), + lang: t.lang ?? 'en', + created_at: t.created_at ? Timestamp.fromMillis(t.created_at * 1000) : null + }; + phase2Batch.set(transcriptRef, cleanTranscript); + phase2OpCount++; + if (phase2OpCount >= MAX_BATCH_OPERATIONS) { + phase2Promises.push(phase2Batch.commit()); + phase2Batch = writeBatch(db); + phase2OpCount = 0; + } + } + + const messages = await sqliteAiMessageRepo.getAllAiMessagesBySessionId(session.id); + for (const m of messages) { + const msgRef = doc(db, `sessions/${session.id}/ai_messages`, m.id); + const cleanMessage = { + uid: firebaseUser.uid, + session_id: m.session_id, + sent_at: m.sent_at ? Timestamp.fromMillis(m.sent_at * 1000) : null, + role: m.role ?? 'user', + content: encryptionService.encrypt(m.content ?? ''), + tokens: m.tokens ?? null, + model: m.model ?? 'unknown', + created_at: m.created_at ? Timestamp.fromMillis(m.created_at * 1000) : null + }; + phase2Batch.set(msgRef, cleanMessage); + phase2OpCount++; + if (phase2OpCount >= MAX_BATCH_OPERATIONS) { + phase2Promises.push(phase2Batch.commit()); + phase2Batch = writeBatch(db); + phase2OpCount = 0; + } + } + + const summary = await sqliteSummaryRepo.getSummaryBySessionId(session.id); + if (summary) { + // Reverting to use 'data' as the document ID for summary. + const summaryRef = doc(db, `sessions/${session.id}/summary`, 'data'); + const cleanSummary = { + uid: firebaseUser.uid, + session_id: summary.session_id, + generated_at: summary.generated_at ? Timestamp.fromMillis(summary.generated_at * 1000) : null, + model: summary.model ?? 'unknown', + tldr: encryptionService.encrypt(summary.tldr ?? ''), + text: encryptionService.encrypt(summary.text ?? ''), + bullet_json: encryptionService.encrypt(summary.bullet_json ?? '[]'), + action_json: encryptionService.encrypt(summary.action_json ?? '[]'), + tokens_used: summary.tokens_used ?? null, + updated_at: summary.updated_at ? Timestamp.fromMillis(summary.updated_at * 1000) : null + }; + phase2Batch.set(summaryRef, cleanSummary); + phase2OpCount++; + if (phase2OpCount >= MAX_BATCH_OPERATIONS) { + phase2Promises.push(phase2Batch.commit()); + phase2Batch = writeBatch(db); + phase2OpCount = 0; + } + } + } + + if (phase2OpCount > 0) { + phase2Promises.push(phase2Batch.commit()); + } + + if (phase2Promises.length > 0) { + await Promise.all(phase2Promises); + console.log(`[Migration Phase 2] Successfully committed ${phase2Promises.length} batches of child documents.`); + } else { + console.log('[Migration Phase 2] No child documents to migrate.'); + } + + // --- 4. Mark migration as complete --- + sqliteUserRepo.setMigrationComplete(firebaseUser.uid); + console.log(`[Migration] ✅ Successfully marked migration as complete for ${firebaseUser.uid}.`); + + } catch (error) { + console.error(`[Migration] 🔥 An error occurred during migration for user ${firebaseUser.uid}:`, error); + } +} + +module.exports = { + checkAndRunMigration, +}; \ No newline at end of file diff --git a/src/features/ask/askService.js b/src/features/ask/askService.js index dcfc8b8..33d661e 100644 --- a/src/features/ask/askService.js +++ b/src/features/ask/askService.js @@ -28,9 +28,18 @@ async function sendMessage(userPrompt) { askWindow.webContents.send('hide-text-input'); } + let sessionId; + try { console.log(`[AskService] 🤖 Processing message: ${userPrompt.substring(0, 50)}...`); + // --- Save user's message immediately --- + // This ensures the user message is always timestamped before the assistant's response. + sessionId = await sessionRepository.getOrCreateActive('ask'); + await askRepository.addAiMessage({ sessionId, role: 'user', content: userPrompt.trim() }); + console.log(`[AskService] DB: Saved user prompt to session ${sessionId}`); + // --- End of user message saving --- + const modelInfo = await getCurrentModelInfo(null, { type: 'llm' }); if (!modelInfo || !modelInfo.apiKey) { throw new Error('AI model or API key not configured.'); @@ -99,15 +108,13 @@ async function sendMessage(userPrompt) { if (data === '[DONE]') { askWin.webContents.send('ask-response-stream-end'); - // Save to DB + // Save assistant's message to DB try { - // The repository adapter will now handle the UID internally. - const sessionId = await sessionRepository.getOrCreateActive('ask'); - await askRepository.addAiMessage({ sessionId, role: 'user', content: userPrompt.trim() }); + // sessionId is already available from when we saved the user prompt await askRepository.addAiMessage({ sessionId, role: 'assistant', content: fullResponse }); - console.log(`[AskService] DB: Saved ask/answer pair to session ${sessionId}`); + console.log(`[AskService] DB: Saved assistant response to session ${sessionId}`); } catch(dbError) { - console.error("[AskService] DB: Failed to save ask/answer pair:", dbError); + console.error("[AskService] DB: Failed to save assistant response:", dbError); } return { success: true, response: fullResponse }; diff --git a/src/features/ask/repositories/firebase.repository.js b/src/features/ask/repositories/firebase.repository.js index a8148de..09ef25c 100644 --- a/src/features/ask/repositories/firebase.repository.js +++ b/src/features/ask/repositories/firebase.repository.js @@ -1,16 +1,17 @@ -const { getFirestore, collection, addDoc, query, getDocs, orderBy } = require('firebase/firestore'); +const { collection, addDoc, query, getDocs, orderBy, Timestamp } = require('firebase/firestore'); +const { getFirestoreInstance } = require('../../../common/services/firebaseClient'); const { createEncryptedConverter } = require('../../../common/repositories/firestoreConverter'); const aiMessageConverter = createEncryptedConverter(['content']); function aiMessagesCol(sessionId) { if (!sessionId) throw new Error("Session ID is required to access AI messages."); - const db = getFirestore(); + const db = getFirestoreInstance(); return collection(db, `sessions/${sessionId}/ai_messages`).withConverter(aiMessageConverter); } async function addAiMessage({ uid, sessionId, role, content, model = 'unknown' }) { - const now = Math.floor(Date.now() / 1000); + const now = Timestamp.now(); const newMessage = { uid, // To identify the author of the message session_id: sessionId, diff --git a/src/features/listen/stt/repositories/firebase.repository.js b/src/features/listen/stt/repositories/firebase.repository.js index a79954a..3fb1606 100644 --- a/src/features/listen/stt/repositories/firebase.repository.js +++ b/src/features/listen/stt/repositories/firebase.repository.js @@ -1,16 +1,17 @@ -const { getFirestore, collection, addDoc, query, getDocs, orderBy } = require('firebase/firestore'); +const { collection, addDoc, query, getDocs, orderBy, Timestamp } = require('firebase/firestore'); +const { getFirestoreInstance } = require('../../../../common/services/firebaseClient'); const { createEncryptedConverter } = require('../../../../common/repositories/firestoreConverter'); const transcriptConverter = createEncryptedConverter(['text']); function transcriptsCol(sessionId) { if (!sessionId) throw new Error("Session ID is required to access transcripts."); - const db = getFirestore(); + const db = getFirestoreInstance(); return collection(db, `sessions/${sessionId}/transcripts`).withConverter(transcriptConverter); } async function addTranscript({ uid, sessionId, speaker, text }) { - const now = Math.floor(Date.now() / 1000); + const now = Timestamp.now(); const newTranscript = { uid, // To identify the author/source of the transcript session_id: sessionId, diff --git a/src/features/listen/summary/repositories/firebase.repository.js b/src/features/listen/summary/repositories/firebase.repository.js index 1e9c115..573c299 100644 --- a/src/features/listen/summary/repositories/firebase.repository.js +++ b/src/features/listen/summary/repositories/firebase.repository.js @@ -1,20 +1,24 @@ -const { getFirestore, collection, doc, setDoc, getDoc } = require('firebase/firestore'); +const { collection, doc, setDoc, getDoc, Timestamp } = require('firebase/firestore'); +const { getFirestoreInstance } = require('../../../../common/services/firebaseClient'); const { createEncryptedConverter } = require('../../../../common/repositories/firestoreConverter'); +const encryptionService = require('../../../../common/services/encryptionService'); const fieldsToEncrypt = ['tldr', 'text', 'bullet_json', 'action_json']; const summaryConverter = createEncryptedConverter(fieldsToEncrypt); function summaryDocRef(sessionId) { if (!sessionId) throw new Error("Session ID is required to access summary."); - const db = getFirestore(); - const path = `sessions/${sessionId}/summary`; - return doc(collection(db, path).withConverter(summaryConverter), 'data'); + const db = getFirestoreInstance(); + // Reverting to the original structure with 'data' as the document ID. + const docPath = `sessions/${sessionId}/summary/data`; + return doc(db, docPath).withConverter(summaryConverter); } async function saveSummary({ uid, sessionId, tldr, text, bullet_json, action_json, model = 'unknown' }) { - const now = Math.floor(Date.now() / 1000); + const now = Timestamp.now(); const summaryData = { uid, // To know who generated the summary + session_id: sessionId, generated_at: now, model, text, @@ -24,6 +28,8 @@ async function saveSummary({ uid, sessionId, tldr, text, bullet_json, action_jso updated_at: now, }; + // The converter attached to summaryDocRef will handle encryption via its `toFirestore` method. + // Manual encryption was removed to fix the double-encryption bug. const docRef = summaryDocRef(sessionId); await setDoc(docRef, summaryData, { merge: true }); diff --git a/src/features/settings/repositories/firebase.repository.js b/src/features/settings/repositories/firebase.repository.js index 53524f0..e007398 100644 --- a/src/features/settings/repositories/firebase.repository.js +++ b/src/features/settings/repositories/firebase.repository.js @@ -1,7 +1,9 @@ -const { getFirestore, collection, doc, addDoc, getDoc, getDocs, updateDoc, deleteDoc, query, where, orderBy } = require('firebase/firestore'); +const { collection, doc, addDoc, getDoc, getDocs, updateDoc, deleteDoc, query, where, orderBy } = require('firebase/firestore'); +const { getFirestoreInstance } = require('../../../common/services/firebaseClient'); const { createEncryptedConverter } = require('../../../common/repositories/firestoreConverter'); +const encryptionService = require('../../../common/services/encryptionService'); -const userPresetConverter = createEncryptedConverter(['prompt']); +const userPresetConverter = createEncryptedConverter(['prompt', 'title']); const defaultPresetConverter = { toFirestore: (data) => data, @@ -12,13 +14,13 @@ const defaultPresetConverter = { }; function userPresetsCol() { - const db = getFirestore(); + const db = getFirestoreInstance(); return collection(db, 'prompt_presets').withConverter(userPresetConverter); } function defaultPresetsCol() { - const db = getFirestore(); - return collection(db, 'defaults/prompt_presets').withConverter(defaultPresetConverter); + const db = getFirestoreInstance(); + return collection(db, 'defaults/v1/prompt_presets').withConverter(defaultPresetConverter); } async function getPresets(uid) { @@ -54,7 +56,7 @@ async function createPreset({ uid, title, prompt }) { uid: uid, title, prompt, - is_default: false, + is_default: 0, created_at: now, }; const docRef = await addDoc(userPresetsCol(), newPreset); @@ -68,8 +70,17 @@ async function updatePreset(id, { title, prompt }, uid) { if (!docSnap.exists() || docSnap.data().uid !== uid || docSnap.data().is_default) { throw new Error("Preset not found or permission denied to update."); } + + const updates = {}; + if (title !== undefined) { + updates.title = encryptionService.encrypt(title); + } + if (prompt !== undefined) { + updates.prompt = encryptionService.encrypt(prompt); + } + updates.updated_at = Math.floor(Date.now() / 1000); - await updateDoc(docRef, { title, prompt }); + await updateDoc(docRef, updates); return { changes: 1 }; } @@ -87,7 +98,7 @@ async function deletePreset(id, uid) { async function getAutoUpdate(uid) { // Assume users are stored in a "users" collection, and auto_update_enabled is a field - const userDocRef = doc(getFirestore(), 'users', uid); + const userDocRef = doc(getFirestoreInstance(), 'users', uid); try { const userSnap = await getDoc(userDocRef); if (userSnap.exists()) { @@ -110,7 +121,7 @@ async function getAutoUpdate(uid) { } async function setAutoUpdate(uid, isEnabled) { - const userDocRef = doc(getFirestore(), 'users', uid); + const userDocRef = doc(getFirestoreInstance(), 'users', uid); try { const userSnap = await getDoc(userDocRef); if (userSnap.exists()) { diff --git a/src/index.js b/src/index.js index 378bd6a..3bf273d 100644 --- a/src/index.js +++ b/src/index.js @@ -187,8 +187,8 @@ app.whenReady().then(async () => { await databaseInitializer.initialize(); console.log('>>> [index.js] Database initialized successfully'); - // Clean up zombie sessions from previous runs first - sessionRepository.endAllActiveSessions(); + // Clean up zombie sessions from previous runs first - MOVED TO authService + // sessionRepository.endAllActiveSessions(); await authService.initialize(); @@ -237,7 +237,7 @@ app.on('window-all-closed', () => { app.on('before-quit', async () => { console.log('[Shutdown] App is about to quit.'); listenService.stopMacOSAudioCapture(); - await sessionRepository.endAllActiveSessions(); + // await sessionRepository.endAllActiveSessions(); // MOVED TO authService databaseInitializer.close(); });