feat: enhance database service with centralized error logging and improve media handling across services

This commit is contained in:
bobbert committed 2026-02-25 00:14:00 +00:00
1 parent 04046702d3
commit ad0cc1a7f5
5 files changed
+376 -157

No files matched your search

+81 -5
View File
@@ -1,6 +1,7 @@
import * as AuthSession from 'expo-auth-session'; import * as AuthSession from 'expo-auth-session';
import * as WebBrowser from 'expo-web-browser'; import * as WebBrowser from 'expo-web-browser';
import * as Application from 'expo-application'; import * as Application from 'expo-application';
import * as FileSystem from 'expo-file-system';
import { AuthServiceBase } from './AuthServiceBase'; import { AuthServiceBase } from './AuthServiceBase';
import { logError } from '../services/ErrorHandler'; import { logError } from '../services/ErrorHandler';
@@ -29,6 +30,8 @@ const SCOPES = [
const PICKER_API = 'https://photospicker.googleapis.com/v1'; const PICKER_API = 'https://photospicker.googleapis.com/v1';
const CACHE_DIR = `${FileSystem.cacheDirectory}google_photos/`;
const REDIRECT_URI = `${Application.applicationId}:/oauthredirect`; const REDIRECT_URI = `${Application.applicationId}:/oauthredirect`;
class GoogleAUTH extends AuthServiceBase { class GoogleAUTH extends AuthServiceBase {
@@ -276,6 +279,10 @@ class GoogleAUTH extends AuthServiceBase {
/** /**
* Normalise Picker API media items to the app's standard format. * Normalise Picker API media items to the app's standard format.
*
* NOTE: The Picker API's baseUrl requires an Authorization header to
* fetch the image. We store it in `remote_path` / `picker_base_url`
* so the caller can download it to a local cache file for rendering.
*/ */
_normalisePickerItems(items) { _normalisePickerItems(items) {
return (items || []).map((item) => { return (items || []).map((item) => {
@@ -286,14 +293,80 @@ class GoogleAUTH extends AuthServiceBase {
source_id: item.id || file.id, source_id: item.id || file.id,
title: file.filename || 'Untitled', title: file.filename || 'Untitled',
description: '', description: '',
local_path: file.baseUrl ? `${file.baseUrl}=w400-h400` : null, // local_path is null until we download it with the auth token
local_path: null,
remote_path: file.baseUrl || '', remote_path: file.baseUrl || '',
picker_base_url: file.baseUrl || null,
media_type: isVideo ? 'video' : 'photo', media_type: isVideo ? 'video' : 'photo',
mime_type: file.mimeType || null,
created_date: file.mediaFileMetadata?.creationTime || null, created_date: file.mediaFileMetadata?.creationTime || null,
}; };
}); });
} }
/**
* Download Picker images to local cache so <Image> can render them.
*
* The Picker API's baseUrl requires an Authorization header. React
* Native's <Image> can't add custom headers reliably, so we download
* each photo to a cache file and set local_path to the file:// URI.
*
* @param {Array} items — normalised items from _normalisePickerItems
* @returns {Array} — same items with local_path populated
*/
async downloadPickerPhotos(items) {
const token = await this.getAccessToken();
// Ensure cache directory exists
const dirInfo = await FileSystem.getInfoAsync(CACHE_DIR);
if (!dirInfo.exists) {
await FileSystem.makeDirectoryAsync(CACHE_DIR, { intermediates: true });
}
const results = [];
for (const item of items) {
if (!item.picker_base_url) {
results.push(item);
continue;
}
try {
// Use the source_id as a safe filename
const safeId = (item.source_id || 'photo').replace(/[^a-zA-Z0-9_-]/g, '_');
const ext = item.mime_type?.includes('png') ? '.png'
: item.mime_type?.includes('gif') ? '.gif'
: item.mime_type?.includes('webp') ? '.webp'
: '.jpg';
const localUri = `${CACHE_DIR}${safeId}${ext}`;
// Skip download if already cached
const fileInfo = await FileSystem.getInfoAsync(localUri);
if (fileInfo.exists && fileInfo.size > 0) {
results.push({ ...item, local_path: localUri });
continue;
}
// Download with auth header — append =d to get the full-res download
const downloadUrl = `${item.picker_base_url}=d`;
const download = await FileSystem.downloadAsync(downloadUrl, localUri, {
headers: { Authorization: `Bearer ${token}` },
});
if (download.status === 200) {
results.push({ ...item, local_path: download.uri });
} else {
logError('Download Picker Photo', `HTTP ${download.status} for ${item.title}`);
results.push(item);
}
} catch (err) {
logError('Download Picker Photo', err);
results.push(item);
}
}
return results;
}
/** /**
* Full interactive flow: create session → open picker in browser → * Full interactive flow: create session → open picker in browser →
* wait for user to select photos → fetch the selected items. * wait for user to select photos → fetch the selected items.
@@ -339,11 +412,13 @@ class GoogleAUTH extends AuthServiceBase {
}; };
} }
// 4. Fetch selected items // 4. Fetch selected items and download to local cache
const result = await this._fetchPickerMediaItems(session.id, 100, null); const result = await this._fetchPickerMediaItems(session.id, 100, null);
const mediaItems = this._normalisePickerItems(result.mediaItems); let mediaItems = this._normalisePickerItems(result.mediaItems);
console.log(`Picker returned ${mediaItems.length} selected items`); console.log(`Picker returned ${mediaItems.length} selected items, downloading…`);
mediaItems = await this.downloadPickerPhotos(mediaItems);
console.log(`Downloaded ${mediaItems.filter((m) => m.local_path).length} photos to cache`);
return { return {
mediaItems, mediaItems,
@@ -366,7 +441,8 @@ class GoogleAUTH extends AuthServiceBase {
} }
const result = await this._fetchPickerMediaItems(sessionId, pageSize, pageToken); const result = await this._fetchPickerMediaItems(sessionId, pageSize, pageToken);
const mediaItems = this._normalisePickerItems(result.mediaItems); let mediaItems = this._normalisePickerItems(result.mediaItems);
mediaItems = await this.downloadPickerPhotos(mediaItems);
return { return {
mediaItems, mediaItems,
+20 -9
View File
@@ -1,4 +1,4 @@
import React, { useRef, useState, useEffect, useCallback } from 'react'; import React, { useRef, useState, useEffect } from 'react';
import { import {
Modal, View, Text, TouchableOpacity, Image, StyleSheet, Modal, View, Text, TouchableOpacity, Image, StyleSheet,
Dimensions, FlatList, Dimensions, FlatList,
@@ -57,22 +57,22 @@ const MediaViewer = ({ visible, media, allMedia, onClose }) => {
}, 100); }, 100);
}, [visible, media]); }, [visible, media]);
const onMainScroll = useCallback( const activeIndexRef = useRef(0);
(event) => {
const onMainScroll = (event) => {
const offsetX = event.nativeEvent.contentOffset.x; const offsetX = event.nativeEvent.contentOffset.x;
const idx = Math.round(offsetX / SCREEN_WIDTH); const idx = Math.round(offsetX / SCREEN_WIDTH);
if (idx >= 0 && idx < mediaList.length && idx !== activeIndex) { if (idx >= 0 && idx < mediaList.length && idx !== activeIndexRef.current) {
activeIndexRef.current = idx;
setActiveIndex(idx); setActiveIndex(idx);
setCurrentMedia(mediaList[idx]); setCurrentMedia(mediaList[idx]);
if (thumbListRef.current) { if (thumbListRef.current) {
try { try {
thumbListRef.current.scrollToIndex({ index: idx, animated: true, viewPosition: 0.5 }); thumbListRef.current.scrollToIndex({ index: idx, animated: true, viewPosition: 0.5 });
} catch (e) { /* ignore */ } } catch (_) { /* ignore */ }
} }
} }
}, };
[activeIndex, mediaList, thumbListRef]
);
const onThumbPress = (index) => { const onThumbPress = (index) => {
setActiveIndex(index); setActiveIndex(index);
@@ -189,9 +189,20 @@ const MediaViewer = ({ visible, media, allMedia, onClose }) => {
<View style={styles.row}> <View style={styles.row}>
<Text style={styles.label}>Source</Text> <Text style={styles.label}>Source</Text>
<Text style={styles.value}> <Text style={styles.value}>
{getServiceIcon(displayMedia.source_service)} {getServiceName(displayMedia.source_service)} {displayMedia.sources && displayMedia.sources.length > 1
? displayMedia.sources.map((s) => `${getServiceIcon(s)} ${getServiceName(s)}`).join(' · ')
: `${getServiceIcon(displayMedia.source_service)} ${getServiceName(displayMedia.source_service)}`
}
</Text> </Text>
</View> </View>
{displayMedia.sources && displayMedia.sources.length > 1 && (
<View style={styles.row}>
<Text style={styles.label}>Available on</Text>
<Text style={[styles.value, { color: '#8ab4f8' }]}>
{displayMedia.sources.length} services
</Text>
</View>
)}
{displayMedia.created_date && ( {displayMedia.created_date && (
<View style={styles.row}> <View style={styles.row}>
<Text style={styles.label}>Date</Text> <Text style={styles.label}>Date</Text>
+31 -4
View File
@@ -21,7 +21,31 @@ const PhotoGrid = ({
const itemSize = (width - GRID_GAP * (columns + 1)) / columns; const itemSize = (width - GRID_GAP * (columns + 1)) / columns;
const getServiceIcon = (type) => const getServiceIcon = (type) =>
({ GOOGLE_PHOTOS: '📷', ICLOUD_PHOTOS: '🍎', NAS: '💾' }[type] || '📱'); ({ DEVICE: '📱', GOOGLE_PHOTOS: '📷', ICLOUD_PHOTOS: '🍎', NAS: '💾' }[type] || '📱');
const renderSourceBadges = (item) => {
const sources = item.sources && item.sources.length > 0
? item.sources
: [item.source_service];
if (sources.length === 1) {
// Single source — simple badge
return (
<View style={styles.sourceBadge}>
<Text style={{ fontSize: 10 }}>{getServiceIcon(sources[0])}</Text>
</View>
);
}
// Multiple sources — show all icons with a subtle indicator
return (
<View style={styles.multiSourceBadge}>
{sources.map((src, i) => (
<Text key={src + i} style={{ fontSize: 9 }}>{getServiceIcon(src)}</Text>
))}
</View>
);
};
const renderItem = ({ item }) => ( const renderItem = ({ item }) => (
<TouchableOpacity <TouchableOpacity
@@ -41,9 +65,7 @@ const PhotoGrid = ({
<Text style={{ color: '#fff', fontSize: 20 }}>▶</Text> <Text style={{ color: '#fff', fontSize: 20 }}>▶</Text>
</View> </View>
)} )}
<View style={styles.sourceBadge}> {renderSourceBadges(item)}
<Text style={{ fontSize: 10 }}>{getServiceIcon(item.source_service)}</Text>
</View>
</TouchableOpacity> </TouchableOpacity>
); );
@@ -82,6 +104,11 @@ const styles = StyleSheet.create({
position: 'absolute', bottom: 4, right: 4, backgroundColor: 'rgba(0,0,0,0.5)', position: 'absolute', bottom: 4, right: 4, backgroundColor: 'rgba(0,0,0,0.5)',
borderRadius: 8, paddingHorizontal: 4, paddingVertical: 2, borderRadius: 8, paddingHorizontal: 4, paddingVertical: 2,
}, },
multiSourceBadge: {
position: 'absolute', bottom: 4, right: 4, backgroundColor: 'rgba(0,0,0,0.6)',
borderRadius: 8, paddingHorizontal: 4, paddingVertical: 2,
flexDirection: 'row', gap: 2,
},
}); });
export default PhotoGrid; export default PhotoGrid;
+69 -19
View File
@@ -14,6 +14,62 @@ import MediaViewer from '../components/MediaViewer';
import { logError } from '../services/ErrorHandler'; import { logError } from '../services/ErrorHandler';
import { TouchableOpacity } from 'react-native'; import { TouchableOpacity } from 'react-native';
/**
* Merge media items from different services.
* Items with the same filename (title) are merged into a single entry
* with a `sources` array listing every service it appears in.
* The best local_path is kept (prefer file:// from DEVICE over a cached
* Picker download, since device URIs are always fresh).
*/
const mergeMediaAcrossServices = (items) => {
// First pass: group by normalised filename
const byFilename = new Map();
const noFilename = [];
for (const item of items) {
const key = item.title?.toLowerCase().trim();
if (!key || key === 'untitled') {
noFilename.push({ ...item, sources: [item.source_service] });
continue;
}
if (byFilename.has(key)) {
const existing = byFilename.get(key);
// Add this service to the sources list
if (!existing.sources.includes(item.source_service)) {
existing.sources.push(item.source_service);
}
// Prefer a local device path over a cached download
if (item.source_service === 'DEVICE' && item.local_path) {
existing.local_path = item.local_path;
}
// Keep the best local_path if the existing one is missing
if (!existing.local_path && item.local_path) {
existing.local_path = item.local_path;
}
} else {
byFilename.set(key, { ...item, sources: [item.source_service] });
}
}
// Second pass: also deduplicate by source_id for items with different titles
const bySourceId = new Map();
for (const item of [...byFilename.values(), ...noFilename]) {
const key = item.source_id || item.id || item.local_path || Math.random().toString();
if (bySourceId.has(key)) {
const existing = bySourceId.get(key);
for (const src of (item.sources || [])) {
if (!existing.sources.includes(src)) existing.sources.push(src);
}
if (!existing.local_path && item.local_path) existing.local_path = item.local_path;
} else {
bySourceId.set(key, item);
}
}
return Array.from(bySourceId.values());
};
const HomeScreen = ({ navigation, route }) => { const HomeScreen = ({ navigation, route }) => {
const userId = route?.params?.userId; const userId = route?.params?.userId;
const { theme } = useTheme(); const { theme } = useTheme();
@@ -102,11 +158,11 @@ const HomeScreen = ({ navigation, route }) => {
console.log('iCloud: placeholder — would fetch photos here'); console.log('iCloud: placeholder — would fetch photos here');
} }
// Deduplicate // ─── Merge & cross-reference across services ───
const unique = Array.from( // Match items across sources by filename so we can show "this photo
new Map(newMedia.map((m) => [m.source_id || m.id || m.local_path, m])).values() // exists on Device AND Google Photos" etc.
); const merged = mergeMediaAcrossServices(newMedia);
setAllMedia(unique); setAllMedia(merged);
} catch (err) { } catch (err) {
console.error('Error loading media:', err); console.error('Error loading media:', err);
} finally { } finally {
@@ -158,13 +214,8 @@ const HomeScreen = ({ navigation, route }) => {
setGoogleNextPage(result.nextPageToken || null); setGoogleNextPage(result.nextPageToken || null);
setGoogleSessionId(result.sessionId || null); setGoogleSessionId(result.sessionId || null);
// Merge into current view // Merge into current view with cross-service matching
setAllMedia((prev) => { setAllMedia((prev) => mergeMediaAcrossServices([...prev, ...result.mediaItems]));
const combined = [...prev, ...result.mediaItems];
return Array.from(
new Map(combined.map((m) => [m.source_id || m.id || m.local_path, m])).values()
);
});
console.log(`Imported ${result.mediaItems.length} photos from Google`); console.log(`Imported ${result.mediaItems.length} photos from Google`);
} }
@@ -188,7 +239,11 @@ const HomeScreen = ({ navigation, route }) => {
const filterMedia = () => { const filterMedia = () => {
let filtered = allMedia; let filtered = allMedia;
if (selectedFilter !== 'all') { if (selectedFilter !== 'all') {
filtered = allMedia.filter((m) => m.source_service === selectedFilter); // Check both source_service and the sources array for cross-service items
filtered = allMedia.filter((m) =>
m.source_service === selectedFilter ||
(m.sources && m.sources.includes(selectedFilter))
);
} }
filtered.sort((a, b) => new Date(b.created_date || 0) - new Date(a.created_date || 0)); filtered.sort((a, b) => new Date(b.created_date || 0) - new Date(a.created_date || 0));
setFilteredMedia(filtered); setFilteredMedia(filtered);
@@ -233,12 +288,7 @@ const HomeScreen = ({ navigation, route }) => {
} }
if (moreMedia.length) { if (moreMedia.length) {
setAllMedia((prev) => { setAllMedia((prev) => mergeMediaAcrossServices([...prev, ...moreMedia]));
const combined = [...prev, ...moreMedia];
return Array.from(
new Map(combined.map((m) => [m.source_id || m.id || m.local_path, m])).values()
);
});
} }
} catch (err) { } catch (err) {
console.error('Error loading more photos:', err); console.error('Error loading more photos:', err);
+175 -120
View File
@@ -1,11 +1,19 @@
// noinspection SqlNoDataSourceInspection,SqlResolve
/**
* Database service — all tables are created at runtime by expo-sqlite.
* The IDE cannot resolve them statically; the SQL warnings are safe to ignore.
*/
import * as SQLite from 'expo-sqlite'; import * as SQLite from 'expo-sqlite';
import CredentialEncryption from './CredentialEncryption'; import CredentialEncryption from './CredentialEncryption';
import { logError } from './ErrorHandler';
let db = null; let db = null;
let initPromise = null; let initPromise = null;
const DatabaseService = { const DatabaseService = {
// Open the database and create all tables // ─── Initialisation ────────────────────────────────────────
initDatabase: async () => { initDatabase: async () => {
if (initPromise) return initPromise; if (initPromise) return initPromise;
@@ -14,12 +22,17 @@ const DatabaseService = {
if (db) return true; if (db) return true;
if (typeof SQLite.openDatabaseAsync !== 'function') { if (typeof SQLite.openDatabaseAsync !== 'function') {
throw new Error('SQLite openDatabaseAsync API not available'); logError('DB Init', 'SQLite openDatabaseAsync API not available');
return false;
} }
db = await SQLite.openDatabaseAsync('PhotosApp.db'); db = await SQLite.openDatabaseAsync('PhotosApp.db');
if (!db) throw new Error('SQLite openDatabaseAsync returned null'); if (!db) {
logError('DB Init', 'SQLite openDatabaseAsync returned null');
return false;
}
// Create all tables in one batch
await db.execAsync(` await db.execAsync(`
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -27,10 +40,7 @@ const DatabaseService = {
email TEXT UNIQUE NOT NULL, email TEXT UNIQUE NOT NULL,
password TEXT NOT NULL, password TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP created_at DATETIME DEFAULT CURRENT_TIMESTAMP
) );
`);
await db.execAsync(`
CREATE TABLE IF NOT EXISTS linked_accounts ( CREATE TABLE IF NOT EXISTS linked_accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL, user_id INTEGER NOT NULL,
@@ -40,10 +50,7 @@ const DatabaseService = {
is_default INTEGER DEFAULT 0, is_default INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(user_id) REFERENCES users(id) FOREIGN KEY(user_id) REFERENCES users(id)
) );
`);
await db.execAsync(`
CREATE TABLE IF NOT EXISTS media ( CREATE TABLE IF NOT EXISTS media (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL, user_id INTEGER NOT NULL,
@@ -58,20 +65,20 @@ const DatabaseService = {
synced INTEGER DEFAULT 0, synced INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(user_id) REFERENCES users(id) FOREIGN KEY(user_id) REFERENCES users(id)
) );
`); `);
// Add optional columns (safe migration — errors ignored if columns already exist) // Safe migrations — errors ignored if columns already exist
try { await db.execAsync(`ALTER TABLE users ADD COLUMN auto_backup_services TEXT DEFAULT '[]'`); } catch (_) {} try { await db.execAsync("ALTER TABLE users ADD COLUMN auto_backup_services TEXT DEFAULT '[]'"); } catch (_) {}
try { await db.execAsync(`ALTER TABLE users ADD COLUMN theme_preference TEXT DEFAULT 'light'`); } catch (_) {} try { await db.execAsync("ALTER TABLE users ADD COLUMN theme_preference TEXT DEFAULT 'light'"); } catch (_) {}
console.log('Database initialized successfully'); console.log('Database initialized successfully');
return true; return true;
} catch (error) { } catch (error) {
console.error('Database initialization error:', error); logError('DB Init', error);
db = null; db = null;
initPromise = null; initPromise = null;
throw error; return false;
} }
})(); })();
@@ -80,12 +87,18 @@ const DatabaseService = {
ensureDb: async () => { ensureDb: async () => {
await DatabaseService.initDatabase(); await DatabaseService.initDatabase();
if (!db) throw new Error('Database not initialized'); if (!db) {
logError('DB', 'Database not initialized');
return false;
}
return true;
}, },
// ─── Users ─────────────────────────────────────────────────
createUser: async (username, email, password) => { createUser: async (username, email, password) => {
try { try {
await DatabaseService.ensureDb(); if (!(await DatabaseService.ensureDb())) return null;
const hashedPassword = await CredentialEncryption.hashPassword(password); const hashedPassword = await CredentialEncryption.hashPassword(password);
const result = await db.runAsync( const result = await db.runAsync(
'INSERT INTO users (username, email, password) VALUES (?, ?, ?)', 'INSERT INTO users (username, email, password) VALUES (?, ?, ?)',
@@ -93,98 +106,111 @@ const DatabaseService = {
); );
return result.lastInsertRowId; return result.lastInsertRowId;
} catch (error) { } catch (error) {
console.error('Error creating user:', error); logError('Create User', error);
throw error; return null;
} }
}, },
// Authenticate by username or email
loginUser: async (identifier, password) => { loginUser: async (identifier, password) => {
try { try {
await DatabaseService.ensureDb(); if (!(await DatabaseService.ensureDb())) return null;
const row = await db.getFirstAsync( const row = await db.getFirstAsync(
'SELECT * FROM users WHERE username = ? OR email = ?', 'SELECT * FROM users WHERE username = ? OR email = ?',
[identifier, identifier] [identifier, identifier]
); );
if (!row) return null; if (!row) return null;
const valid = await CredentialEncryption.verifyPassword(password, row.password); const valid = await CredentialEncryption.verifyPassword(password, row.password);
if (!valid) return null; return valid ? row : null;
return row;
} catch (error) { } catch (error) {
console.error('Error logging in user:', error); logError('Login User', error);
throw error; return null;
} }
}, },
userExists: async (username, email) => { userExists: async (username, email) => {
try { try {
await DatabaseService.ensureDb(); if (!(await DatabaseService.ensureDb())) return false;
const row = await db.getFirstAsync( const row = await db.getFirstAsync(
'SELECT id FROM users WHERE username = ? OR email = ?', 'SELECT id FROM users WHERE username = ? OR email = ?',
[username, email] [username, email]
); );
return !!row; return !!row;
} catch (error) { } catch (error) {
console.error('Error checking if user exists:', error); logError('User Exists', error);
throw error; return false;
} }
}, },
getUserById: async (userId) => { getUserById: async (userId) => {
try { try {
await DatabaseService.ensureDb(); if (!(await DatabaseService.ensureDb())) return null;
if (!userId && userId !== 0) throw new Error('User ID is required'); if (!userId && userId !== 0) {
logError('Get User', 'User ID is required');
return null;
}
return (await db.getFirstAsync('SELECT * FROM users WHERE id = ?', [userId])) || null; return (await db.getFirstAsync('SELECT * FROM users WHERE id = ?', [userId])) || null;
} catch (error) { } catch (error) {
console.error('Error getting user:', error); logError('Get User', error);
throw error; return null;
} }
}, },
// Link an external service account (Google, iCloud, NAS) to a local user // ─── Linked Accounts ──────────────────────────────────────
linkAccount: async (userId, serviceType, accountData, isDefault = false) => { linkAccount: async (userId, serviceType, accountData, isDefault = false) => {
try { try {
await DatabaseService.ensureDb(); if (!(await DatabaseService.ensureDb())) return null;
if (!userId) throw new Error('User ID is required'); if (!userId || !serviceType || !accountData || typeof accountData !== 'object') {
if (!serviceType) throw new Error('Service type is required'); logError('Link Account', 'userId, serviceType, and accountData object are required');
if (!accountData || typeof accountData !== 'object') throw new Error('Account data must be a valid object'); return null;
}
const encryptedData = CredentialEncryption.encrypt(accountData); const encryptedData = CredentialEncryption.encrypt(accountData);
if (!encryptedData) throw new Error('Encryption returned empty data'); if (!encryptedData) {
logError('Link Account', 'Encryption returned empty data');
return null;
}
// Replace any existing link for the same service type // Replace any existing link for the same service type
await db.runAsync('DELETE FROM linked_accounts WHERE user_id = ? AND service_type = ?', [userId, serviceType]); await db.runAsync(
'DELETE FROM linked_accounts WHERE user_id = ? AND service_type = ?',
[userId, serviceType]
);
const result = await db.runAsync( const result = await db.runAsync(
'INSERT INTO linked_accounts (user_id, service_type, account_data, is_default) VALUES (?, ?, ?, ?)', 'INSERT INTO linked_accounts (user_id, service_type, account_data, is_default) VALUES (?, ?, ?, ?)',
[userId, serviceType, encryptedData, isDefault ? 1 : 0] [userId, serviceType, encryptedData, isDefault ? 1 : 0]
); );
return result.lastInsertRowId; return result.lastInsertRowId;
} catch (error) { } catch (error) {
console.error('Error linking account:', error); logError('Link Account', error);
throw new Error('Failed to link account: ' + error.message); return null;
} }
}, },
getLinkedAccounts: async (userId) => { getLinkedAccounts: async (userId) => {
try { try {
await DatabaseService.ensureDb(); if (!(await DatabaseService.ensureDb())) return [];
if (!userId && userId !== 0) throw new Error('User ID is required'); if (!userId && userId !== 0) {
logError('Get Linked Accounts', 'User ID is required');
return [];
}
const rows = await db.getAllAsync( const rows = await db.getAllAsync(
'SELECT * FROM linked_accounts WHERE user_id = ? AND is_enabled = 1', [userId] 'SELECT * FROM linked_accounts WHERE user_id = ? AND is_enabled = 1',
[userId]
); );
return rows.map((row) => ({ ...row, account_data: CredentialEncryption.decrypt(row.account_data) })); return rows.map((row) => ({
...row,
account_data: CredentialEncryption.decrypt(row.account_data),
}));
} catch (error) { } catch (error) {
console.error('Error getting linked accounts:', error); logError('Get Linked Accounts', error);
throw error; return [];
} }
}, },
getLinkedAccountByService: async (userId, serviceType) => { getLinkedAccountByService: async (userId, serviceType) => {
try { try {
await DatabaseService.ensureDb(); if (!(await DatabaseService.ensureDb())) return null;
const row = await db.getFirstAsync( const row = await db.getFirstAsync(
'SELECT * FROM linked_accounts WHERE user_id = ? AND service_type = ? AND is_enabled = 1', 'SELECT * FROM linked_accounts WHERE user_id = ? AND service_type = ? AND is_enabled = 1',
[userId, serviceType] [userId, serviceType]
@@ -192,40 +218,83 @@ const DatabaseService = {
if (!row) return null; if (!row) return null;
return { ...row, account_data: CredentialEncryption.decrypt(row.account_data) }; return { ...row, account_data: CredentialEncryption.decrypt(row.account_data) };
} catch (error) { } catch (error) {
console.error('Error getting linked account:', error); logError('Get Linked Account By Service', error);
throw error; return null;
} }
}, },
setDefaultService: async (userId, accountId) => { setDefaultService: async (userId, accountId) => {
try { try {
await DatabaseService.ensureDb(); if (!(await DatabaseService.ensureDb())) return;
await db.runAsync('UPDATE linked_accounts SET is_default = 0 WHERE user_id = ?', [userId]); await db.runAsync('UPDATE linked_accounts SET is_default = 0 WHERE user_id = ?', [userId]);
await db.runAsync('UPDATE linked_accounts SET is_default = 1 WHERE id = ? AND user_id = ?', [accountId, userId]); await db.runAsync('UPDATE linked_accounts SET is_default = 1 WHERE id = ? AND user_id = ?', [accountId, userId]);
} catch (error) { } catch (error) {
console.error('Error setting default service:', error); logError('Set Default Service', error);
throw error;
} }
}, },
getDefaultService: async (userId) => { getDefaultService: async (userId) => {
try { try {
await DatabaseService.ensureDb(); if (!(await DatabaseService.ensureDb())) return null;
const row = await db.getFirstAsync( const row = await db.getFirstAsync(
'SELECT * FROM linked_accounts WHERE user_id = ? AND is_default = 1', [userId] 'SELECT * FROM linked_accounts WHERE user_id = ? AND is_default = 1',
[userId]
); );
if (!row) return null; if (!row) return null;
return { ...row, account_data: CredentialEncryption.decrypt(row.account_data) }; return { ...row, account_data: CredentialEncryption.decrypt(row.account_data) };
} catch (error) { } catch (error) {
console.error('Error getting default service:', error); logError('Get Default Service', error);
throw error; return null;
} }
}, },
toggleAccountEnabled: async (accountId, enabled) => {
try {
if (!(await DatabaseService.ensureDb())) return;
await db.runAsync(
'UPDATE linked_accounts SET is_enabled = ? WHERE id = ?',
[enabled ? 1 : 0, accountId]
);
} catch (error) {
logError('Toggle Account', error);
}
},
removeLinkedAccount: async (accountId) => {
try {
if (!(await DatabaseService.ensureDb())) return;
await db.runAsync('DELETE FROM linked_accounts WHERE id = ?', [accountId]);
} catch (error) {
logError('Remove Account', error);
}
},
getAllLinkedAccounts: async (userId) => {
try {
if (!(await DatabaseService.ensureDb())) return [];
const rows = await db.getAllAsync(
'SELECT * FROM linked_accounts WHERE user_id = ?',
[userId]
);
return rows.map((row) => ({
...row,
account_data: CredentialEncryption.decrypt(row.account_data),
}));
} catch (error) {
logError('Get All Linked Accounts', error);
return [];
}
},
// ─── Media ─────────────────────────────────────────────────
addMedia: async (userId, sourceService, sourceId, title, description, localPath, remotePath, mediaType, createdDate) => { addMedia: async (userId, sourceService, sourceId, title, description, localPath, remotePath, mediaType, createdDate) => {
try { try {
await DatabaseService.ensureDb(); if (!(await DatabaseService.ensureDb())) return null;
if (!userId || !sourceService) throw new Error('userId and sourceService are required'); if (!userId || !sourceService) {
logError('Add Media', 'userId and sourceService are required');
return null;
}
const result = await db.runAsync( const result = await db.runAsync(
`INSERT INTO media (user_id, source_service, source_id, title, description, local_path, remote_path, media_type, created_date) `INSERT INTO media (user_id, source_service, source_id, title, description, local_path, remote_path, media_type, created_date)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
@@ -234,8 +303,8 @@ const DatabaseService = {
); );
return result.lastInsertRowId; return result.lastInsertRowId;
} catch (error) { } catch (error) {
console.error('Error adding media:', error); logError('Add Media', error);
throw error; return null;
} }
}, },
@@ -245,10 +314,10 @@ const DatabaseService = {
*/ */
saveMediaItem: async (userId, item) => { saveMediaItem: async (userId, item) => {
try { try {
await DatabaseService.ensureDb(); if (!(await DatabaseService.ensureDb())) return null;
if (!userId || !item.source_service) return; if (!userId || !item?.source_service) return null;
// Check for existing entry to avoid duplicates // Skip if already exists
if (item.source_id) { if (item.source_id) {
const existing = await db.getFirstAsync( const existing = await db.getFirstAsync(
'SELECT id FROM media WHERE user_id = ? AND source_id = ?', 'SELECT id FROM media WHERE user_id = ? AND source_id = ?',
@@ -266,108 +335,94 @@ const DatabaseService = {
); );
return result.lastInsertRowId; return result.lastInsertRowId;
} catch (error) { } catch (error) {
console.error('Error saving media item:', error); logError('Save Media Item', error);
return null;
} }
}, },
getMediaByUser: async (userId) => { getMediaByUser: async (userId) => {
try { try {
await DatabaseService.ensureDb(); if (!(await DatabaseService.ensureDb())) return [];
if (!userId && userId !== 0) throw new Error('User ID is required'); if (!userId && userId !== 0) {
return await db.getAllAsync('SELECT * FROM media WHERE user_id = ? ORDER BY created_date DESC', [userId]); logError('Get Media', 'User ID is required');
return [];
}
return await db.getAllAsync(
'SELECT * FROM media WHERE user_id = ? ORDER BY created_date DESC',
[userId]
);
} catch (error) { } catch (error) {
console.error('Error getting media:', error); logError('Get Media', error);
throw error; return [];
} }
}, },
getMediaByService: async (userId, serviceType) => { getMediaByService: async (userId, serviceType) => {
try { try {
await DatabaseService.ensureDb(); if (!(await DatabaseService.ensureDb())) return [];
return await db.getAllAsync( return await db.getAllAsync(
'SELECT * FROM media WHERE user_id = ? AND source_service = ? ORDER BY created_date DESC', 'SELECT * FROM media WHERE user_id = ? AND source_service = ? ORDER BY created_date DESC',
[userId, serviceType] [userId, serviceType]
); );
} catch (error) { } catch (error) {
console.error('Error getting media by service:', error); logError('Get Media By Service', error);
throw error; return [];
} }
}, },
searchMedia: async (userId, query) => { searchMedia: async (userId, query) => {
try { try {
await DatabaseService.ensureDb(); if (!(await DatabaseService.ensureDb())) return [];
const searchTerm = `%${query}%`; const searchTerm = `%${query}%`;
return await db.getAllAsync( return await db.getAllAsync(
'SELECT * FROM media WHERE user_id = ? AND (title LIKE ? OR description LIKE ?) ORDER BY created_date DESC', 'SELECT * FROM media WHERE user_id = ? AND (title LIKE ? OR description LIKE ?) ORDER BY created_date DESC',
[userId, searchTerm, searchTerm] [userId, searchTerm, searchTerm]
); );
} catch (error) { } catch (error) {
console.error('Error searching media:', error); logError('Search Media', error);
throw error; return [];
} }
}, },
// ─── Settings ──────────────────────────────────────────────
getAutoBackupServices: async (userId) => { getAutoBackupServices: async (userId) => {
try { try {
await DatabaseService.ensureDb(); if (!(await DatabaseService.ensureDb())) return [];
const row = await db.getFirstAsync('SELECT auto_backup_services FROM users WHERE id = ?', [userId]); const row = await db.getFirstAsync(
'SELECT auto_backup_services FROM users WHERE id = ?',
[userId]
);
if (!row?.auto_backup_services) return []; if (!row?.auto_backup_services) return [];
return JSON.parse(row.auto_backup_services); return JSON.parse(row.auto_backup_services);
} catch (error) { } catch (error) {
console.error('Error getting auto backup services:', error); logError('Get Auto Backup', error);
return []; return [];
} }
}, },
setAutoBackupServices: async (userId, services) => { setAutoBackupServices: async (userId, services) => {
try { try {
await DatabaseService.ensureDb(); if (!(await DatabaseService.ensureDb())) return;
await db.runAsync('UPDATE users SET auto_backup_services = ? WHERE id = ?', [JSON.stringify(services), userId]); await db.runAsync(
'UPDATE users SET auto_backup_services = ? WHERE id = ?',
[JSON.stringify(services), userId]
);
} catch (error) { } catch (error) {
console.error('Error setting auto backup services:', error); logError('Set Auto Backup', error);
throw error;
} }
}, },
toggleAccountEnabled: async (accountId, enabled) => { // ─── Lifecycle ─────────────────────────────────────────────
try {
await DatabaseService.ensureDb();
await db.runAsync('UPDATE linked_accounts SET is_enabled = ? WHERE id = ?', [enabled ? 1 : 0, accountId]);
} catch (error) {
console.error('Error toggling account:', error);
throw error;
}
},
removeLinkedAccount: async (accountId) => {
try {
await DatabaseService.ensureDb();
await db.runAsync('DELETE FROM linked_accounts WHERE id = ?', [accountId]);
} catch (error) {
console.error('Error removing account:', error);
throw error;
}
},
getAllLinkedAccounts: async (userId) => {
try {
await DatabaseService.ensureDb();
const rows = await db.getAllAsync('SELECT * FROM linked_accounts WHERE user_id = ?', [userId]);
return rows.map((row) => ({ ...row, account_data: CredentialEncryption.decrypt(row.account_data) }));
} catch (error) {
console.error('Error getting all linked accounts:', error);
throw error;
}
},
closeDatabase: async () => { closeDatabase: async () => {
try { try {
if (db?.closeAsync) await db.closeAsync(); if (db?.closeAsync) await db.closeAsync();
} catch (error) {
logError('Close DB', error);
} finally {
db = null; db = null;
initPromise = null; initPromise = null;
} catch (error) {
console.error('Error closing database:', error);
} }
}, },
}; };