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
+384 -165

No files matched your search

+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 CredentialEncryption from './CredentialEncryption';
import { logError } from './ErrorHandler';
let db = null;
let initPromise = null;
const DatabaseService = {
// Open the database and create all tables
// ─── Initialisation ────────────────────────────────────────
initDatabase: async () => {
if (initPromise) return initPromise;
@@ -14,12 +22,17 @@ const DatabaseService = {
if (db) return true;
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');
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(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -27,10 +40,7 @@ const DatabaseService = {
email TEXT UNIQUE NOT NULL,
password TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
await db.execAsync(`
);
CREATE TABLE IF NOT EXISTS linked_accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
@@ -40,10 +50,7 @@ const DatabaseService = {
is_default INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(user_id) REFERENCES users(id)
)
`);
await db.execAsync(`
);
CREATE TABLE IF NOT EXISTS media (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
@@ -58,20 +65,20 @@ const DatabaseService = {
synced INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(user_id) REFERENCES users(id)
)
);
`);
// Add optional columns (safe migration — 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 theme_preference TEXT DEFAULT 'light'`); } catch (_) {}
// 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 theme_preference TEXT DEFAULT 'light'"); } catch (_) {}
console.log('Database initialized successfully');
return true;
} catch (error) {
console.error('Database initialization error:', error);
logError('DB Init', error);
db = null;
initPromise = null;
throw error;
return false;
}
})();
@@ -80,12 +87,18 @@ const DatabaseService = {
ensureDb: async () => {
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) => {
try {
await DatabaseService.ensureDb();
if (!(await DatabaseService.ensureDb())) return null;
const hashedPassword = await CredentialEncryption.hashPassword(password);
const result = await db.runAsync(
'INSERT INTO users (username, email, password) VALUES (?, ?, ?)',
@@ -93,98 +106,111 @@ const DatabaseService = {
);
return result.lastInsertRowId;
} catch (error) {
console.error('Error creating user:', error);
throw error;
logError('Create User', error);
return null;
}
},
// Authenticate by username or email
loginUser: async (identifier, password) => {
try {
await DatabaseService.ensureDb();
if (!(await DatabaseService.ensureDb())) return null;
const row = await db.getFirstAsync(
'SELECT * FROM users WHERE username = ? OR email = ?',
[identifier, identifier]
);
if (!row) return null;
const valid = await CredentialEncryption.verifyPassword(password, row.password);
if (!valid) return null;
return row;
return valid ? row : null;
} catch (error) {
console.error('Error logging in user:', error);
throw error;
logError('Login User', error);
return null;
}
},
userExists: async (username, email) => {
try {
await DatabaseService.ensureDb();
if (!(await DatabaseService.ensureDb())) return false;
const row = await db.getFirstAsync(
'SELECT id FROM users WHERE username = ? OR email = ?',
[username, email]
);
return !!row;
} catch (error) {
console.error('Error checking if user exists:', error);
throw error;
logError('User Exists', error);
return false;
}
},
getUserById: async (userId) => {
try {
await DatabaseService.ensureDb();
if (!userId && userId !== 0) throw new Error('User ID is required');
if (!(await DatabaseService.ensureDb())) return null;
if (!userId && userId !== 0) {
logError('Get User', 'User ID is required');
return null;
}
return (await db.getFirstAsync('SELECT * FROM users WHERE id = ?', [userId])) || null;
} catch (error) {
console.error('Error getting user:', error);
throw error;
logError('Get User', error);
return null;
}
},
// Link an external service account (Google, iCloud, NAS) to a local user
// ─── Linked Accounts ──────────────────────────────────────
linkAccount: async (userId, serviceType, accountData, isDefault = false) => {
try {
await DatabaseService.ensureDb();
if (!userId) throw new Error('User ID is required');
if (!serviceType) throw new Error('Service type is required');
if (!accountData || typeof accountData !== 'object') throw new Error('Account data must be a valid object');
if (!(await DatabaseService.ensureDb())) return null;
if (!userId || !serviceType || !accountData || typeof accountData !== 'object') {
logError('Link Account', 'userId, serviceType, and accountData object are required');
return null;
}
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
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(
'INSERT INTO linked_accounts (user_id, service_type, account_data, is_default) VALUES (?, ?, ?, ?)',
[userId, serviceType, encryptedData, isDefault ? 1 : 0]
);
return result.lastInsertRowId;
} catch (error) {
console.error('Error linking account:', error);
throw new Error('Failed to link account: ' + error.message);
logError('Link Account', error);
return null;
}
},
getLinkedAccounts: async (userId) => {
try {
await DatabaseService.ensureDb();
if (!userId && userId !== 0) throw new Error('User ID is required');
if (!(await DatabaseService.ensureDb())) return [];
if (!userId && userId !== 0) {
logError('Get Linked Accounts', 'User ID is required');
return [];
}
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) {
console.error('Error getting linked accounts:', error);
throw error;
logError('Get Linked Accounts', error);
return [];
}
},
getLinkedAccountByService: async (userId, serviceType) => {
try {
await DatabaseService.ensureDb();
if (!(await DatabaseService.ensureDb())) return null;
const row = await db.getFirstAsync(
'SELECT * FROM linked_accounts WHERE user_id = ? AND service_type = ? AND is_enabled = 1',
[userId, serviceType]
@@ -192,40 +218,83 @@ const DatabaseService = {
if (!row) return null;
return { ...row, account_data: CredentialEncryption.decrypt(row.account_data) };
} catch (error) {
console.error('Error getting linked account:', error);
throw error;
logError('Get Linked Account By Service', error);
return null;
}
},
setDefaultService: async (userId, accountId) => {
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 = 1 WHERE id = ? AND user_id = ?', [accountId, userId]);
} catch (error) {
console.error('Error setting default service:', error);
throw error;
logError('Set Default Service', error);
}
},
getDefaultService: async (userId) => {
try {
await DatabaseService.ensureDb();
if (!(await DatabaseService.ensureDb())) return null;
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;
return { ...row, account_data: CredentialEncryption.decrypt(row.account_data) };
} catch (error) {
console.error('Error getting default service:', error);
throw error;
logError('Get Default Service', 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) => {
try {
await DatabaseService.ensureDb();
if (!userId || !sourceService) throw new Error('userId and sourceService are required');
if (!(await DatabaseService.ensureDb())) return null;
if (!userId || !sourceService) {
logError('Add Media', 'userId and sourceService are required');
return null;
}
const result = await db.runAsync(
`INSERT INTO media (user_id, source_service, source_id, title, description, local_path, remote_path, media_type, created_date)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
@@ -234,8 +303,8 @@ const DatabaseService = {
);
return result.lastInsertRowId;
} catch (error) {
console.error('Error adding media:', error);
throw error;
logError('Add Media', error);
return null;
}
},
@@ -245,10 +314,10 @@ const DatabaseService = {
*/
saveMediaItem: async (userId, item) => {
try {
await DatabaseService.ensureDb();
if (!userId || !item.source_service) return;
if (!(await DatabaseService.ensureDb())) return null;
if (!userId || !item?.source_service) return null;
// Check for existing entry to avoid duplicates
// Skip if already exists
if (item.source_id) {
const existing = await db.getFirstAsync(
'SELECT id FROM media WHERE user_id = ? AND source_id = ?',
@@ -266,108 +335,94 @@ const DatabaseService = {
);
return result.lastInsertRowId;
} catch (error) {
console.error('Error saving media item:', error);
logError('Save Media Item', error);
return null;
}
},
getMediaByUser: async (userId) => {
try {
await DatabaseService.ensureDb();
if (!userId && userId !== 0) throw new Error('User ID is required');
return await db.getAllAsync('SELECT * FROM media WHERE user_id = ? ORDER BY created_date DESC', [userId]);
if (!(await DatabaseService.ensureDb())) return [];
if (!userId && userId !== 0) {
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) {
console.error('Error getting media:', error);
throw error;
logError('Get Media', error);
return [];
}
},
getMediaByService: async (userId, serviceType) => {
try {
await DatabaseService.ensureDb();
if (!(await DatabaseService.ensureDb())) return [];
return await db.getAllAsync(
'SELECT * FROM media WHERE user_id = ? AND source_service = ? ORDER BY created_date DESC',
[userId, serviceType]
);
} catch (error) {
console.error('Error getting media by service:', error);
throw error;
logError('Get Media By Service', error);
return [];
}
},
searchMedia: async (userId, query) => {
try {
await DatabaseService.ensureDb();
if (!(await DatabaseService.ensureDb())) return [];
const searchTerm = `%${query}%`;
return await db.getAllAsync(
'SELECT * FROM media WHERE user_id = ? AND (title LIKE ? OR description LIKE ?) ORDER BY created_date DESC',
[userId, searchTerm, searchTerm]
);
} catch (error) {
console.error('Error searching media:', error);
throw error;
logError('Search Media', error);
return [];
}
},
// ─── Settings ──────────────────────────────────────────────
getAutoBackupServices: async (userId) => {
try {
await DatabaseService.ensureDb();
const row = await db.getFirstAsync('SELECT auto_backup_services FROM users WHERE id = ?', [userId]);
if (!(await DatabaseService.ensureDb())) return [];
const row = await db.getFirstAsync(
'SELECT auto_backup_services FROM users WHERE id = ?',
[userId]
);
if (!row?.auto_backup_services) return [];
return JSON.parse(row.auto_backup_services);
} catch (error) {
console.error('Error getting auto backup services:', error);
logError('Get Auto Backup', error);
return [];
}
},
setAutoBackupServices: async (userId, services) => {
try {
await DatabaseService.ensureDb();
await db.runAsync('UPDATE users SET auto_backup_services = ? WHERE id = ?', [JSON.stringify(services), userId]);
if (!(await DatabaseService.ensureDb())) return;
await db.runAsync(
'UPDATE users SET auto_backup_services = ? WHERE id = ?',
[JSON.stringify(services), userId]
);
} catch (error) {
console.error('Error setting auto backup services:', error);
throw error;
logError('Set Auto Backup', error);
}
},
toggleAccountEnabled: async (accountId, enabled) => {
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;
}
},
// ─── Lifecycle ─────────────────────────────────────────────
closeDatabase: async () => {
try {
if (db?.closeAsync) await db.closeAsync();
} catch (error) {
logError('Close DB', error);
} finally {
db = null;
initPromise = null;
} catch (error) {
console.error('Error closing database:', error);
}
},
};