/** * SQLite Adapter – implements DatabaseInterface with better-sqlite3 */ import Database from 'better-sqlite3'; import { readFileSync } from 'fs'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; import { randomUUID } from 'crypto'; import type { DatabaseInterface, DBProject, DBDrawing, DBLayer, DBElement, DBBlock, DBSetting, DBUser, DBSession, DBNotification, DBProjectShare, DBGlobalBlockFolder, DBGlobalBlock, DBProjectFolder, } from './DatabaseInterface.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); export class SqliteAdapter implements DatabaseInterface { private db: Database.Database; constructor(dbPath: string = ':memory:') { this.db = new Database(dbPath); this.db.pragma('journal_mode = WAL'); this.db.pragma('foreign_keys = ON'); } async init(): Promise { const schemaPath = join(__dirname, 'schema.sql'); const schema = readFileSync(schemaPath, 'utf-8'); this.db.exec(schema); // Add folder_id column to projects table for existing databases const columns = this.db.prepare("PRAGMA table_info('projects')").all() as { name: string }[]; if (!columns.some(col => col.name === 'folder_id')) { this.db.exec('ALTER TABLE projects ADD COLUMN folder_id TEXT REFERENCES project_folders(id) ON DELETE SET NULL'); } } close(): void { this.db.close(); } // ─── Users ────────────────────────────────────────── getUser(id: string): DBUser | null { return (this.db.prepare('SELECT * FROM users WHERE id = ?').get(id) as DBUser) ?? null; } getUserByEmail(email: string): DBUser | null { return (this.db.prepare('SELECT * FROM users WHERE email = ?').get(email) as DBUser) ?? null; } createUser(data: Partial): DBUser { const id = data.id ?? `user-${Date.now()}`; this.db.prepare( 'INSERT INTO users (id, email, password_hash, name, role) VALUES (?, ?, ?, ?, ?)', ).run(id, data.email!, data.password_hash!, data.name!, data.role ?? 'planer'); return this.getUser(id)!; } updateUser(id: string, data: Partial): DBUser | null { const sets: string[] = []; const vals: any[] = []; if (data.email !== undefined) { sets.push('email = ?'); vals.push(data.email); } if (data.password_hash !== undefined) { sets.push('password_hash = ?'); vals.push(data.password_hash); } if (data.name !== undefined) { sets.push('name = ?'); vals.push(data.name); } if (data.role !== undefined) { sets.push('role = ?'); vals.push(data.role); } sets.push("updated_at = datetime('now')"); if (sets.length > 1) { this.db.prepare(`UPDATE users SET ${sets.join(', ')} WHERE id = ?`).run(...vals, id); } return this.getUser(id); } deleteUser(id: string): boolean { return this.db.prepare('DELETE FROM users WHERE id = ?').run(id).changes > 0; } listUsers(): DBUser[] { return this.db.prepare('SELECT * FROM users ORDER BY created_at DESC').all() as DBUser[]; } // ─── Projects ────────────────────────────────────── listProjects(ownerId?: string, folderId?: string | null): DBProject[] { if (ownerId && folderId !== undefined) { if (folderId === null) { return this.db.prepare('SELECT * FROM projects WHERE owner_id = ? AND folder_id IS NULL ORDER BY updated_at DESC').all(ownerId) as DBProject[]; } return this.db.prepare('SELECT * FROM projects WHERE owner_id = ? AND folder_id = ? ORDER BY updated_at DESC').all(ownerId, folderId) as DBProject[]; } if (ownerId) { return this.db.prepare('SELECT * FROM projects WHERE owner_id = ? ORDER BY updated_at DESC').all(ownerId) as DBProject[]; } if (folderId !== undefined) { if (folderId === null) { return this.db.prepare('SELECT * FROM projects WHERE folder_id IS NULL ORDER BY updated_at DESC').all() as DBProject[]; } return this.db.prepare('SELECT * FROM projects WHERE folder_id = ? ORDER BY updated_at DESC').all(folderId) as DBProject[]; } return this.db.prepare('SELECT * FROM projects ORDER BY updated_at DESC').all() as DBProject[]; } getProject(id: string): DBProject | null { return (this.db.prepare('SELECT * FROM projects WHERE id = ?').get(id) as DBProject) ?? null; } createProject(data: Partial): DBProject { const id = data.id ?? `proj-${Date.now()}`; this.db.prepare( 'INSERT INTO projects (id, name, description, owner_id, folder_id) VALUES (?, ?, ?, ?, ?)', ).run(id, data.name ?? 'Unbenannt', data.description ?? null, data.owner_id ?? 'user-default', data.folder_id ?? null); return this.getProject(id)!; } updateProject(id: string, data: Partial): DBProject | null { const sets: string[] = []; const vals: unknown[] = []; if (data.name !== undefined) { sets.push('name = ?'); vals.push(data.name); } if (data.description !== undefined) { sets.push('description = ?'); vals.push(data.description); } if (data.folder_id !== undefined) { sets.push('folder_id = ?'); vals.push(data.folder_id); } sets.push("updated_at = datetime('now')"); if (sets.length > 1) { this.db.prepare(`UPDATE projects SET ${sets.join(', ')} WHERE id = ?`).run(...vals, id); } return this.getProject(id); } deleteProject(id: string): boolean { return this.db.prepare('DELETE FROM projects WHERE id = ?').run(id).changes > 0; } moveProjectToFolder(projectId: string, folderId: string | null): DBProject | null { this.db.prepare('UPDATE projects SET folder_id = ?, updated_at = datetime(\'now\') WHERE id = ?').run(folderId, projectId); return this.getProject(projectId); } // ─── Project Folders ───────────────────────────────── listProjectFolders(ownerId: string): DBProjectFolder[] { return this.db.prepare('SELECT * FROM project_folders WHERE owner_id = ? ORDER BY name').all(ownerId) as DBProjectFolder[]; } getProjectFolder(id: string): DBProjectFolder | null { return (this.db.prepare('SELECT * FROM project_folders WHERE id = ?').get(id) as DBProjectFolder) ?? null; } createProjectFolder(data: Partial): DBProjectFolder { const id = data.id ?? `pfolder-${Date.now()}`; this.db.prepare( 'INSERT INTO project_folders (id, name, parent_id, owner_id) VALUES (?, ?, ?, ?)', ).run(id, data.name ?? 'Neuer Ordner', data.parent_id ?? null, data.owner_id ?? 'user-default'); return this.getProjectFolder(id)!; } updateProjectFolder(id: string, data: Partial): DBProjectFolder | null { const sets: string[] = []; const vals: unknown[] = []; if (data.name !== undefined) { sets.push('name = ?'); vals.push(data.name); } if (data.parent_id !== undefined) { sets.push('parent_id = ?'); vals.push(data.parent_id); } if (sets.length > 0) { this.db.prepare(`UPDATE project_folders SET ${sets.join(', ')} WHERE id = ?`).run(...vals, id); } return this.getProjectFolder(id); } deleteProjectFolder(id: string): boolean { // Move projects inside this folder to root (folder_id = NULL) before deleting this.db.prepare('UPDATE projects SET folder_id = NULL WHERE folder_id = ?').run(id); return this.db.prepare('DELETE FROM project_folders WHERE id = ?').run(id).changes > 0; } // ─── Drawings ────────────────────────────────────── listDrawings(projectId: string): DBDrawing[] { return this.db.prepare('SELECT * FROM drawings WHERE project_id = ? ORDER BY updated_at DESC').all(projectId) as DBDrawing[]; } getDrawing(id: string): DBDrawing | null { return (this.db.prepare('SELECT * FROM drawings WHERE id = ?').get(id) as DBDrawing) ?? null; } createDrawing(data: Partial): DBDrawing { const id = data.id ?? `draw-${Date.now()}`; this.db.prepare( 'INSERT INTO drawings (id, project_id, name, data_json) VALUES (?, ?, ?, ?)', ).run(id, data.project_id!, data.name ?? 'Unbenannt', data.data_json ?? '{}'); return this.getDrawing(id)!; } updateDrawing(id: string, data: Partial): DBDrawing | null { const sets: string[] = []; const vals: any[] = []; if (data.name !== undefined) { sets.push('name = ?'); vals.push(data.name); } if (data.data_json !== undefined) { sets.push('data_json = ?'); vals.push(data.data_json); } sets.push("updated_at = datetime('now')"); if (sets.length > 1) { this.db.prepare(`UPDATE drawings SET ${sets.join(', ')} WHERE id = ?`).run(...vals, id); } return this.getDrawing(id); } deleteDrawing(id: string): boolean { return this.db.prepare('DELETE FROM drawings WHERE id = ?').run(id).changes > 0; } // ─── Layers ──────────────────────────────────────── listLayers(drawingId: string): DBLayer[] { return this.db.prepare('SELECT * FROM layers WHERE drawing_id = ? ORDER BY sort_order').all(drawingId) as DBLayer[]; } createLayer(data: Partial): DBLayer { const id = data.id ?? `layer-${Date.now()}`; this.db.prepare( 'INSERT INTO layers (id, drawing_id, name, visible, locked, color, line_type, transparency, sort_order, parent_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', ).run(id, data.drawing_id!, data.name ?? 'Layer', data.visible !== undefined ? (data.visible ? 1 : 0) : 1, data.locked !== undefined ? (data.locked ? 1 : 0) : 0, data.color ?? '#ffffff', data.line_type ?? 'solid', data.transparency ?? 0, data.sort_order ?? 0, data.parent_id ?? null); return this.db.prepare('SELECT * FROM layers WHERE id = ?').get(id) as DBLayer; } updateLayer(id: string, data: Partial): DBLayer | null { const sets: string[] = []; const vals: any[] = []; for (const [k, v] of Object.entries(data)) { if (['name','visible','locked','color','line_type','transparency','sort_order','parent_id'].includes(k)) { sets.push(`${k} = ?`); vals.push(k === 'visible' || k === 'locked' ? (v ? 1 : 0) : v); } } if (sets.length > 0) { this.db.prepare(`UPDATE layers SET ${sets.join(', ')} WHERE id = ?`).run(...vals, id); } return this.db.prepare('SELECT * FROM layers WHERE id = ?').get(id) as DBLayer ?? null; } deleteLayer(id: string): boolean { return this.db.prepare('DELETE FROM layers WHERE id = ?').run(id).changes > 0; } // ─── Elements ────────────────────────────────────── listElements(drawingId: string): DBElement[] { return this.db.prepare('SELECT * FROM elements WHERE drawing_id = ?').all(drawingId) as DBElement[]; } createElement(data: Partial): DBElement { const id = data.id ?? `elem-${Date.now()}`; this.db.prepare( 'INSERT INTO elements (id, drawing_id, layer_id, type, x, y, width, height, properties_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', ).run(id, data.drawing_id!, data.layer_id!, data.type!, data.x ?? 0, data.y ?? 0, data.width ?? 0, data.height ?? 0, data.properties_json ?? '{}'); return this.db.prepare('SELECT * FROM elements WHERE id = ?').get(id) as DBElement; } updateElement(id: string, data: Partial): DBElement | null { const sets: string[] = []; const vals: any[] = []; for (const [k, v] of Object.entries(data)) { if (['layer_id','type','x','y','width','height','properties_json'].includes(k)) { sets.push(`${k} = ?`); vals.push(v); } } if (sets.length > 0) { this.db.prepare(`UPDATE elements SET ${sets.join(', ')} WHERE id = ?`).run(...vals, id); } return this.db.prepare('SELECT * FROM elements WHERE id = ?').get(id) as DBElement ?? null; } deleteElement(id: string): boolean { return this.db.prepare('DELETE FROM elements WHERE id = ?').run(id).changes > 0; } // ─── Blocks ──────────────────────────────────────── listBlocks(drawingId: string): DBBlock[] { return this.db.prepare('SELECT * FROM blocks WHERE drawing_id = ?').all(drawingId) as DBBlock[]; } createBlock(data: Partial): DBBlock { const id = data.id ?? `block-${Date.now()}`; this.db.prepare( 'INSERT INTO blocks (id, drawing_id, name, description, category, elements_json, thumbnail) VALUES (?, ?, ?, ?, ?, ?, ?)', ).run(id, data.drawing_id!, data.name ?? 'Block', data.description ?? null, data.category ?? 'Allgemein', data.elements_json ?? '[]', data.thumbnail ?? null); return this.db.prepare('SELECT * FROM blocks WHERE id = ?').get(id) as DBBlock; } updateBlock(id: string, data: Partial): DBBlock | null { const sets: string[] = []; const vals: any[] = []; for (const [k, v] of Object.entries(data)) { if (['name','description','category','elements_json','thumbnail'].includes(k)) { sets.push(`${k} = ?`); vals.push(v); } } if (sets.length > 0) { this.db.prepare(`UPDATE blocks SET ${sets.join(', ')} WHERE id = ?`).run(...vals, id); } return this.db.prepare('SELECT * FROM blocks WHERE id = ?').get(id) as DBBlock ?? null; } deleteBlock(id: string): boolean { return this.db.prepare('DELETE FROM blocks WHERE id = ?').run(id).changes > 0; } // ─── Settings ────────────────────────────────────── getSetting(key: string): DBSetting | null { return (this.db.prepare('SELECT * FROM settings WHERE key = ?').get(key) as DBSetting) ?? null; } setSetting(key: string, value: string): void { this.db.prepare( "INSERT INTO settings (key, value, updated_at) VALUES (?, ?, datetime('now')) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = datetime('now')", ).run(key, value); } // ─── Sessions ─────────────────────────────────────── createSession(data: Partial): DBSession { const token = data.token ?? randomUUID(); this.db.prepare( 'INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)', ).run(token, data.user_id!, data.expires_at!); return this.getSession(token)!; } getSession(token: string): DBSession | null { return (this.db.prepare('SELECT * FROM sessions WHERE token = ?').get(token) as DBSession) ?? null; } deleteSession(token: string): boolean { return this.db.prepare('DELETE FROM sessions WHERE token = ?').run(token).changes > 0; } deleteExpiredSessions(): void { // Use consistent epoch-millisecond integer comparison. // <= ensures sessions expiring at exactly the current time are also deleted. const now = Math.floor(Date.now()); this.db.prepare('DELETE FROM sessions WHERE expires_at <= ?').run(now); } // ─── Notifications ────────────────────────────────── listNotifications(userId: string): DBNotification[] { return this.db.prepare('SELECT * FROM notifications WHERE user_id = ? ORDER BY created_at DESC').all(userId) as DBNotification[]; } getNotification(id: string): DBNotification | null { return this.db.prepare('SELECT * FROM notifications WHERE id = ?').get(id) as DBNotification | null; } createNotification(data: Partial): DBNotification { const id = data.id ?? `notif-${Date.now()}`; this.db.prepare( 'INSERT INTO notifications (id, user_id, type, title, message, read) VALUES (?, ?, ?, ?, ?, ?)', ).run(id, data.user_id!, data.type ?? 'info', data.title!, data.message!, data.read ?? 0); return this.db.prepare('SELECT * FROM notifications WHERE id = ?').get(id) as DBNotification; } markNotificationRead(id: string): boolean { return this.db.prepare('UPDATE notifications SET read = 1 WHERE id = ?').run(id).changes > 0; } deleteNotification(id: string): boolean { return this.db.prepare('DELETE FROM notifications WHERE id = ?').run(id).changes > 0; } // ─── Project Shares ───────────────────────────────── listProjectShares(projectId: string): DBProjectShare[] { return this.db.prepare('SELECT * FROM project_shares WHERE project_id = ? ORDER BY created_at DESC').all(projectId) as DBProjectShare[]; } getProjectShare(id: string): DBProjectShare | null { return this.db.prepare('SELECT * FROM project_shares WHERE id = ?').get(id) as DBProjectShare | null; } createProjectShare(data: Partial): DBProjectShare { const id = data.id ?? `share-${Date.now()}`; const shareToken = data.share_token ?? randomUUID(); this.db.prepare( 'INSERT INTO project_shares (id, project_id, shared_with_email, shared_by, permission, share_token) VALUES (?, ?, ?, ?, ?, ?)', ).run(id, data.project_id!, data.shared_with_email!, data.shared_by!, data.permission ?? 'view', shareToken); return this.db.prepare('SELECT * FROM project_shares WHERE id = ?').get(id) as DBProjectShare; } deleteProjectShare(id: string): boolean { return this.db.prepare('DELETE FROM project_shares WHERE id = ?').run(id).changes > 0; } // ─── Global Block Folders ────────────────────────────── listGlobalFolders(parentId?: string | null): DBGlobalBlockFolder[] { if (parentId === undefined) { return this.db.prepare('SELECT * FROM global_block_folders ORDER BY name').all() as DBGlobalBlockFolder[]; } if (parentId === null) { return this.db.prepare('SELECT * FROM global_block_folders WHERE parent_id IS NULL ORDER BY name').all() as DBGlobalBlockFolder[]; } return this.db.prepare('SELECT * FROM global_block_folders WHERE parent_id = ? ORDER BY name').all(parentId) as DBGlobalBlockFolder[]; } getGlobalFolder(id: string): DBGlobalBlockFolder | null { return (this.db.prepare('SELECT * FROM global_block_folders WHERE id = ?').get(id) as DBGlobalBlockFolder) ?? null; } createGlobalFolder(data: Partial): DBGlobalBlockFolder { const id = data.id ?? `gfolder-${Date.now()}`; this.db.prepare( 'INSERT INTO global_block_folders (id, name, parent_id) VALUES (?, ?, ?)', ).run(id, data.name ?? 'Neuer Ordner', data.parent_id ?? null); return this.getGlobalFolder(id)!; } updateGlobalFolder(id: string, data: Partial): DBGlobalBlockFolder | null { const sets: string[] = []; const vals: unknown[] = []; if (data.name !== undefined) { sets.push('name = ?'); vals.push(data.name); } if (data.parent_id !== undefined) { sets.push('parent_id = ?'); vals.push(data.parent_id); } if (sets.length > 0) { this.db.prepare(`UPDATE global_block_folders SET ${sets.join(', ')} WHERE id = ?`).run(...vals, id); } return this.getGlobalFolder(id); } deleteGlobalFolder(id: string): boolean { return this.db.prepare('DELETE FROM global_block_folders WHERE id = ?').run(id).changes > 0; } // ─── Global Blocks ───────────────────────────────────── listGlobalBlocks(folderId?: string | null): DBGlobalBlock[] { if (folderId === undefined) { return this.db.prepare('SELECT * FROM global_blocks ORDER BY updated_at DESC').all() as DBGlobalBlock[]; } if (folderId === null) { return this.db.prepare('SELECT * FROM global_blocks WHERE folder_id IS NULL ORDER BY updated_at DESC').all() as DBGlobalBlock[]; } return this.db.prepare('SELECT * FROM global_blocks WHERE folder_id = ? ORDER BY updated_at DESC').all(folderId) as DBGlobalBlock[]; } getGlobalBlock(id: string): DBGlobalBlock | null { return (this.db.prepare('SELECT * FROM global_blocks WHERE id = ?').get(id) as DBGlobalBlock) ?? null; } createGlobalBlock(data: Partial): DBGlobalBlock { const id = data.id ?? `gblock-${Date.now()}`; this.db.prepare( 'INSERT INTO global_blocks (id, folder_id, name, block_data, svg_data) VALUES (?, ?, ?, ?, ?)', ).run(id, data.folder_id ?? null, data.name ?? 'Global Block', data.block_data ?? '{}', data.svg_data ?? null); return this.getGlobalBlock(id)!; } updateGlobalBlock(id: string, data: Partial): DBGlobalBlock | null { const sets: string[] = []; const vals: unknown[] = []; if (data.name !== undefined) { sets.push('name = ?'); vals.push(data.name); } if (data.folder_id !== undefined) { sets.push('folder_id = ?'); vals.push(data.folder_id); } if (data.block_data !== undefined) { sets.push('block_data = ?'); vals.push(data.block_data); } if (data.svg_data !== undefined) { sets.push('svg_data = ?'); vals.push(data.svg_data); } sets.push("updated_at = datetime('now')"); this.db.prepare(`UPDATE global_blocks SET ${sets.join(', ')} WHERE id = ?`).run(...vals, id); return this.getGlobalBlock(id); } deleteGlobalBlock(id: string): boolean { return this.db.prepare('DELETE FROM global_blocks WHERE id = ?').run(id).changes > 0; } }