Files
web-cad/backend/src/database/SqliteAdapter.ts
T

459 lines
21 KiB
TypeScript
Raw Normal View History

/**
* 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<void> {
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>): 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>): 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>): 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>): 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>): 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>): 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>): 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>): 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>): 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>): 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>): 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>): 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>): 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>): 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>): 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>): 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>): 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>): 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>): 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>): 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>): 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;
}
}