diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..643845a --- /dev/null +++ b/PLAN.md @@ -0,0 +1,51 @@ +# 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/package-lock.json b/package-lock.json index 944e413..1d1839e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "firebase": "^11.10.0", "firebase-admin": "^13.4.0", "jsonwebtoken": "^9.0.2", + "keytar": "^7.9.0", "node-fetch": "^2.7.0", "openai": "^4.70.0", "react-hot-toast": "^2.5.2", @@ -8090,6 +8091,23 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/keytar": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/keytar/-/keytar-7.9.0.tgz", + "integrity": "sha512-VPD8mtVtm5JNtA2AErl6Chp06JBfy7diFQ7TQQhdpWOl6MrCRB+eRbvAZUsbGQS9kiMq0coJsy0W0vHpDCkWsQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^4.3.0", + "prebuild-install": "^7.0.1" + } + }, + "node_modules/keytar/node_modules/node-addon-api": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-4.3.0.tgz", + "integrity": "sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ==", + "license": "MIT" + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", diff --git a/package.json b/package.json index 84777a5..92beb67 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,7 @@ { "name": "pickle-glass", "productName": "Glass", - "version": "0.2.2", - "description": "Cl*ely for Free", "main": "src/index.js", "scripts": { @@ -48,6 +46,7 @@ "firebase": "^11.10.0", "firebase-admin": "^13.4.0", "jsonwebtoken": "^9.0.2", + "keytar": "^7.9.0", "node-fetch": "^2.7.0", "openai": "^4.70.0", "react-hot-toast": "^2.5.2", diff --git a/repository_api_report.md b/repository_api_report.md new file mode 100644 index 0000000..a9a6231 --- /dev/null +++ b/repository_api_report.md @@ -0,0 +1,77 @@ +# 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/repositories/firestoreConverter.js b/src/common/repositories/firestoreConverter.js new file mode 100644 index 0000000..cb1532c --- /dev/null +++ b/src/common/repositories/firestoreConverter.js @@ -0,0 +1,45 @@ +const encryptionService = require('../services/encryptionService'); + +/** + * Creates a Firestore converter that automatically encrypts and decrypts specified fields. + * @param {string[]} fieldsToEncrypt - An array of field names to encrypt. + * @returns {import('@firebase/firestore').FirestoreDataConverter} A Firestore converter. + * @template T + */ +function createEncryptedConverter(fieldsToEncrypt = []) { + return { + /** + * @param {import('@firebase/firestore').DocumentData} appObject + */ + toFirestore: (appObject) => { + const firestoreData = { ...appObject }; + for (const field of fieldsToEncrypt) { + if (Object.prototype.hasOwnProperty.call(firestoreData, field) && firestoreData[field] != null) { + firestoreData[field] = encryptionService.encrypt(firestoreData[field]); + } + } + // Ensure there's a timestamp for the last modification + firestoreData.updated_at = Math.floor(Date.now() / 1000); + return firestoreData; + }, + /** + * @param {import('@firebase/firestore').QueryDocumentSnapshot} snapshot + * @param {import('@firebase/firestore').SnapshotOptions} options + */ + fromFirestore: (snapshot, options) => { + const firestoreData = snapshot.data(options); + const appObject = { ...firestoreData, id: snapshot.id }; // include the document ID + + for (const field of fieldsToEncrypt) { + if (Object.prototype.hasOwnProperty.call(appObject, field) && appObject[field] != null) { + appObject[field] = encryptionService.decrypt(appObject[field]); + } + } + return appObject; + } + }; +} + +module.exports = { + createEncryptedConverter, +}; \ No newline at end of file diff --git a/src/common/repositories/preset/firebase.repository.js b/src/common/repositories/preset/firebase.repository.js new file mode 100644 index 0000000..3baee39 --- /dev/null +++ b/src/common/repositories/preset/firebase.repository.js @@ -0,0 +1,94 @@ +const { getFirestore, collection, doc, addDoc, getDoc, getDocs, updateDoc, deleteDoc, query, where, orderBy } = require('firebase/firestore'); +const { createEncryptedConverter } = require('../firestoreConverter'); + +const userPresetConverter = createEncryptedConverter(['prompt', 'title']); + +const defaultPresetConverter = { + toFirestore: (data) => data, + fromFirestore: (snapshot, options) => { + const data = snapshot.data(options); + return { ...data, id: snapshot.id }; + } +}; + +function userPresetsCol() { + const db = getFirestore(); + return collection(db, 'prompt_presets').withConverter(userPresetConverter); +} + +function defaultPresetsCol() { + const db = getFirestore(); + return collection(db, 'defaults/prompt_presets').withConverter(defaultPresetConverter); +} + +async function getPresets(uid) { + const userPresetsQuery = query(userPresetsCol(), where('uid', '==', uid)); + const defaultPresetsQuery = query(defaultPresetsCol()); // Defaults have no owner + + const [userSnapshot, defaultSnapshot] = await Promise.all([ + getDocs(userPresetsQuery), + getDocs(defaultPresetsQuery) + ]); + + const presets = [ + ...defaultSnapshot.docs.map(d => d.data()), + ...userSnapshot.docs.map(d => d.data()) + ]; + + return presets.sort((a, b) => { + if (a.is_default && !b.is_default) return -1; + if (!a.is_default && b.is_default) return 1; + return a.title.localeCompare(b.title); + }); +} + +async function getPresetTemplates() { + const q = query(defaultPresetsCol(), orderBy('title', 'asc')); + const snapshot = await getDocs(q); + return snapshot.docs.map(doc => doc.data()); +} + +async function create({ uid, title, prompt }) { + const now = Math.floor(Date.now() / 1000); + const newPreset = { + uid: uid, + title, + prompt, + is_default: false, + created_at: now, + }; + const docRef = await addDoc(userPresetsCol(), newPreset); + return { id: docRef.id }; +} + +async function update(id, { title, prompt }, uid) { + const docRef = doc(userPresetsCol(), id); + const docSnap = await getDoc(docRef); + + 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 }); + return { changes: 1 }; +} + +async function del(id, uid) { + const docRef = doc(userPresetsCol(), id); + const docSnap = await getDoc(docRef); + + if (!docSnap.exists() || docSnap.data().uid !== uid || docSnap.data().is_default) { + throw new Error("Preset not found or permission denied to delete."); + } + + await deleteDoc(docRef); + return { changes: 1 }; +} + +module.exports = { + getPresets, + getPresetTemplates, + create, + update, + delete: del, +}; \ No newline at end of file diff --git a/src/common/repositories/preset/index.js b/src/common/repositories/preset/index.js index ebf7f77..ac61784 100644 --- a/src/common/repositories/preset/index.js +++ b/src/common/repositories/preset/index.js @@ -1,19 +1,39 @@ const sqliteRepository = require('./sqlite.repository'); -// const firebaseRepository = require('./firebase.repository'); +const firebaseRepository = require('./firebase.repository'); const authService = require('../../../common/services/authService'); -function getRepository() { - // const user = authService.getCurrentUser(); - // if (user.isLoggedIn) { - // return firebaseRepository; - // } +function getBaseRepository() { + const user = authService.getCurrentUser(); + if (user && user.isLoggedIn) { + return firebaseRepository; + } return sqliteRepository; } -module.exports = { - getPresets: (...args) => getRepository().getPresets(...args), - getPresetTemplates: (...args) => getRepository().getPresetTemplates(...args), - create: (...args) => getRepository().create(...args), - update: (...args) => getRepository().update(...args), - delete: (...args) => getRepository().delete(...args), -}; \ No newline at end of file +const presetRepositoryAdapter = { + getPresets: () => { + const uid = authService.getCurrentUserId(); + return getBaseRepository().getPresets(uid); + }, + + getPresetTemplates: () => { + return getBaseRepository().getPresetTemplates(); + }, + + create: (options) => { + const uid = authService.getCurrentUserId(); + return getBaseRepository().create({ uid, ...options }); + }, + + update: (id, options) => { + const uid = authService.getCurrentUserId(); + return getBaseRepository().update(id, options, uid); + }, + + delete: (id) => { + const uid = authService.getCurrentUserId(); + return getBaseRepository().delete(id, uid); + }, +}; + +module.exports = presetRepositoryAdapter; \ No newline at end of file diff --git a/src/common/repositories/session/firebase.repository.js b/src/common/repositories/session/firebase.repository.js new file mode 100644 index 0000000..00e1411 --- /dev/null +++ b/src/common/repositories/session/firebase.repository.js @@ -0,0 +1,155 @@ +const { getFirestore, doc, getDoc, collection, addDoc, query, where, getDocs, writeBatch, orderBy, limit, runTransaction, updateDoc } = require('firebase/firestore'); +const { createEncryptedConverter } = require('../firestoreConverter'); + +const sessionConverter = createEncryptedConverter(['title']); + +function sessionsCol() { + const db = getFirestore(); + return collection(db, 'sessions').withConverter(sessionConverter); +} + +// Sub-collection references are now built from the top-level +function subCollections(sessionId) { + const db = getFirestore(); + const sessionPath = `sessions/${sessionId}`; + return { + transcripts: collection(db, `${sessionPath}/transcripts`), + ai_messages: collection(db, `${sessionPath}/ai_messages`), + summary: collection(db, `${sessionPath}/summary`), + } +} + +async function getById(id) { + const docRef = doc(sessionsCol(), id); + const docSnap = await getDoc(docRef); + return docSnap.exists() ? docSnap.data() : null; +} + +async function create(uid, type = 'ask') { + const now = Math.floor(Date.now() / 1000); + const newSession = { + uid: uid, + members: [uid], // For future sharing functionality + title: `Session @ ${new Date().toLocaleTimeString()}`, + session_type: type, + started_at: now, + updated_at: now, + ended_at: null, + }; + const docRef = await addDoc(sessionsCol(), newSession); + console.log(`Firebase: Created session ${docRef.id} for user ${uid}`); + return docRef.id; +} + +async function getAllByUserId(uid) { + const q = query(sessionsCol(), where('members', 'array-contains', uid), orderBy('started_at', 'desc')); + const querySnapshot = await getDocs(q); + return querySnapshot.docs.map(doc => doc.data()); +} + +async function updateTitle(id, title) { + const docRef = doc(sessionsCol(), id); + await updateDoc(docRef, { title }); + return { changes: 1 }; +} + +async function deleteWithRelatedData(id) { + const db = getFirestore(); + const batch = writeBatch(db); + + const { transcripts, ai_messages, summary } = subCollections(id); + const [transcriptsSnap, aiMessagesSnap, summarySnap] = await Promise.all([ + getDocs(query(transcripts)), + getDocs(query(ai_messages)), + getDocs(query(summary)), + ]); + + transcriptsSnap.forEach(d => batch.delete(d.ref)); + aiMessagesSnap.forEach(d => batch.delete(d.ref)); + summarySnap.forEach(d => batch.delete(d.ref)); + + const sessionRef = doc(sessionsCol(), id); + batch.delete(sessionRef); + + await batch.commit(); + return { success: true }; +} + +async function end(id) { + const docRef = doc(sessionsCol(), id); + await updateDoc(docRef, { ended_at: Math.floor(Date.now() / 1000) }); + return { changes: 1 }; +} + +async function updateType(id, type) { + const docRef = doc(sessionsCol(), id); + await updateDoc(docRef, { session_type: type }); + return { changes: 1 }; +} + +async function touch(id) { + const docRef = doc(sessionsCol(), id); + await updateDoc(docRef, { updated_at: Math.floor(Date.now() / 1000) }); + return { changes: 1 }; +} + +async function getOrCreateActive(uid, requestedType = 'ask') { + const findQuery = query( + sessionsCol(), + where('uid', '==', uid), + where('ended_at', '==', null), + orderBy('session_type', 'desc'), + limit(1) + ); + + const activeSessionSnap = await getDocs(findQuery); + + if (!activeSessionSnap.empty) { + const activeSessionDoc = activeSessionSnap.docs[0]; + const sessionRef = doc(sessionsCol(), activeSessionDoc.id); + const activeSession = activeSessionDoc.data(); + + console.log(`[Repo] Found active Firebase session ${activeSession.id}`); + + const updates = { updated_at: Math.floor(Date.now() / 1000) }; + if (activeSession.session_type === 'ask' && requestedType === 'listen') { + updates.session_type = 'listen'; + console.log(`[Repo] Promoted Firebase session ${activeSession.id} to 'listen' type.`); + } + + await updateDoc(sessionRef, updates); + return activeSessionDoc.id; + } else { + console.log(`[Repo] No active Firebase session for user ${uid}. Creating new.`); + return create(uid, requestedType); + } +} + +async function endAllActiveSessions(uid) { + const q = query(sessionsCol(), where('uid', '==', uid), where('ended_at', '==', null)); + const snapshot = await getDocs(q); + + if (snapshot.empty) return { changes: 0 }; + + const batch = writeBatch(getFirestore()); + snapshot.forEach(d => { + batch.update(d.ref, { ended_at: Math.floor(Date.now() / 1000) }); + }); + await batch.commit(); + + console.log(`[Repo] Ended ${snapshot.size} active session(s) for user ${uid}.`); + return { changes: snapshot.size }; +} + +module.exports = { + getById, + create, + getAllByUserId, + updateTitle, + deleteWithRelatedData, + end, + updateType, + touch, + getOrCreateActive, + endAllActiveSessions, +}; \ No newline at end of file diff --git a/src/common/repositories/session/index.js b/src/common/repositories/session/index.js index 42e7ec0..4f7b540 100644 --- a/src/common/repositories/session/index.js +++ b/src/common/repositories/session/index.js @@ -1,26 +1,48 @@ const sqliteRepository = require('./sqlite.repository'); -// const firebaseRepository = require('./firebase.repository'); // Future implementation +const firebaseRepository = require('./firebase.repository'); const authService = require('../../../common/services/authService'); -function getRepository() { - // In the future, we can check the user's login status from authService - // const user = authService.getCurrentUser(); - // if (user.isLoggedIn) { - // return firebaseRepository; - // } +function getBaseRepository() { + const user = authService.getCurrentUser(); + if (user && user.isLoggedIn) { + return firebaseRepository; + } return sqliteRepository; } -// Directly export functions for ease of use, decided by the strategy -module.exports = { - getById: (...args) => getRepository().getById(...args), - create: (...args) => getRepository().create(...args), - getAllByUserId: (...args) => getRepository().getAllByUserId(...args), - updateTitle: (...args) => getRepository().updateTitle(...args), - deleteWithRelatedData: (...args) => getRepository().deleteWithRelatedData(...args), - end: (...args) => getRepository().end(...args), - updateType: (...args) => getRepository().updateType(...args), - touch: (...args) => getRepository().touch(...args), - getOrCreateActive: (...args) => getRepository().getOrCreateActive(...args), - endAllActiveSessions: (...args) => getRepository().endAllActiveSessions(...args), -}; \ No newline at end of file +// The adapter layer that injects the UID +const sessionRepositoryAdapter = { + getById: (id) => getBaseRepository().getById(id), + + create: (type = 'ask') => { + const uid = authService.getCurrentUserId(); + return getBaseRepository().create(uid, type); + }, + + getAllByUserId: () => { + const uid = authService.getCurrentUserId(); + return getBaseRepository().getAllByUserId(uid); + }, + + updateTitle: (id, title) => getBaseRepository().updateTitle(id, title), + + deleteWithRelatedData: (id) => getBaseRepository().deleteWithRelatedData(id), + + end: (id) => getBaseRepository().end(id), + + updateType: (id, type) => getBaseRepository().updateType(id, type), + + touch: (id) => getBaseRepository().touch(id), + + getOrCreateActive: (requestedType = 'ask') => { + const uid = authService.getCurrentUserId(); + return getBaseRepository().getOrCreateActive(uid, requestedType); + }, + + endAllActiveSessions: () => { + const uid = authService.getCurrentUserId(); + return getBaseRepository().endAllActiveSessions(uid); + }, +}; + +module.exports = sessionRepositoryAdapter; \ No newline at end of file diff --git a/src/common/repositories/session/sqlite.repository.js b/src/common/repositories/session/sqlite.repository.js index 509ba98..c3c9f9c 100644 --- a/src/common/repositories/session/sqlite.repository.js +++ b/src/common/repositories/session/sqlite.repository.js @@ -108,14 +108,15 @@ function getOrCreateActive(uid, requestedType = 'ask') { } } -function endAllActiveSessions() { +function endAllActiveSessions(uid) { const db = sqliteClient.getDb(); const now = Math.floor(Date.now() / 1000); - const query = `UPDATE sessions SET ended_at = ?, updated_at = ? WHERE ended_at IS NULL`; + // Filter by uid to match the Firebase repository's behavior. + const query = `UPDATE sessions SET ended_at = ?, updated_at = ? WHERE ended_at IS NULL AND uid = ?`; try { - const result = db.prepare(query).run(now, now); - console.log(`[Repo] Ended ${result.changes} active session(s).`); + const result = db.prepare(query).run(now, now, uid); + console.log(`[Repo] Ended ${result.changes} active SQLite session(s) for user ${uid}.`); return { changes: result.changes }; } catch (err) { console.error('SQLite: Failed to end all active sessions:', err); diff --git a/src/common/repositories/user/firebase.repository.js b/src/common/repositories/user/firebase.repository.js new file mode 100644 index 0000000..c8a7b3f --- /dev/null +++ b/src/common/repositories/user/firebase.repository.js @@ -0,0 +1,89 @@ +const { getFirestore, doc, getDoc, setDoc, deleteDoc, writeBatch, query, where, getDocs, collection } = require('firebase/firestore'); +const { createEncryptedConverter } = require('../firestoreConverter'); + +const userConverter = createEncryptedConverter(['api_key']); + +function usersCol() { + const db = getFirestore(); + return collection(db, 'users').withConverter(userConverter); +} + +// These functions are mostly correct as they already operate on a top-level collection. +// We just need to ensure the signatures are consistent. + +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 docRef = doc(usersCol(), uid); + const docSnap = await getDoc(docRef); + + if (docSnap.exists()) { + await setDoc(docRef, { + display_name: displayName || docSnap.data().display_name || 'User', + email: email || docSnap.data().email || 'no-email@example.com' + }, { merge: true }); + } else { + await setDoc(docRef, { uid, display_name: displayName || 'User', email: email || 'no-email@example.com', created_at: now }); + } + const finalDoc = await getDoc(docRef); + return finalDoc.data(); +} + +async function getById(uid) { + const docRef = doc(usersCol(), uid); + const docSnap = await getDoc(docRef); + return docSnap.exists() ? docSnap.data() : null; +} + +async function saveApiKey(uid, apiKey, provider = 'openai') { + const docRef = doc(usersCol(), uid); + await setDoc(docRef, { api_key: apiKey, provider }, { merge: true }); + return { changes: 1 }; +} + +async function update({ uid, displayName }) { + const docRef = doc(usersCol(), uid); + await setDoc(docRef, { display_name: displayName }, { merge: true }); + return { changes: 1 }; +} + +async function deleteById(uid) { + const db = getFirestore(); + const batch = writeBatch(db); + + // 1. Delete all sessions owned by the user + const sessionsQuery = query(collection(db, 'sessions'), where('uid', '==', uid)); + const sessionsSnapshot = await getDocs(sessionsQuery); + + for (const sessionDoc of sessionsSnapshot.docs) { + // Recursively delete sub-collections + const subcollectionsToDelete = ['transcripts', 'ai_messages', 'summary']; + for (const sub of subcollectionsToDelete) { + const subColPath = `sessions/${sessionDoc.id}/${sub}`; + const subSnapshot = await getDocs(query(collection(db, subColPath))); + subSnapshot.forEach(d => batch.delete(d.ref)); + } + batch.delete(sessionDoc.ref); + } + + // 2. Delete all presets owned by the user + const presetsQuery = query(collection(db, 'prompt_presets'), where('uid', '==', uid)); + const presetsSnapshot = await getDocs(presetsQuery); + presetsSnapshot.forEach(doc => batch.delete(doc.ref)); + + // 3. Delete the user document itself + const userRef = doc(usersCol(), uid); + batch.delete(userRef); + + await batch.commit(); + return { success: true }; +} + +module.exports = { + findOrCreate, + getById, + saveApiKey, + update, + deleteById, +}; \ No newline at end of file diff --git a/src/common/repositories/user/index.js b/src/common/repositories/user/index.js index 9455ef8..6c90a87 100644 --- a/src/common/repositories/user/index.js +++ b/src/common/repositories/user/index.js @@ -1,19 +1,40 @@ const sqliteRepository = require('./sqlite.repository'); -// const firebaseRepository = require('./firebase.repository'); -const authService = require('../../../common/services/authService'); +const firebaseRepository = require('./firebase.repository'); +const authService = require('../../services/authService'); -function getRepository() { - // const user = authService.getCurrentUser(); - // if (user.isLoggedIn) { - // return firebaseRepository; - // } +function getBaseRepository() { + const user = authService.getCurrentUser(); + if (user && user.isLoggedIn) { + return firebaseRepository; + } return sqliteRepository; } -module.exports = { - findOrCreate: (...args) => getRepository().findOrCreate(...args), - getById: (...args) => getRepository().getById(...args), - saveApiKey: (...args) => getRepository().saveApiKey(...args), - update: (...args) => getRepository().update(...args), - deleteById: (...args) => getRepository().deleteById(...args), -}; \ No newline at end of file +const userRepositoryAdapter = { + findOrCreate: (user) => { + // This function receives the full user object, which includes the uid. No need to inject. + return getBaseRepository().findOrCreate(user); + }, + + getById: () => { + const uid = authService.getCurrentUserId(); + return getBaseRepository().getById(uid); + }, + + saveApiKey: (apiKey, provider) => { + const uid = authService.getCurrentUserId(); + return getBaseRepository().saveApiKey(uid, apiKey, provider); + }, + + update: (updateData) => { + const uid = authService.getCurrentUserId(); + return getBaseRepository().update({ uid, ...updateData }); + }, + + deleteById: () => { + const uid = authService.getCurrentUserId(); + return getBaseRepository().deleteById(uid); + } +}; + +module.exports = userRepositoryAdapter; \ No newline at end of file diff --git a/src/common/repositories/user/sqlite.repository.js b/src/common/repositories/user/sqlite.repository.js index 7185c81..556fa40 100644 --- a/src/common/repositories/user/sqlite.repository.js +++ b/src/common/repositories/user/sqlite.repository.js @@ -40,7 +40,7 @@ function getById(uid) { return db.prepare('SELECT * FROM users WHERE uid = ?').get(uid); } -function saveApiKey(apiKey, uid, provider = 'openai') { +function saveApiKey(uid, apiKey, provider = 'openai') { const db = sqliteClient.getDb(); try { const result = db.prepare('UPDATE users SET api_key = ?, provider = ? WHERE uid = ?').run(apiKey, provider, uid); diff --git a/src/common/services/authService.js b/src/common/services/authService.js index a664ba1..641a880 100644 --- a/src/common/services/authService.js +++ b/src/common/services/authService.js @@ -1,8 +1,8 @@ const { onAuthStateChanged, signInWithCustomToken, signOut } = require('firebase/auth'); const { BrowserWindow } = require('electron'); const { getFirebaseAuth } = require('./firebaseClient'); -const userRepository = require('../repositories/user'); const fetch = require('node-fetch'); +const encryptionService = require('./encryptionService'); async function getVirtualKeyByEmail(email, idToken) { if (!idToken) { @@ -37,6 +37,10 @@ class AuthService { this.currentUserMode = 'local'; // 'local' or 'firebase' this.currentUser = null; this.isInitialized = false; + + // Initialize immediately for the default local user on startup. + // This ensures the key is ready before any login/logout state change. + encryptionService.initializeKey(this.currentUserId); } initialize() { @@ -53,6 +57,10 @@ class AuthService { this.currentUserId = user.uid; this.currentUserMode = 'firebase'; + // ** Initialize encryption key for the logged-in user ** + await encryptionService.initializeKey(user.uid); + + // Start background task to fetch and save virtual key (async () => { try { @@ -81,6 +89,9 @@ class AuthService { this.currentUser = null; this.currentUserId = 'default_user'; this.currentUserMode = 'local'; + + // ** Initialize encryption key for the default/local user ** + await encryptionService.initializeKey(this.currentUserId); } this.broadcastUserState(); }); diff --git a/src/common/services/encryptionService.js b/src/common/services/encryptionService.js new file mode 100644 index 0000000..7b3d419 --- /dev/null +++ b/src/common/services/encryptionService.js @@ -0,0 +1,140 @@ +const crypto = require('crypto'); +let keytar; + +// Dynamically import keytar, as it's an optional dependency. +try { + keytar = require('keytar'); +} catch (error) { + console.warn('[EncryptionService] keytar is not available. Will use in-memory key for this session. Restarting the app might be required for data persistence after login.'); + keytar = null; +} + +const SERVICE_NAME = 'com.pickle.glass'; // A unique identifier for the app in the keychain +let sessionKey = null; // In-memory fallback key + +const ALGORITHM = 'aes-256-gcm'; +const IV_LENGTH = 16; // For AES, this is always 16 +const AUTH_TAG_LENGTH = 16; + + +/** + * Initializes the encryption key for a given user. + * It first tries to get the key from the OS keychain. + * If that fails, it generates a new key. + * If keytar is available, it saves the new key. + * Otherwise, it uses an in-memory key for the session. + * + * @param {string} userId - The unique identifier for the user (e.g., Firebase UID). + */ +async function initializeKey(userId) { + if (!userId) { + throw new Error('A user ID must be provided to initialize the encryption key.'); + } + + if (keytar) { + try { + let key = await keytar.getPassword(SERVICE_NAME, userId); + if (!key) { + console.log(`[EncryptionService] No key found for ${userId}. Creating a new one.`); + key = crypto.randomBytes(32).toString('hex'); + await keytar.setPassword(SERVICE_NAME, userId, key); + console.log(`[EncryptionService] New key securely stored in keychain for ${userId}.`); + } else { + console.log(`[EncryptionService] Encryption key successfully retrieved from keychain for ${userId}.`); + } + sessionKey = key; + } catch (error) { + console.error('[EncryptionService] keytar failed. Falling back to in-memory key for this session.', error); + keytar = null; // Disable keytar for the rest of the session to avoid repeated errors + sessionKey = crypto.randomBytes(32).toString('hex'); + } + } else { + // keytar is not available + if (!sessionKey) { + console.warn('[EncryptionService] Using in-memory session key. Data will not persist across restarts without keytar.'); + sessionKey = crypto.randomBytes(32).toString('hex'); + } + } + + if (!sessionKey) { + throw new Error('Failed to initialize encryption key.'); + } +} + +/** + * Encrypts a given text using AES-256-GCM. + * @param {string} text The text to encrypt. + * @returns {string | null} The encrypted data, as a base64 string containing iv, authTag, and content, or the original value if it cannot be encrypted. + */ +function encrypt(text) { + if (!sessionKey) { + console.error('[EncryptionService] Encryption key is not initialized. Cannot encrypt.'); + return text; // Return original if key is missing + } + if (text == null) { // checks for null or undefined + return text; + } + + try { + const key = Buffer.from(sessionKey, 'hex'); + const iv = crypto.randomBytes(IV_LENGTH); + const cipher = crypto.createCipheriv(ALGORITHM, key, iv); + + let encrypted = cipher.update(String(text), 'utf8', 'hex'); + encrypted += cipher.final('hex'); + + const authTag = cipher.getAuthTag(); + + // Prepend IV and AuthTag to the encrypted content, then encode as base64. + return Buffer.concat([iv, authTag, Buffer.from(encrypted, 'hex')]).toString('base64'); + } catch (error) { + console.error('[EncryptionService] Encryption failed:', error); + return text; // Return original on error + } +} + +/** + * Decrypts a given encrypted string. + * @param {string} encryptedText The base64 encrypted text. + * @returns {string | null} The decrypted text, or the original value if it cannot be decrypted. + */ +function decrypt(encryptedText) { + if (!sessionKey) { + console.error('[EncryptionService] Encryption key is not initialized. Cannot decrypt.'); + return encryptedText; // Return original if key is missing + } + if (encryptedText == null || typeof encryptedText !== 'string') { + return encryptedText; + } + + try { + const data = Buffer.from(encryptedText, 'base64'); + if (data.length < IV_LENGTH + AUTH_TAG_LENGTH) { + // This is not a valid encrypted string, likely plain text. + return encryptedText; + } + + const key = Buffer.from(sessionKey, 'hex'); + const iv = data.slice(0, IV_LENGTH); + const authTag = data.slice(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH); + const encryptedContent = data.slice(IV_LENGTH + AUTH_TAG_LENGTH); + + const decipher = crypto.createDecipheriv(ALGORITHM, key, iv); + decipher.setAuthTag(authTag); + + let decrypted = decipher.update(encryptedContent, 'hex', 'utf8'); + decrypted += decipher.final('utf8'); + + return decrypted; + } catch (error) { + // It's common for this to fail if the data is not encrypted (e.g., legacy data). + // In that case, we return the original value. + return encryptedText; + } +} + +module.exports = { + initializeKey, + encrypt, + decrypt, +}; \ No newline at end of file diff --git a/src/common/services/firebaseClient.js b/src/common/services/firebaseClient.js index 5ccb6d5..eee7cfe 100644 --- a/src/common/services/firebaseClient.js +++ b/src/common/services/firebaseClient.js @@ -1,6 +1,9 @@ const { initializeApp } = require('firebase/app'); const { initializeAuth } = require('firebase/auth'); const Store = require('electron-store'); +const { setLogLevel } = require('firebase/firestore'); + +setLogLevel('debug'); /** * Firebase Auth expects the `persistence` option passed to `initializeAuth()` to be *classes*, diff --git a/src/features/ask/askService.js b/src/features/ask/askService.js index b59b65d..dcfc8b8 100644 --- a/src/features/ask/askService.js +++ b/src/features/ask/askService.js @@ -101,9 +101,8 @@ async function sendMessage(userPrompt) { // Save to DB try { - const uid = authService.getCurrentUserId(); - if (!uid) throw new Error("User not logged in, cannot save message."); - const sessionId = await sessionRepository.getOrCreateActive(uid, 'ask'); + // The repository adapter will now handle the UID internally. + const sessionId = await sessionRepository.getOrCreateActive('ask'); await askRepository.addAiMessage({ sessionId, role: 'user', content: userPrompt.trim() }); await askRepository.addAiMessage({ sessionId, role: 'assistant', content: fullResponse }); console.log(`[AskService] DB: Saved ask/answer pair to session ${sessionId}`); diff --git a/src/features/ask/repositories/firebase.repository.js b/src/features/ask/repositories/firebase.repository.js new file mode 100644 index 0000000..a8148de --- /dev/null +++ b/src/features/ask/repositories/firebase.repository.js @@ -0,0 +1,37 @@ +const { getFirestore, collection, addDoc, query, getDocs, orderBy } = require('firebase/firestore'); +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(); + 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 newMessage = { + uid, // To identify the author of the message + session_id: sessionId, + sent_at: now, + role, + content, + model, + created_at: now, + }; + + const docRef = await addDoc(aiMessagesCol(sessionId), newMessage); + return { id: docRef.id }; +} + +async function getAllAiMessagesBySessionId(sessionId) { + const q = query(aiMessagesCol(sessionId), orderBy('sent_at', 'asc')); + const querySnapshot = await getDocs(q); + return querySnapshot.docs.map(doc => doc.data()); +} + +module.exports = { + addAiMessage, + getAllAiMessagesBySessionId, +}; \ No newline at end of file diff --git a/src/features/ask/repositories/index.js b/src/features/ask/repositories/index.js index 182d066..f9fdda1 100644 --- a/src/features/ask/repositories/index.js +++ b/src/features/ask/repositories/index.js @@ -1,18 +1,25 @@ const sqliteRepository = require('./sqlite.repository'); -// const firebaseRepository = require('./firebase.repository'); // Future implementation +const firebaseRepository = require('./firebase.repository'); const authService = require('../../../common/services/authService'); -function getRepository() { - // In the future, we can check the user's login status from authService - // const user = authService.getCurrentUser(); - // if (user.isLoggedIn) { - // return firebaseRepository; - // } +function getBaseRepository() { + const user = authService.getCurrentUser(); + if (user && user.isLoggedIn) { + return firebaseRepository; + } return sqliteRepository; } -// Directly export functions for ease of use, decided by the strategy -module.exports = { - addAiMessage: (...args) => getRepository().addAiMessage(...args), - getAllAiMessagesBySessionId: (...args) => getRepository().getAllAiMessagesBySessionId(...args), -}; \ No newline at end of file +// The adapter layer that injects the UID +const askRepositoryAdapter = { + addAiMessage: ({ sessionId, role, content, model }) => { + const uid = authService.getCurrentUserId(); + return getBaseRepository().addAiMessage({ uid, sessionId, role, content, model }); + }, + getAllAiMessagesBySessionId: (sessionId) => { + // This function does not require a UID at the service level. + return getBaseRepository().getAllAiMessagesBySessionId(sessionId); + } +}; + +module.exports = askRepositoryAdapter; \ No newline at end of file diff --git a/src/features/ask/repositories/sqlite.repository.js b/src/features/ask/repositories/sqlite.repository.js index bbab3fb..19962eb 100644 --- a/src/features/ask/repositories/sqlite.repository.js +++ b/src/features/ask/repositories/sqlite.repository.js @@ -1,6 +1,7 @@ const sqliteClient = require('../../../common/services/sqliteClient'); -function addAiMessage({ sessionId, role, content, model = 'gpt-4.1' }) { +function addAiMessage({ uid, sessionId, role, content, model = 'unknown' }) { + // uid is ignored in the SQLite implementation const db = sqliteClient.getDb(); const messageId = require('crypto').randomUUID(); const now = Math.floor(Date.now() / 1000); diff --git a/src/features/listen/listenService.js b/src/features/listen/listenService.js index 4218745..f676d93 100644 --- a/src/features/listen/listenService.js +++ b/src/features/listen/listenService.js @@ -77,12 +77,15 @@ class ListenService { async initializeNewSession() { try { - const uid = authService.getCurrentUserId(); - if (!uid) { - throw new Error("Cannot initialize session: user not logged in."); + // The UID is no longer passed to the repository method directly. + // The adapter layer handles UID injection. We just ensure a user is available. + const user = authService.getCurrentUser(); + if (!user) { + // This case should ideally not happen as authService initializes a default user. + throw new Error("Cannot initialize session: auth service not ready."); } - this.currentSessionId = await sessionRepository.getOrCreateActive(uid, 'listen'); + this.currentSessionId = await sessionRepository.getOrCreateActive('listen'); console.log(`[DB] New listen session ensured: ${this.currentSessionId}`); // Set session ID for summary service diff --git a/src/features/listen/stt/repositories/firebase.repository.js b/src/features/listen/stt/repositories/firebase.repository.js new file mode 100644 index 0000000..a79954a --- /dev/null +++ b/src/features/listen/stt/repositories/firebase.repository.js @@ -0,0 +1,35 @@ +const { getFirestore, collection, addDoc, query, getDocs, orderBy } = require('firebase/firestore'); +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(); + return collection(db, `sessions/${sessionId}/transcripts`).withConverter(transcriptConverter); +} + +async function addTranscript({ uid, sessionId, speaker, text }) { + const now = Math.floor(Date.now() / 1000); + const newTranscript = { + uid, // To identify the author/source of the transcript + session_id: sessionId, + start_at: now, + speaker, + text, + created_at: now, + }; + const docRef = await addDoc(transcriptsCol(sessionId), newTranscript); + return { id: docRef.id }; +} + +async function getAllTranscriptsBySessionId(sessionId) { + const q = query(transcriptsCol(sessionId), orderBy('start_at', 'asc')); + const querySnapshot = await getDocs(q); + return querySnapshot.docs.map(doc => doc.data()); +} + +module.exports = { + addTranscript, + getAllTranscriptsBySessionId, +}; \ No newline at end of file diff --git a/src/features/listen/stt/repositories/index.js b/src/features/listen/stt/repositories/index.js index 6de1a98..8554bc5 100644 --- a/src/features/listen/stt/repositories/index.js +++ b/src/features/listen/stt/repositories/index.js @@ -1,5 +1,23 @@ -const sttRepository = require('./sqlite.repository'); +const sqliteRepository = require('./sqlite.repository'); +const firebaseRepository = require('./firebase.repository'); +const authService = require('../../../../common/services/authService'); -module.exports = { - ...sttRepository, -}; \ No newline at end of file +function getBaseRepository() { + const user = authService.getCurrentUser(); + if (user && user.isLoggedIn) { + return firebaseRepository; + } + return sqliteRepository; +} + +const sttRepositoryAdapter = { + addTranscript: ({ sessionId, speaker, text }) => { + const uid = authService.getCurrentUserId(); + return getBaseRepository().addTranscript({ uid, sessionId, speaker, text }); + }, + getAllTranscriptsBySessionId: (sessionId) => { + return getBaseRepository().getAllTranscriptsBySessionId(sessionId); + } +}; + +module.exports = sttRepositoryAdapter; \ No newline at end of file diff --git a/src/features/listen/stt/repositories/sqlite.repository.js b/src/features/listen/stt/repositories/sqlite.repository.js index 80c838f..be4d00f 100644 --- a/src/features/listen/stt/repositories/sqlite.repository.js +++ b/src/features/listen/stt/repositories/sqlite.repository.js @@ -1,6 +1,7 @@ const sqliteClient = require('../../../../common/services/sqliteClient'); -function addTranscript({ sessionId, speaker, text }) { +function addTranscript({ uid, sessionId, speaker, text }) { + // uid is ignored in the SQLite implementation const db = sqliteClient.getDb(); const transcriptId = require('crypto').randomUUID(); const now = Math.floor(Date.now() / 1000); diff --git a/src/features/listen/summary/repositories/firebase.repository.js b/src/features/listen/summary/repositories/firebase.repository.js new file mode 100644 index 0000000..1e9c115 --- /dev/null +++ b/src/features/listen/summary/repositories/firebase.repository.js @@ -0,0 +1,42 @@ +const { getFirestore, collection, doc, setDoc, getDoc } = require('firebase/firestore'); +const { createEncryptedConverter } = require('../../../../common/repositories/firestoreConverter'); + +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'); +} + +async function saveSummary({ uid, sessionId, tldr, text, bullet_json, action_json, model = 'unknown' }) { + const now = Math.floor(Date.now() / 1000); + const summaryData = { + uid, // To know who generated the summary + generated_at: now, + model, + text, + tldr, + bullet_json, + action_json, + updated_at: now, + }; + + const docRef = summaryDocRef(sessionId); + await setDoc(docRef, summaryData, { merge: true }); + + return { changes: 1 }; +} + +async function getSummaryBySessionId(sessionId) { + const docRef = summaryDocRef(sessionId); + const docSnap = await getDoc(docRef); + return docSnap.exists() ? docSnap.data() : null; +} + +module.exports = { + saveSummary, + getSummaryBySessionId, +}; \ No newline at end of file diff --git a/src/features/listen/summary/repositories/index.js b/src/features/listen/summary/repositories/index.js index d5bd3b3..21934ed 100644 --- a/src/features/listen/summary/repositories/index.js +++ b/src/features/listen/summary/repositories/index.js @@ -1,5 +1,23 @@ -const summaryRepository = require('./sqlite.repository'); +const sqliteRepository = require('./sqlite.repository'); +const firebaseRepository = require('./firebase.repository'); +const authService = require('../../../../common/services/authService'); -module.exports = { - ...summaryRepository, -}; \ No newline at end of file +function getBaseRepository() { + const user = authService.getCurrentUser(); + if (user && user.isLoggedIn) { + return firebaseRepository; + } + return sqliteRepository; +} + +const summaryRepositoryAdapter = { + saveSummary: ({ sessionId, tldr, text, bullet_json, action_json, model }) => { + const uid = authService.getCurrentUserId(); + return getBaseRepository().saveSummary({ uid, sessionId, tldr, text, bullet_json, action_json, model }); + }, + getSummaryBySessionId: (sessionId) => { + return getBaseRepository().getSummaryBySessionId(sessionId); + } +}; + +module.exports = summaryRepositoryAdapter; \ No newline at end of file diff --git a/src/features/listen/summary/repositories/sqlite.repository.js b/src/features/listen/summary/repositories/sqlite.repository.js index 008aa21..1e319cb 100644 --- a/src/features/listen/summary/repositories/sqlite.repository.js +++ b/src/features/listen/summary/repositories/sqlite.repository.js @@ -1,6 +1,7 @@ const sqliteClient = require('../../../../common/services/sqliteClient'); -function saveSummary({ sessionId, tldr, text, bullet_json, action_json, model = 'gpt-4.1' }) { +function saveSummary({ uid, sessionId, tldr, text, bullet_json, action_json, model = 'unknown' }) { + // uid is ignored in the SQLite implementation return new Promise((resolve, reject) => { try { const db = sqliteClient.getDb(); diff --git a/src/features/settings/repositories/firebase.repository.js b/src/features/settings/repositories/firebase.repository.js new file mode 100644 index 0000000..8c90853 --- /dev/null +++ b/src/features/settings/repositories/firebase.repository.js @@ -0,0 +1,94 @@ +const { getFirestore, collection, doc, addDoc, getDoc, getDocs, updateDoc, deleteDoc, query, where, orderBy } = require('firebase/firestore'); +const { createEncryptedConverter } = require('../../../common/repositories/firestoreConverter'); + +const userPresetConverter = createEncryptedConverter(['prompt']); + +const defaultPresetConverter = { + toFirestore: (data) => data, + fromFirestore: (snapshot, options) => { + const data = snapshot.data(options); + return { ...data, id: snapshot.id }; + } +}; + +function userPresetsCol() { + const db = getFirestore(); + return collection(db, 'prompt_presets').withConverter(userPresetConverter); +} + +function defaultPresetsCol() { + const db = getFirestore(); + return collection(db, 'defaults/prompt_presets').withConverter(defaultPresetConverter); +} + +async function getPresets(uid) { + const userPresetsQuery = query(userPresetsCol(), where('uid', '==', uid)); + const defaultPresetsQuery = query(defaultPresetsCol()); + + const [userSnapshot, defaultSnapshot] = await Promise.all([ + getDocs(userPresetsQuery), + getDocs(defaultPresetsQuery) + ]); + + const presets = [ + ...defaultSnapshot.docs.map(d => d.data()), + ...userSnapshot.docs.map(d => d.data()) + ]; + + return presets.sort((a, b) => { + if (a.is_default && !b.is_default) return -1; + if (!a.is_default && b.is_default) return 1; + return a.title.localeCompare(b.title); + }); +} + +async function getPresetTemplates() { + const q = query(defaultPresetsCol(), orderBy('title', 'asc')); + const snapshot = await getDocs(q); + return snapshot.docs.map(doc => doc.data()); +} + +async function createPreset({ uid, title, prompt }) { + const now = Math.floor(Date.now() / 1000); + const newPreset = { + uid: uid, + title, + prompt, + is_default: false, + created_at: now, + }; + const docRef = await addDoc(userPresetsCol(), newPreset); + return { id: docRef.id }; +} + +async function updatePreset(id, { title, prompt }, uid) { + const docRef = doc(userPresetsCol(), id); + const docSnap = await getDoc(docRef); + + 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 }); + return { changes: 1 }; +} + +async function deletePreset(id, uid) { + const docRef = doc(userPresetsCol(), id); + const docSnap = await getDoc(docRef); + + if (!docSnap.exists() || docSnap.data().uid !== uid || docSnap.data().is_default) { + throw new Error("Preset not found or permission denied to delete."); + } + + await deleteDoc(docRef); + return { changes: 1 }; +} + +module.exports = { + getPresets, + getPresetTemplates, + createPreset, + updatePreset, + deletePreset, +}; \ No newline at end of file diff --git a/src/features/settings/repositories/index.js b/src/features/settings/repositories/index.js index 508ebe5..6445613 100644 --- a/src/features/settings/repositories/index.js +++ b/src/features/settings/repositories/index.js @@ -1,21 +1,39 @@ const sqliteRepository = require('./sqlite.repository'); -// const firebaseRepository = require('./firebase.repository'); // Future implementation +const firebaseRepository = require('./firebase.repository'); const authService = require('../../../common/services/authService'); -function getRepository() { - // In the future, we can check the user's login status from authService - // const user = authService.getCurrentUser(); - // if (user.isLoggedIn) { - // return firebaseRepository; - // } +function getBaseRepository() { + const user = authService.getCurrentUser(); + if (user && user.isLoggedIn) { + return firebaseRepository; + } return sqliteRepository; } -// Directly export functions for ease of use, decided by the strategy -module.exports = { - getPresets: (...args) => getRepository().getPresets(...args), - getPresetTemplates: (...args) => getRepository().getPresetTemplates(...args), - createPreset: (...args) => getRepository().createPreset(...args), - updatePreset: (...args) => getRepository().updatePreset(...args), - deletePreset: (...args) => getRepository().deletePreset(...args), -}; \ No newline at end of file +const settingsRepositoryAdapter = { + getPresets: () => { + const uid = authService.getCurrentUserId(); + return getBaseRepository().getPresets(uid); + }, + + getPresetTemplates: () => { + return getBaseRepository().getPresetTemplates(); + }, + + createPreset: (options) => { + const uid = authService.getCurrentUserId(); + return getBaseRepository().createPreset({ uid, ...options }); + }, + + updatePreset: (id, options) => { + const uid = authService.getCurrentUserId(); + return getBaseRepository().updatePreset(id, options, uid); + }, + + deletePreset: (id) => { + const uid = authService.getCurrentUserId(); + return getBaseRepository().deletePreset(id, uid); + }, +}; + +module.exports = settingsRepositoryAdapter; \ No newline at end of file diff --git a/src/features/settings/settingsService.js b/src/features/settings/settingsService.js index 6360f36..0bca617 100644 --- a/src/features/settings/settingsService.js +++ b/src/features/settings/settingsService.js @@ -208,13 +208,8 @@ async function saveSettings(settings) { async function getPresets() { try { - const uid = authService.getCurrentUserId(); - if (!uid) { - // Logged out users only see default presets - return await settingsRepository.getPresetTemplates(); - } - - const presets = await settingsRepository.getPresets(uid); + // The adapter now handles which presets to return based on login state. + const presets = await settingsRepository.getPresets(); return presets; } catch (error) { console.error('[SettingsService] Error getting presets:', error); @@ -234,12 +229,8 @@ async function getPresetTemplates() { async function createPreset(title, prompt) { try { - const uid = authService.getCurrentUserId(); - if (!uid) { - throw new Error("User not logged in, cannot create preset."); - } - - const result = await settingsRepository.createPreset({ uid, title, prompt }); + // The adapter injects the UID. + const result = await settingsRepository.createPreset({ title, prompt }); windowNotificationManager.notifyRelevantWindows('presets-updated', { action: 'created', @@ -256,12 +247,8 @@ async function createPreset(title, prompt) { async function updatePreset(id, title, prompt) { try { - const uid = authService.getCurrentUserId(); - if (!uid) { - throw new Error("User not logged in, cannot update preset."); - } - - await settingsRepository.updatePreset(id, { title, prompt }, uid); + // The adapter injects the UID. + await settingsRepository.updatePreset(id, { title, prompt }); windowNotificationManager.notifyRelevantWindows('presets-updated', { action: 'updated', @@ -278,12 +265,8 @@ async function updatePreset(id, title, prompt) { async function deletePreset(id) { try { - const uid = authService.getCurrentUserId(); - if (!uid) { - throw new Error("User not logged in, cannot delete preset."); - } - - await settingsRepository.deletePreset(id, uid); + // The adapter injects the UID. + await settingsRepository.deletePreset(id); windowNotificationManager.notifyRelevantWindows('presets-updated', { action: 'deleted', @@ -299,10 +282,9 @@ async function deletePreset(id) { async function saveApiKey(apiKey, provider = 'openai') { try { - const uid = authService.getCurrentUserId(); - if (!uid) { + const user = authService.getCurrentUser(); + if (!user.isLoggedIn) { // For non-logged-in users, save to local storage - const { app } = require('electron'); const Store = require('electron-store'); const store = new Store(); store.set('apiKey', apiKey); @@ -318,8 +300,8 @@ async function saveApiKey(apiKey, provider = 'openai') { return { success: true }; } - // For logged-in users, save to database - await userRepository.saveApiKey(apiKey, uid, provider); + // For logged-in users, use the repository adapter which injects the UID. + await userRepository.saveApiKey(apiKey, provider); // Notify windows BrowserWindow.getAllWindows().forEach(win => { @@ -337,17 +319,16 @@ async function saveApiKey(apiKey, provider = 'openai') { async function removeApiKey() { try { - const uid = authService.getCurrentUserId(); - if (!uid) { + const user = authService.getCurrentUser(); + if (!user.isLoggedIn) { // For non-logged-in users, remove from local storage - const { app } = require('electron'); const Store = require('electron-store'); const store = new Store(); store.delete('apiKey'); store.delete('provider'); } else { - // For logged-in users, remove from database - await userRepository.saveApiKey(null, uid, null); + // For logged-in users, use the repository adapter. + await userRepository.saveApiKey(null, null); } // Notify windows diff --git a/src/index.js b/src/index.js index 31ae822..7d6f082 100644 --- a/src/index.js +++ b/src/index.js @@ -251,7 +251,9 @@ function setupGeneralIpcHandlers() { ipcMain.handle('save-api-key', (event, apiKey) => { try { - userRepository.saveApiKey(apiKey, authService.getCurrentUserId()); + // The adapter injects the UID and handles local/firebase logic. + // Assuming a default provider if not specified. + userRepository.saveApiKey(apiKey, 'openai'); BrowserWindow.getAllWindows().forEach(win => { win.webContents.send('api-key-updated'); }); @@ -263,7 +265,8 @@ function setupGeneralIpcHandlers() { }); ipcMain.handle('get-user-presets', () => { - return presetRepository.getPresets(authService.getCurrentUserId()); + // The adapter injects the UID. + return presetRepository.getPresets(); }); ipcMain.handle('get-preset-templates', () => { @@ -302,89 +305,112 @@ function setupWebDataHandlers() { const userRepository = require('./common/repositories/user'); const presetRepository = require('./common/repositories/preset'); - const handleRequest = (channel, responseChannel, payload) => { + const handleRequest = async (channel, responseChannel, payload) => { let result; - const currentUserId = authService.getCurrentUserId(); + // const currentUserId = authService.getCurrentUserId(); // No longer needed here try { switch (channel) { // SESSION case 'get-sessions': - result = sessionRepository.getAllByUserId(currentUserId); + // Adapter injects UID + result = await sessionRepository.getAllByUserId(); break; case 'get-session-details': - const session = sessionRepository.getById(payload); + const session = await sessionRepository.getById(payload); if (!session) { result = null; break; } - const transcripts = sttRepository.getAllTranscriptsBySessionId(payload); - const ai_messages = askRepository.getAllAiMessagesBySessionId(payload); - const summary = summaryRepository.getSummaryBySessionId(payload); + const [transcripts, ai_messages, summary] = await Promise.all([ + sttRepository.getAllTranscriptsBySessionId(payload), + askRepository.getAllAiMessagesBySessionId(payload), + summaryRepository.getSummaryBySessionId(payload) + ]); result = { session, transcripts, ai_messages, summary }; break; case 'delete-session': - result = sessionRepository.deleteWithRelatedData(payload); + result = await sessionRepository.deleteWithRelatedData(payload); break; case 'create-session': - const id = sessionRepository.create(currentUserId, 'ask'); - if (payload.title) { - sessionRepository.updateTitle(id, payload.title); + // Adapter injects UID + const id = await sessionRepository.create('ask'); + if (payload && payload.title) { + await sessionRepository.updateTitle(id, payload.title); } result = { id }; break; // USER case 'get-user-profile': - result = userRepository.getById(currentUserId); + // Adapter injects UID + result = await userRepository.getById(); break; case 'update-user-profile': - result = userRepository.update({ uid: currentUserId, ...payload }); + // Adapter injects UID + result = await userRepository.update(payload); break; case 'find-or-create-user': - result = userRepository.findOrCreate(payload); + result = await userRepository.findOrCreate(payload); break; case 'save-api-key': - result = userRepository.saveApiKey(payload, currentUserId); + // Assuming payload is { apiKey, provider } + result = await userRepository.saveApiKey(payload.apiKey, payload.provider); break; case 'check-api-key-status': - const user = userRepository.getById(currentUserId); + // Adapter injects UID + const user = await userRepository.getById(); result = { hasApiKey: !!user?.api_key && user.api_key.length > 0 }; break; case 'delete-account': - result = userRepository.deleteById(currentUserId); + // Adapter injects UID + result = await userRepository.deleteById(); break; // PRESET case 'get-presets': - result = presetRepository.getPresets(currentUserId); + // Adapter injects UID + result = await presetRepository.getPresets(); break; case 'create-preset': - result = presetRepository.create({ ...payload, uid: currentUserId }); + // Adapter injects UID + result = await presetRepository.create(payload); settingsService.notifyPresetUpdate('created', result.id, payload.title); break; case 'update-preset': - result = presetRepository.update(payload.id, payload.data, currentUserId); + // Adapter injects UID + result = await presetRepository.update(payload.id, payload.data); settingsService.notifyPresetUpdate('updated', payload.id, payload.data.title); break; case 'delete-preset': - result = presetRepository.delete(payload, currentUserId); + // Adapter injects UID + result = await presetRepository.delete(payload); settingsService.notifyPresetUpdate('deleted', payload); break; // BATCH case 'get-batch-data': const includes = payload ? payload.split(',').map(item => item.trim()) : ['profile', 'presets', 'sessions']; - const batchResult = {}; + const promises = {}; if (includes.includes('profile')) { - batchResult.profile = userRepository.getById(currentUserId); + // Adapter injects UID + promises.profile = userRepository.getById(); } if (includes.includes('presets')) { - batchResult.presets = presetRepository.getPresets(currentUserId); + // Adapter injects UID + promises.presets = presetRepository.getPresets(); } if (includes.includes('sessions')) { - batchResult.sessions = sessionRepository.getAllByUserId(currentUserId); + // Adapter injects UID + promises.sessions = sessionRepository.getAllByUserId(); } + + const batchResult = {}; + const promiseResults = await Promise.all(Object.values(promises)); + Object.keys(promises).forEach((key, index) => { + batchResult[key] = promiseResults[index]; + }); + result = batchResult; break;