const Database = require('better-sqlite3'); const path = require('path'); const fs = require('fs'); const DB_PATH = process.env.DB_PATH || '/app/data/refboard.db'; // Ensure directory exists const dbDir = path.dirname(DB_PATH); if (!fs.existsSync(dbDir)) { fs.mkdirSync(dbDir, { recursive: true }); } const db = new Database(DB_PATH); // Enable WAL mode for better concurrent read performance db.pragma('journal_mode = WAL'); db.pragma('foreign_keys = ON'); // --------------------- // Schema // --------------------- db.exec(` CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, email TEXT UNIQUE NOT NULL, username TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, display_name TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'member', is_active INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS collections ( id TEXT PRIMARY KEY, name TEXT NOT NULL, description TEXT DEFAULT '', created_by TEXT NOT NULL REFERENCES users(id), share_token TEXT UNIQUE, is_public INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS collection_members ( collection_id TEXT NOT NULL REFERENCES collections(id) ON DELETE CASCADE, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, role TEXT NOT NULL DEFAULT 'editor', added_at TEXT NOT NULL DEFAULT (datetime('now')), PRIMARY KEY (collection_id, user_id) ); CREATE TABLE IF NOT EXISTS boards ( id TEXT PRIMARY KEY, collection_id TEXT NOT NULL REFERENCES collections(id) ON DELETE CASCADE, name TEXT NOT NULL, description TEXT DEFAULT '', canvas_state TEXT DEFAULT '{}', thumbnail TEXT DEFAULT NULL, object_count INTEGER NOT NULL DEFAULT 0, created_by TEXT NOT NULL REFERENCES users(id), created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS images ( id TEXT PRIMARY KEY, board_id TEXT NOT NULL REFERENCES boards(id) ON DELETE CASCADE, filename TEXT NOT NULL, mime_type TEXT NOT NULL, file_size INTEGER NOT NULL DEFAULT 0, width INTEGER, height INTEGER, minio_path TEXT NOT NULL, public_url TEXT, uploaded_by TEXT NOT NULL REFERENCES users(id), created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX IF NOT EXISTS idx_collections_created_by ON collections(created_by); CREATE INDEX IF NOT EXISTS idx_collection_members_user ON collection_members(user_id); CREATE INDEX IF NOT EXISTS idx_boards_collection ON boards(collection_id); CREATE INDEX IF NOT EXISTS idx_boards_created_by ON boards(created_by); CREATE INDEX IF NOT EXISTS idx_images_board ON images(board_id); CREATE TABLE IF NOT EXISTS media_jobs ( id TEXT PRIMARY KEY, image_id TEXT NOT NULL REFERENCES images(id) ON DELETE CASCADE, board_id TEXT NOT NULL, type TEXT NOT NULL DEFAULT 'poster', status TEXT NOT NULL DEFAULT 'queued', progress REAL DEFAULT 0, error TEXT, attempts INTEGER NOT NULL DEFAULT 0, result_json TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), started_at TEXT, finished_at TEXT ); CREATE INDEX IF NOT EXISTS idx_media_jobs_status ON media_jobs(status); CREATE INDEX IF NOT EXISTS idx_media_jobs_image ON media_jobs(image_id); CREATE TABLE IF NOT EXISTS pdf_pages ( id TEXT PRIMARY KEY, image_id TEXT NOT NULL REFERENCES images(id) ON DELETE CASCADE, page_number INTEGER NOT NULL, width INTEGER, height INTEGER, thumb_asset_key TEXT, hires_asset_key TEXT, status TEXT NOT NULL DEFAULT 'pending', created_at TEXT NOT NULL DEFAULT (datetime('now')), UNIQUE(image_id, page_number) ); CREATE INDEX IF NOT EXISTS idx_pdf_pages_image ON pdf_pages(image_id); `); // ── Comment Threads & Comments ── db.exec(` CREATE TABLE IF NOT EXISTS comment_threads ( id TEXT PRIMARY KEY, board_id TEXT NOT NULL REFERENCES boards(id) ON DELETE CASCADE, object_id TEXT NOT NULL, anchor_type TEXT NOT NULL DEFAULT 'object', pin_x REAL, pin_y REAL, status TEXT NOT NULL DEFAULT 'open', resolved_by TEXT REFERENCES users(id), resolved_at TEXT, comment_count INTEGER NOT NULL DEFAULT 1, last_commented_at TEXT, last_commented_by TEXT, created_by TEXT NOT NULL REFERENCES users(id), created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX IF NOT EXISTS idx_threads_board ON comment_threads(board_id); CREATE INDEX IF NOT EXISTS idx_threads_object ON comment_threads(board_id, object_id); CREATE TABLE IF NOT EXISTS comments ( id TEXT PRIMARY KEY, thread_id TEXT NOT NULL REFERENCES comment_threads(id) ON DELETE CASCADE, user_id TEXT NOT NULL REFERENCES users(id), author_name TEXT NOT NULL, author_color TEXT, content TEXT NOT NULL, edited_at TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX IF NOT EXISTS idx_comments_thread ON comments(thread_id); `); // ── Object Votes ── db.exec(` CREATE TABLE IF NOT EXISTS object_votes ( board_id TEXT NOT NULL REFERENCES boards(id) ON DELETE CASCADE, object_id TEXT NOT NULL, user_id TEXT NOT NULL REFERENCES users(id), created_at TEXT NOT NULL DEFAULT (datetime('now')), PRIMARY KEY (board_id, object_id, user_id) ); CREATE INDEX IF NOT EXISTS idx_votes_object ON object_votes(board_id, object_id); `); // Migrations — add columns to existing tables try { db.prepare("SELECT thumbnail FROM boards LIMIT 0").get(); } catch { db.exec("ALTER TABLE boards ADD COLUMN thumbnail TEXT DEFAULT NULL"); } try { db.prepare("SELECT object_count FROM boards LIMIT 0").get(); } catch { db.exec("ALTER TABLE boards ADD COLUMN object_count INTEGER NOT NULL DEFAULT 0"); } try { db.prepare("SELECT asset_key FROM images LIMIT 0").get(); } catch { db.exec("ALTER TABLE images ADD COLUMN asset_key TEXT"); } try { db.prepare("SELECT media_type FROM images LIMIT 0").get(); } catch { db.exec("ALTER TABLE images ADD COLUMN media_type TEXT DEFAULT 'image'"); } try { db.prepare("SELECT poster_asset_key FROM images LIMIT 0").get(); } catch { db.exec("ALTER TABLE images ADD COLUMN poster_asset_key TEXT"); } try { db.prepare("SELECT duration FROM images LIMIT 0").get(); } catch { db.exec("ALTER TABLE images ADD COLUMN duration REAL"); } try { db.prepare("SELECT native_width FROM images LIMIT 0").get(); } catch { db.exec("ALTER TABLE images ADD COLUMN native_width INTEGER"); db.exec("ALTER TABLE images ADD COLUMN native_height INTEGER"); } try { db.prepare('SELECT page_count FROM images LIMIT 0').get(); } catch { db.exec('ALTER TABLE images ADD COLUMN page_count INTEGER'); } try { db.prepare('SELECT priority FROM media_jobs LIMIT 0').get(); } catch { db.exec('ALTER TABLE media_jobs ADD COLUMN priority INTEGER DEFAULT 0'); } // --------------------- // Activity log (per-board audit trail) // --------------------- db.exec(` CREATE TABLE IF NOT EXISTS activity_logs ( id TEXT PRIMARY KEY, board_id TEXT NOT NULL, user_id TEXT, actor_name TEXT, actor_email TEXT, action TEXT NOT NULL, target_type TEXT, target_id TEXT, target_label TEXT, metadata TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX IF NOT EXISTS idx_activity_board_time ON activity_logs(board_id, created_at DESC); `); const { v4: _activityUuid } = require('uuid'); /** * Append a single activity entry. Resolves user → denormalised actor fields * at log time so the entry survives user rename / deactivation. */ function logActivity({ boardId, userId, action, targetType, targetId, targetLabel, metadata }) { if (!boardId || !action) return null; let actorName = null; let actorEmail = null; if (userId) { const user = db.prepare('SELECT display_name, email FROM users WHERE id = ?').get(userId); if (user) { actorName = user.display_name; actorEmail = user.email; } } const id = _activityUuid(); db.prepare(` INSERT INTO activity_logs (id, board_id, user_id, actor_name, actor_email, action, target_type, target_id, target_label, metadata) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( id, boardId, userId || null, actorName, actorEmail, action, targetType || null, targetId || null, targetLabel || null, metadata ? JSON.stringify(metadata) : null, ); return db.prepare('SELECT * FROM activity_logs WHERE id = ?').get(id); } function getBoardActivity(boardId, { limit = 50, before = null } = {}) { const lim = Math.max(1, Math.min(parseInt(limit, 10) || 50, 200)); if (before) { return db.prepare(` SELECT * FROM activity_logs WHERE board_id = ? AND created_at < ? ORDER BY created_at DESC LIMIT ? `).all(boardId, before, lim); } return db.prepare(` SELECT * FROM activity_logs WHERE board_id = ? ORDER BY created_at DESC LIMIT ? `).all(boardId, lim); } // --------------------- // Settings (runtime-tunable key/value pairs) // --------------------- db.exec(` CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); `); function getSetting(key) { const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key); return row ? row.value : null; } function setSetting(key, value) { 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, String(value)); } function getAllSettings() { return db.prepare('SELECT key, value, updated_at FROM settings').all(); } function getBoolSetting(key, defaultValue = false) { const v = getSetting(key); if (v === null) return defaultValue; return v === 'true' || v === '1'; } // First-boot: seed allow_self_registration from env var if it has never been // stored. After first boot, the env var is ignored — admins control it from // the dashboard. (function seedSettingsFromEnv() { if (getSetting('allow_self_registration') === null) { const envValue = (process.env.ALLOW_SELF_REGISTRATION || '').toLowerCase() === 'true'; setSetting('allow_self_registration', envValue ? 'true' : 'false'); } })(); // JWT secret resolution. // 1. process.env.JWT_SECRET wins if set (lets ops manage secrets via env). // 2. Otherwise read from settings.jwt_secret. // 3. If neither exists, generate a 64-byte random secret and persist it. // // This removes the need to set JWT_SECRET in .env before first boot. The // first boot of a fresh install lands directly on the Login page in "create // the first admin" mode; tokens minted there are signed with the auto- // generated secret. function getOrCreateJwtSecret() { if (process.env.JWT_SECRET) return process.env.JWT_SECRET; const existing = getSetting('jwt_secret'); if (existing) return existing; const generated = require('crypto').randomBytes(64).toString('base64'); setSetting('jwt_secret', generated); console.log('[db] JWT_SECRET was not set in the environment — generated a fresh one and persisted it in the settings table.'); return generated; } // --------------------- // User helpers // --------------------- function getUserByEmail(email) { return db.prepare('SELECT * FROM users WHERE email = ? AND is_active = 1').get(email); } function getUserById(id) { return db.prepare('SELECT * FROM users WHERE id = ? AND is_active = 1').get(id); } function getUserByUsername(username) { return db.prepare('SELECT * FROM users WHERE username = ? AND is_active = 1').get(username); } function createUser({ id, email, username, passwordHash, displayName, role }) { db.prepare(` INSERT INTO users (id, email, username, password_hash, display_name, role) VALUES (?, ?, ?, ?, ?, ?) `).run(id, email, username, passwordHash, displayName, role || 'member'); return getUserById(id); } function getAllUsers() { return db.prepare('SELECT id, email, username, display_name, role, is_active, created_at, updated_at FROM users').all(); } function updateUserPassword(userId, passwordHash) { db.prepare("UPDATE users SET password_hash = ?, updated_at = datetime('now') WHERE id = ?").run(passwordHash, userId); } function deactivateUser(userId) { db.prepare("UPDATE users SET is_active = 0, updated_at = datetime('now') WHERE id = ?").run(userId); } function getUserCount() { return db.prepare('SELECT COUNT(*) as count FROM users').get().count; } // --------------------- // Collection helpers // --------------------- function getCollections(userId, search, limit = 50, offset = 0) { let query = ` SELECT DISTINCT c.*, cm.role as member_role, (SELECT COUNT(*) FROM boards WHERE collection_id = c.id) as board_count, (SELECT COUNT(*) FROM collection_members WHERE collection_id = c.id) as member_count, (SELECT b.thumbnail FROM boards b WHERE b.collection_id = c.id AND b.thumbnail IS NOT NULL ORDER BY b.updated_at DESC LIMIT 1) as preview_thumbnail, (SELECT GROUP_CONCAT(sub.thumbnail, '|||') FROM (SELECT thumbnail FROM boards WHERE collection_id = c.id AND thumbnail IS NOT NULL ORDER BY updated_at DESC LIMIT 4) sub) as preview_thumbnails FROM collections c LEFT JOIN collection_members cm ON c.id = cm.collection_id AND cm.user_id = ? WHERE (cm.user_id IS NOT NULL OR c.is_public = 1) `; const params = [userId]; if (search) { query += ' AND (c.name LIKE ? OR c.description LIKE ?)'; const searchTerm = `%${search}%`; params.push(searchTerm, searchTerm); } query += ' ORDER BY c.updated_at DESC LIMIT ? OFFSET ?'; params.push(limit, offset); return db.prepare(query).all(...params); } function getCollection(collectionId) { return db.prepare('SELECT * FROM collections WHERE id = ?').get(collectionId); } function getCollectionByShareToken(shareToken) { return db.prepare('SELECT * FROM collections WHERE share_token = ?').get(shareToken); } function createCollection({ id, name, description, createdBy }) { db.prepare(` INSERT INTO collections (id, name, description, created_by) VALUES (?, ?, ?, ?) `).run(id, name, description || '', createdBy); return getCollection(id); } function getCollectionByName(name) { return db.prepare('SELECT * FROM collections WHERE name = ?').get(name); } function updateCollection(collectionId, { name, description, isPublic, shareToken }) { const fields = []; const params = []; if (name !== undefined) { fields.push('name = ?'); params.push(name); } if (description !== undefined) { fields.push('description = ?'); params.push(description); } if (isPublic !== undefined) { fields.push('is_public = ?'); params.push(isPublic ? 1 : 0); } if (shareToken !== undefined) { fields.push('share_token = ?'); params.push(shareToken); } if (fields.length === 0) return getCollection(collectionId); fields.push("updated_at = datetime('now')"); params.push(collectionId); db.prepare(`UPDATE collections SET ${fields.join(', ')} WHERE id = ?`).run(...params); return getCollection(collectionId); } function deleteCollection(collectionId) { db.prepare('DELETE FROM collections WHERE id = ?').run(collectionId); } // --------------------- // Collection members // --------------------- function getCollectionMembers(collectionId) { return db.prepare(` SELECT cm.collection_id, cm.user_id, cm.role, cm.added_at, u.email, u.username, u.display_name FROM collection_members cm JOIN users u ON cm.user_id = u.id WHERE cm.collection_id = ? `).all(collectionId); } function getCollectionMember(collectionId, userId) { return db.prepare('SELECT * FROM collection_members WHERE collection_id = ? AND user_id = ?').get(collectionId, userId); } function addCollectionMember(collectionId, userId, role = 'editor') { db.prepare(` INSERT OR REPLACE INTO collection_members (collection_id, user_id, role) VALUES (?, ?, ?) `).run(collectionId, userId, role); } function removeCollectionMember(collectionId, userId) { db.prepare('DELETE FROM collection_members WHERE collection_id = ? AND user_id = ?').run(collectionId, userId); } /** * Check if user can access a collection (is member, or collection is public). * Returns the member record or null. */ function checkCollectionAccess(collectionId, userId) { const collection = getCollection(collectionId); if (!collection) return { collection: null, member: null }; const member = getCollectionMember(collectionId, userId); if (!member && !collection.is_public) return { collection, member: null }; return { collection, member: member || { role: 'viewer' } }; } // --------------------- // Board helpers // --------------------- function getCollectionBoards(collectionId, search, limit = 50, offset = 0) { let query = ` SELECT b.id, b.collection_id, b.name, b.description, b.thumbnail, b.created_by, b.created_at, b.updated_at, b.object_count as image_count FROM boards b WHERE b.collection_id = ? `; const params = [collectionId]; if (search) { query += ' AND (b.name LIKE ? OR b.description LIKE ?)'; const searchTerm = `%${search}%`; params.push(searchTerm, searchTerm); } query += ' ORDER BY b.updated_at DESC LIMIT ? OFFSET ?'; params.push(limit, offset); return db.prepare(query).all(...params); } function getBoard(boardId) { return db.prepare('SELECT * FROM boards WHERE id = ?').get(boardId); } function createBoard({ id, collectionId, name, description, createdBy }) { db.prepare(` INSERT INTO boards (id, collection_id, name, description, created_by) VALUES (?, ?, ?, ?, ?) `).run(id, collectionId, name, description || '', createdBy); return getBoard(id); } function updateBoard(boardId, { name, description }) { const fields = []; const params = []; if (name !== undefined) { fields.push('name = ?'); params.push(name); } if (description !== undefined) { fields.push('description = ?'); params.push(description); } if (fields.length === 0) return getBoard(boardId); fields.push("updated_at = datetime('now')"); params.push(boardId); db.prepare(`UPDATE boards SET ${fields.join(', ')} WHERE id = ?`).run(...params); return getBoard(boardId); } function deleteBoard(boardId) { db.prepare('DELETE FROM boards WHERE id = ?').run(boardId); } function saveBoardCanvas(boardId, canvasState, thumbnail) { const stateStr = typeof canvasState === 'string' ? canvasState : JSON.stringify(canvasState); // Count objects from canvas state JSON let objectCount = 0; try { const parsed = typeof canvasState === 'string' ? JSON.parse(canvasState) : canvasState; if (parsed && Array.isArray(parsed.objects)) objectCount = parsed.objects.length; } catch {} if (thumbnail) { db.prepare("UPDATE boards SET canvas_state = ?, thumbnail = ?, object_count = ?, updated_at = datetime('now') WHERE id = ?") .run(stateStr, thumbnail, objectCount, boardId); } else { db.prepare("UPDATE boards SET canvas_state = ?, object_count = ?, updated_at = datetime('now') WHERE id = ?") .run(stateStr, objectCount, boardId); } } // --------------------- // Images // --------------------- function createImage({ id, boardId, filename, mimeType, fileSize, width, height, minioPath, publicUrl, uploadedBy, assetKey, mediaType }) { db.prepare(` INSERT INTO images (id, board_id, filename, mime_type, file_size, width, height, minio_path, public_url, uploaded_by, asset_key, media_type) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run(id, boardId, filename, mimeType, fileSize, width || null, height || null, minioPath, publicUrl || null, uploadedBy, assetKey || null, mediaType || 'image'); return db.prepare('SELECT * FROM images WHERE id = ?').get(id); } function getBoardImages(boardId) { return db.prepare('SELECT * FROM images WHERE board_id = ? ORDER BY created_at DESC').all(boardId); } function getImage(imageId) { return db.prepare('SELECT * FROM images WHERE id = ?').get(imageId); } function deleteImage(imageId) { const image = getImage(imageId); db.prepare('DELETE FROM images WHERE id = ?').run(imageId); return image; } function deleteBoardImageRecords(boardId) { const images = getBoardImages(boardId); db.prepare('DELETE FROM images WHERE board_id = ?').run(boardId); return images; } // --------------------- // Media Jobs // --------------------- function createMediaJob({ id, imageId, boardId, type, priority, metaJson }) { db.prepare(` INSERT INTO media_jobs (id, image_id, board_id, type, status, priority, result_json) VALUES (?, ?, ?, ?, 'queued', ?, ?) `).run(id, imageId, boardId, type || 'poster', priority || 0, metaJson || null); return db.prepare('SELECT * FROM media_jobs WHERE id = ?').get(id); } function updateMediaJob(id, updates) { const sets = []; const values = []; for (const [key, val] of Object.entries(updates)) { const col = key.replace(/([A-Z])/g, '_$1').toLowerCase(); // camelCase → snake_case sets.push(`${col} = ?`); values.push(val); } if (sets.length === 0) return; values.push(id); db.prepare(`UPDATE media_jobs SET ${sets.join(', ')} WHERE id = ?`).run(...values); } function getMediaJob(id) { return db.prepare('SELECT * FROM media_jobs WHERE id = ?').get(id); } function getPendingMediaJobs(limit = 10) { return db.prepare('SELECT * FROM media_jobs WHERE status IN (?, ?) ORDER BY priority DESC, created_at ASC LIMIT ?') .all('queued', 'retry', limit); } function updateImageMedia(imageId, { posterAssetKey, duration, nativeWidth, nativeHeight }) { db.prepare(` UPDATE images SET poster_asset_key = ?, duration = ?, native_width = ?, native_height = ? WHERE id = ? `).run(posterAssetKey || null, duration || null, nativeWidth || null, nativeHeight || null, imageId); } // --------------------- // PDF Page helpers // --------------------- function createPdfPage(id, imageId, pageNumber, width, height) { db.prepare(`INSERT INTO pdf_pages (id, image_id, page_number, width, height, status) VALUES (?, ?, ?, ?, ?, 'pending') ON CONFLICT(image_id, page_number) DO UPDATE SET width = ?, height = ?`).run(id, imageId, pageNumber, width, height, width, height); } function updatePdfPageThumb(imageId, pageNumber, thumbAssetKey) { db.prepare(`UPDATE pdf_pages SET thumb_asset_key = ?, status = 'thumb_ready' WHERE image_id = ? AND page_number = ?`).run(thumbAssetKey, imageId, pageNumber); } function updatePdfPageHires(imageId, pageNumber, hiresAssetKey) { db.prepare(`UPDATE pdf_pages SET hires_asset_key = ?, status = 'done' WHERE image_id = ? AND page_number = ?`).run(hiresAssetKey, imageId, pageNumber); } function getPdfPages(imageId) { return db.prepare('SELECT * FROM pdf_pages WHERE image_id = ? ORDER BY page_number ASC').all(imageId); } function getPdfPage(imageId, pageNumber) { return db.prepare('SELECT * FROM pdf_pages WHERE image_id = ? AND page_number = ?').get(imageId, pageNumber); } function updateImagePageCount(imageId, pageCount) { db.prepare('UPDATE images SET page_count = ? WHERE id = ?').run(pageCount, imageId); } // --------------------- // Thread helpers // --------------------- function getThreadsByBoard(boardId) { return db.prepare(` SELECT t.*, u.display_name AS author_name FROM comment_threads t LEFT JOIN users u ON u.id = t.created_by WHERE t.board_id = ? ORDER BY t.created_at ASC `).all(boardId); } function getThread(threadId) { return db.prepare('SELECT * FROM comment_threads WHERE id = ?').get(threadId); } function createThread({ id, boardId, objectId, anchorType, pinX, pinY, createdBy }) { db.prepare(` INSERT INTO comment_threads (id, board_id, object_id, anchor_type, pin_x, pin_y, created_by, last_commented_at, last_commented_by) VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'), ?) `).run(id, boardId, objectId, anchorType, pinX ?? null, pinY ?? null, createdBy, createdBy); return getThread(id); } function createThreadWithComment({ threadId, boardId, objectId, anchorType, pinX, pinY, createdBy, commentId, userId, authorName, authorColor, content }) { return db.transaction(() => { const thread = createThread({ id: threadId, boardId, objectId, anchorType, pinX, pinY, createdBy }); const comment = createComment({ id: commentId, threadId, userId, authorName, authorColor, content }); return { thread, comment }; })(); } function updateThreadStatus(threadId, status, resolvedBy) { if (status === 'resolved') { db.prepare(` UPDATE comment_threads SET status = ?, resolved_by = ?, resolved_at = datetime('now'), updated_at = datetime('now') WHERE id = ? `).run(status, resolvedBy, threadId); } else { db.prepare(` UPDATE comment_threads SET status = ?, resolved_by = NULL, resolved_at = NULL, updated_at = datetime('now') WHERE id = ? `).run(status, threadId); } return getThread(threadId); } function deleteThread(threadId) { db.prepare('DELETE FROM comment_threads WHERE id = ?').run(threadId); } function incrementThreadCommentCount(threadId, userId) { db.prepare(` UPDATE comment_threads SET comment_count = comment_count + 1, last_commented_at = datetime('now'), last_commented_by = ?, updated_at = datetime('now') WHERE id = ? `).run(userId, threadId); } function decrementThreadCommentCount(threadId) { db.prepare(` UPDATE comment_threads SET comment_count = MAX(0, comment_count - 1), updated_at = datetime('now') WHERE id = ? `).run(threadId); } // --------------------- // Comment helpers // --------------------- function getCommentsByThread(threadId) { return db.prepare('SELECT * FROM comments WHERE thread_id = ? ORDER BY created_at ASC').all(threadId); } function getCommentsByBoard(boardId) { return db.prepare(` SELECT c.* FROM comments c JOIN comment_threads t ON t.id = c.thread_id WHERE t.board_id = ? ORDER BY c.created_at ASC `).all(boardId); } function getComment(commentId) { return db.prepare('SELECT * FROM comments WHERE id = ?').get(commentId); } function createComment({ id, threadId, userId, authorName, authorColor, content }) { db.prepare(` INSERT INTO comments (id, thread_id, user_id, author_name, author_color, content) VALUES (?, ?, ?, ?, ?, ?) `).run(id, threadId, userId, authorName, authorColor ?? null, content); return getComment(id); } function updateComment(commentId, content) { db.prepare(` UPDATE comments SET content = ?, edited_at = datetime('now') WHERE id = ? `).run(content, commentId); return getComment(commentId); } function deleteComment(commentId) { db.prepare('DELETE FROM comments WHERE id = ?').run(commentId); } // --------------------- // Seed admin from env (idempotent) // --------------------- async function seedAdminFromEnv() { const email = process.env.SEED_ADMIN_EMAIL; const password = process.env.SEED_ADMIN_PASSWORD; if (!email || !password) return; const existing = getUserByEmail(email); if (existing) { if (existing.role !== 'admin') { db.prepare("UPDATE users SET role = 'admin', updated_at = datetime('now') WHERE id = ?").run(existing.id); console.log(`[db] Promoted ${email} to admin via SEED_ADMIN_EMAIL.`); } return; } const bcrypt = require('bcryptjs'); const { v4: uuidv4 } = require('uuid'); const username = (process.env.SEED_ADMIN_USERNAME || email.split('@')[0]).replace(/[^a-zA-Z0-9_-]/g, ''); const displayName = process.env.SEED_ADMIN_DISPLAY_NAME || username; const passwordHash = await bcrypt.hash(password, 12); // Avoid username collision let finalUsername = username; let suffix = 1; while (getUserByUsername(finalUsername)) { finalUsername = `${username}${suffix++}`; } createUser({ id: uuidv4(), email, username: finalUsername, passwordHash, displayName, role: 'admin', }); console.log(`[db] Seeded admin user: ${email} (username: ${finalUsername})`); } // --------------------- // AYON integration helpers // --------------------- // Collection that holds all AYON task boards. const AYON_COLLECTION_NAME = 'AYON'; const { v4: uuidv4 } = require('uuid'); /** * Get-or-create the internal user row backing an AYON identity. * AYON users never log in here — the row only exists so that boards, * images, threads and comments (which reference users.id) keep working. * Password hash is an unusable sentinel. */ function getOrCreateAyonUser(username, displayName) { const existing = getUserByUsername(username); if (existing) return existing; const email = `${username}@ayon.local`; const byEmail = getUserByEmail(email); if (byEmail) return byEmail; return createUser({ id: uuidv4(), email, username, passwordHash: `!ayon:${uuidv4()}`, // no password ever matches this displayName: displayName || username, role: 'member', }); } /** * Get-or-create the board for an AYON task: board name is * `/`, living in the shared "AYON" collection. * Returns { board, collection, created }. */ function getOrCreateAyonBoard(project, taskPath) { const name = `${project}/${taskPath}`; let collection = getCollectionByName(AYON_COLLECTION_NAME); if (!collection) { // Bootstrap user owns the container collection const owner = getOrCreateAyonUser('ayon-system', 'AYON System'); collection = createCollection({ id: uuidv4(), name: AYON_COLLECTION_NAME, description: 'Task boards created by the AYON integration', createdBy: owner.id, }); addCollectionMember(collection.id, owner.id, 'owner'); } const ownerId = collection.created_by; const existing = db.prepare( 'SELECT * FROM boards WHERE collection_id = ? AND name = ?' ).get(collection.id, name); if (existing) return { board: existing, collection, created: false }; const board = createBoard({ id: uuidv4(), collectionId: collection.id, name, description: `AYON task board (${name})`, createdBy: ownerId, }); return { board, collection, created: true }; } /** Grant a user editor membership on the AYON collection (idempotent). */ function grantAyonCollectionAccess(userId) { const collection = getCollectionByName(AYON_COLLECTION_NAME); if (!collection) return; const member = getCollectionMember(collection.id, userId); if (!member) addCollectionMember(collection.id, userId, 'editor'); } module.exports = { db, // AYON integration getOrCreateAyonUser, getOrCreateAyonBoard, grantAyonCollectionAccess, // Users getUserByEmail, getUserById, getUserByUsername, getCollections, getCollection, getCollectionByShareToken, createCollection, updateCollection, deleteCollection, getCollectionMembers, getCollectionMember, addCollectionMember, removeCollectionMember, checkCollectionAccess, // Boards getCollectionBoards, getBoard, createBoard, updateBoard, deleteBoard, saveBoardCanvas, // Images createImage, getBoardImages, getImage, deleteImage, deleteBoardImageRecords, // Media Jobs createMediaJob, updateMediaJob, getMediaJob, getPendingMediaJobs, updateImageMedia, // PDF Pages createPdfPage, updatePdfPageThumb, updatePdfPageHires, getPdfPages, getPdfPage, updateImagePageCount, // Threads getThreadsByBoard, getThread, createThread, createThreadWithComment, updateThreadStatus, deleteThread, incrementThreadCommentCount, decrementThreadCommentCount, // Comments getCommentsByThread, getCommentsByBoard, getComment, createComment, updateComment, deleteComment, // Settings getSetting, setSetting, getAllSettings, getBoolSetting, getOrCreateJwtSecret, // Activity log logActivity, getBoardActivity, // Bootstrap seedAdminFromEnv, };