246 lines
9.5 KiB
TypeScript
246 lines
9.5 KiB
TypeScript
import Database from 'better-sqlite3';
|
|
import { join, dirname } from 'path';
|
|
import { existsSync, mkdirSync } from 'fs';
|
|
import { fileURLToPath } from 'url';
|
|
import type { CV, CVData, CVSettings } from '../types';
|
|
import { DEFAULT_DATA, DEFAULT_SETTINGS } from '../types';
|
|
|
|
const DB_PATH = import.meta.env.DB_PATH || join(process.cwd(), 'data', 'lebenslauf.db');
|
|
const dir = dirname(DB_PATH);
|
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
|
|
let _db: Database.Database | null = null;
|
|
|
|
export function getDb(): Database.Database {
|
|
if (!_db) {
|
|
_db = new Database(DB_PATH);
|
|
_db.pragma('journal_mode = WAL');
|
|
_db.pragma('foreign_keys = ON');
|
|
initSchema(_db);
|
|
}
|
|
return _db;
|
|
}
|
|
|
|
function initSchema(db: Database.Database) {
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS users (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
email TEXT UNIQUE NOT NULL,
|
|
otp TEXT,
|
|
otp_expires INTEGER,
|
|
created_at INTEGER DEFAULT (unixepoch())
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS sessions (
|
|
id TEXT PRIMARY KEY,
|
|
user_id INTEGER NOT NULL,
|
|
expires_at INTEGER NOT NULL,
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS profiles (
|
|
id TEXT PRIMARY KEY,
|
|
user_id INTEGER NOT NULL,
|
|
title TEXT NOT NULL DEFAULT 'Mein Profil',
|
|
language TEXT NOT NULL DEFAULT 'de',
|
|
data TEXT NOT NULL DEFAULT '{}',
|
|
created_at INTEGER DEFAULT (unixepoch()),
|
|
updated_at INTEGER DEFAULT (unixepoch()),
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS cvs (
|
|
id TEXT PRIMARY KEY,
|
|
user_id INTEGER NOT NULL,
|
|
profile_id TEXT NOT NULL,
|
|
hash TEXT UNIQUE NOT NULL,
|
|
title TEXT NOT NULL DEFAULT 'Mein Lebenslauf',
|
|
template INTEGER NOT NULL DEFAULT 1,
|
|
settings TEXT NOT NULL DEFAULT '{}',
|
|
public INTEGER NOT NULL DEFAULT 0,
|
|
created_at INTEGER DEFAULT (unixepoch()),
|
|
updated_at INTEGER DEFAULT (unixepoch()),
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
|
FOREIGN KEY (profile_id) REFERENCES profiles(id) ON DELETE CASCADE
|
|
);
|
|
`);
|
|
|
|
// Migration for cvs: add profile_id if not exists
|
|
try {
|
|
const tableInfo = db.prepare('PRAGMA table_info(cvs)').all() as any[];
|
|
const hasProfileId = tableInfo.some(col => col.name === 'profile_id');
|
|
if (!hasProfileId) {
|
|
db.prepare('ALTER TABLE cvs ADD COLUMN profile_id TEXT').run();
|
|
|
|
// Migrate embedded data into profiles
|
|
const allOldCVs = db.prepare('SELECT * FROM cvs WHERE profile_id IS NULL').all() as any[];
|
|
for (const old of allOldCVs) {
|
|
const profId = Math.random().toString(36).slice(2, 10);
|
|
db.prepare('INSERT INTO profiles (id, user_id, title, language, data, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)')
|
|
.run(profId, old.user_id, old.title + ' (Data)', 'de', old.data || '{}', old.created_at, old.updated_at);
|
|
db.prepare('UPDATE cvs SET profile_id = ? WHERE id = ?').run(profId, old.id);
|
|
}
|
|
}
|
|
} catch(e) { console.error("Migration error:", e); }
|
|
}
|
|
|
|
// ── Users ─────────────────────────────────────────────────────────────
|
|
export function getUser(email: string) {
|
|
const db = getDb();
|
|
return db.prepare('SELECT * FROM users WHERE email = ?').get(email) as any;
|
|
}
|
|
|
|
export function upsertUser(email: string) {
|
|
const db = getDb();
|
|
db.prepare('INSERT OR IGNORE INTO users (email) VALUES (?)').run(email);
|
|
return getUser(email);
|
|
}
|
|
|
|
export function setOTP(email: string, otp: string, expiresAt: number) {
|
|
const db = getDb();
|
|
db.prepare('UPDATE users SET otp = ?, otp_expires = ? WHERE email = ?').run(otp, expiresAt, email);
|
|
}
|
|
|
|
export function consumeOTP(email: string, otp: string): boolean {
|
|
const db = getDb();
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const user = db.prepare(
|
|
'SELECT id FROM users WHERE email = ? AND otp = ? AND otp_expires > ?'
|
|
).get(email, otp, now) as any;
|
|
if (user) {
|
|
db.prepare('UPDATE users SET otp = NULL, otp_expires = NULL WHERE email = ?').run(email);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// ── Sessions ──────────────────────────────────────────────────────────
|
|
export function createSession(userId: number, sessionId: string, expiresAt: number) {
|
|
const db = getDb();
|
|
db.prepare('INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, ?)').run(sessionId, userId, expiresAt);
|
|
}
|
|
|
|
export function getSession(sessionId: string) {
|
|
const db = getDb();
|
|
const now = Math.floor(Date.now() / 1000);
|
|
return db.prepare(`
|
|
SELECT s.*, u.email, u.id as user_id FROM sessions s
|
|
JOIN users u ON s.user_id = u.id
|
|
WHERE s.id = ? AND s.expires_at > ?
|
|
`).get(sessionId, now) as any;
|
|
}
|
|
|
|
export function deleteSession(sessionId: string) {
|
|
const db = getDb();
|
|
db.prepare('DELETE FROM sessions WHERE id = ?').run(sessionId);
|
|
}
|
|
|
|
// ── Profiles ──────────────────────────────────────────────────────────
|
|
export function getProfilesByUser(userId: number): any[] {
|
|
const db = getDb();
|
|
const rows = db.prepare('SELECT * FROM profiles WHERE user_id = ? ORDER BY updated_at DESC').all(userId) as any[];
|
|
return rows.map(r => ({ ...r, data: JSON.parse(r.data || '{}') }));
|
|
}
|
|
|
|
export function getProfileById(id: string, userId?: number): any | undefined {
|
|
const db = getDb();
|
|
const row = userId
|
|
? db.prepare('SELECT * FROM profiles WHERE id = ? AND user_id = ?').get(id, userId) as any
|
|
: db.prepare('SELECT * FROM profiles WHERE id = ?').get(id) as any;
|
|
return row ? { ...row, data: JSON.parse(row.data || '{}') } : undefined;
|
|
}
|
|
|
|
export function createProfile(userId: number, id: string, title: string, language: string): any {
|
|
const db = getDb();
|
|
db.prepare(`
|
|
INSERT INTO profiles (id, user_id, title, language, data)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
`).run(id, userId, title, language, JSON.stringify(DEFAULT_DATA));
|
|
return getProfileById(id)!;
|
|
}
|
|
|
|
export function updateProfile(id: string, userId: number, updates: Partial<{ title: string; language: string; data: any; }>) {
|
|
const db = getDb();
|
|
const fields: string[] = [];
|
|
const values: any[] = [];
|
|
|
|
if (updates.title !== undefined) { fields.push('title = ?'); values.push(updates.title); }
|
|
if (updates.language !== undefined) { fields.push('language = ?'); values.push(updates.language); }
|
|
if (updates.data !== undefined) { fields.push('data = ?'); values.push(JSON.stringify(updates.data)); }
|
|
|
|
if (fields.length === 0) return;
|
|
fields.push('updated_at = unixepoch()');
|
|
values.push(id, userId);
|
|
|
|
db.prepare(`UPDATE profiles SET ${fields.join(', ')} WHERE id = ? AND user_id = ?`).run(...values);
|
|
}
|
|
|
|
export function deleteProfile(id: string, userId: number) {
|
|
const db = getDb();
|
|
db.prepare('DELETE FROM profiles WHERE id = ? AND user_id = ?').run(id, userId);
|
|
}
|
|
|
|
// ── CVs ───────────────────────────────────────────────────────────────
|
|
export function getCVsByUser(userId: number): CV[] {
|
|
const db = getDb();
|
|
const rows = db.prepare('SELECT * FROM cvs WHERE user_id = ? ORDER BY updated_at DESC').all(userId) as any[];
|
|
return rows.map(parseCV);
|
|
}
|
|
|
|
export function getCVById(id: string, userId?: number): CV | undefined {
|
|
const db = getDb();
|
|
const row = userId
|
|
? db.prepare('SELECT * FROM cvs WHERE id = ? AND user_id = ?').get(id, userId)
|
|
: db.prepare('SELECT * FROM cvs WHERE id = ?').get(id);
|
|
return row ? parseCV(row as any) : undefined;
|
|
}
|
|
|
|
export function getCVByHash(hash: string): CV | undefined {
|
|
const db = getDb();
|
|
const row = db.prepare('SELECT * FROM cvs WHERE hash = ? AND public = 1').get(hash);
|
|
return row ? parseCV(row as any) : undefined;
|
|
}
|
|
|
|
export function createCV(userId: number, id: string, profile_id: string, hash: string, title: string): CV {
|
|
const db = getDb();
|
|
db.prepare(`
|
|
INSERT INTO cvs (id, user_id, profile_id, hash, title, template, settings)
|
|
VALUES (?, ?, ?, ?, ?, 1, ?)
|
|
`).run(id, userId, profile_id, hash, title,
|
|
JSON.stringify(DEFAULT_SETTINGS)
|
|
);
|
|
return getCVById(id)!;
|
|
}
|
|
|
|
export function updateCV(id: string, userId: number, updates: Partial<{
|
|
title: string; template: number; settings: CVSettings; public: boolean;
|
|
}>) {
|
|
const db = getDb();
|
|
const fields: string[] = [];
|
|
const values: any[] = [];
|
|
|
|
if (updates.title !== undefined) { fields.push('title = ?'); values.push(updates.title); }
|
|
if (updates.template !== undefined) { fields.push('template = ?'); values.push(updates.template); }
|
|
if (updates.settings !== undefined) { fields.push('settings = ?'); values.push(JSON.stringify(updates.settings)); }
|
|
if (updates.public !== undefined) { fields.push('public = ?'); values.push(updates.public ? 1 : 0); }
|
|
|
|
if (fields.length === 0) return;
|
|
fields.push('updated_at = unixepoch()');
|
|
values.push(id, userId);
|
|
|
|
db.prepare(`UPDATE cvs SET ${fields.join(', ')} WHERE id = ? AND user_id = ?`).run(...values);
|
|
}
|
|
|
|
export function deleteCV(id: string, userId: number) {
|
|
const db = getDb();
|
|
db.prepare('DELETE FROM cvs WHERE id = ? AND user_id = ?').run(id, userId);
|
|
}
|
|
|
|
function parseCV(row: any): CV {
|
|
return {
|
|
...row,
|
|
settings: JSON.parse(row.settings || '{}'),
|
|
public: Boolean(row.public),
|
|
};
|
|
}
|