const initSqlJs = require('sql.js'); const fs = require('fs'); const path = require('path'); let db = null; const DB_PATH = './ka.db'; // Helper functions function getNextKANumber() { const result = db.exec('SELECT ka_number FROM articles ORDER BY id DESC LIMIT 1') if (result.length > 0 && result[0].values.length > 0) { const lastKANumber = result[0].values[0][0]; const number = parseInt(lastKANumber.replace('KA', '')) + 1; return `KA${String(number).padStart(3, '0')}`; } return 'KA001'; } async function initDb() { const SQL = await initSqlJs(); console.log('Initializing Database...'); // Loading Database if it already exists if (fs.existsSync(DB_PATH)) { const buffer = fs.readFileSync(DB_PATH); db = new SQL.Database(buffer); console.log('Database Loaded') } else { // Creating a new one if it does not console.log('Database not found. Creating new instance...') db = new SQL.Database(); // Creating Knowledge Article table db.run(` CREATE TABLE articles ( id INTEGER PRIMARY KEY AUTOINCREMENT, ka_number TEXT UNIQUE, title TEXT, content TEXT, status TEXT DEFAULT 'published', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_by TEXT, updated_at DATETIME, updated_by TEXT ) `); db.run("CREATE INDEX idx_ka_number ON articles(ka_number)"); db.run("CREATE INDEX idx_status ON articles(status)"); // Creating Users table db.run(` CREATE TABLE users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE, email TEXT UNIQUE NOT NULL, pass_hash TEXT, display_name TEXT, auth_provider TEXT DEFAULT 'local', entra_id TEXT, role TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) `); db.run("CREATE INDEX idx_email ON users(email)"); db.run("CREATE INDEX idx_entra_id ON users(entra_id)"); db.run("CREATE INDEX idx_username ON users(username)"); // Creating tags table and its junction table db.run(` CREATE TABLE tags ( id INTEGER PRIMAY KEY AUTOINCREMENT, name TEXT UNIQUE NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) `); db.run('CREATE INDEX idx_tag_name ON tags(name)'); db.run(` CREATE TABLE article_tags ( article_id INTEGER, tag_id INTEGER, PRIMARY KEY (article_id, tag_id), FOREIGN KEY (article_id) REFERENCES articles(id) ON DELETE CASCADE, FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE ) `); // Creating categories table and its junction table db.run(` CREATE TABLE categories ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) `); db.run('CREATE INDEX idx_category_name ON categories(name'); db.run(` CREATE TABLE article_categories ( article_id INTEGER, category_id INTEGER, PRIMARY KEY (article_id, category_id), FOREIGN KEY (article_id) REFERENCES articles(id) ON DELETE CASCADE, FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE, ) `); // Saving the created db to file const data = db.export(); fs.writeFileSync(DB_PATH, Buffer.from(data)); console.log(`Database created at ${DB_PATH}`); } return db; } /** * Gets all published articles from the database * @returns {Array} - returns an array of all stored articles */ function getAllArticles() { const result = db.exec("SELECT * FROM articles WHERE status = 'published' ORDER BY created_at DESC"); if (result.length === 0) { return []; } const columns = result[0].columns; const rows = result[0].values; const articles = rows.map(row => { const article = {}; columns.forEach((col, index) => { article[col] = row[index]; }); return article; }) return articles; } /** * Gets the article from the database associated with a KA number * @param {string} ka_num - The KA number associated with an article * @returns {Object} - the associated article from the database */ function getArticle(ka_num) { const stmt = db.prepare('SELECT * FROM articles where ka_number = ?'); stmt.bind([ka_num]); if (stmt.step()) { const article = stmt.getAsObject(); stmt.free(); return article; } stmt.free(); return null; } /** * Creates a new entry in the articles table, atomatically assigns KA number, and returns the newly created article * @param {string} title - The title of the article * @param {string} content - The content of the article * @param {string} author - The username of the article creator * @param {string} status - the status of the created article ('draft' or 'published') * @returns {Object} - The newly created article from the database */ function createArticle(title, content, author, status = 'published') { const ka_num = getNextKANumber(); db.run( "INSERT INTO articles (ka_number, title, content, created_by, status) VALUES (?, ?, ?, ?, ?)", [ka_num, title, content, author, status] ); // Saving updated DB to file const data = db.export(); fs.writeFileSync(DB_PATH, Buffer.from(data)); return getArticle(ka_num); } /** * Updates the stored article that is associated with the KA number, and returns the updated article * @param {string} ka_num - The KA number of the article beaing updated * @param {string} title - The title of the article * @param {string} content - The content of the article * @param {string} author - The username that updated the article * @param {string} status - The status of the article ('published' or 'draft') * @returns {Object} - The newly updatd article from the database */ function updateArticle(ka_num, title, content, author, status = 'published') { db.run( "UPDATE articles SET title = ?, content = ?, updated_at = CURRENT_TIMESTAMP, updated_by = ?, status = ? WHERE ka_number = ?", [title, content, author, status, ka_num] ); // Saving updated DB to file const data = db.export(); fs.writeFileSync(DB_PATH, Buffer.from(data)); return getArticle(ka_num); } /** * Deletes the associated article from the article table * @param {*} ka_num - The KA number associated to the article */ function deleteArticle(ka_num) { db.run( "DELETE FROM articles WHERE ka_number = ?", [ka_num] ); // Saving updated DB to file const data = db.export(); fs.writeFileSync(DB_PATH, Buffer.from(data)); // Deleting media folder if it exists const mediaDir = path.join('./media', ka_num); if (fs.existsSync(mediaDir)) { fs.rmSync(mediaDir, {recursive: true, force: true}); } } /** * Searches the article table for any words matching in the title, content, or ka number and returns an array of them * @param {string} query - The search query that will be used for looking up the assocaited articles * @returns {Array} - An arry of articles that match the query content */ function searchArticles(query) { const searchTerm = `%${query}%`; const stmt = db.prepare( "SELECT * FROM articles WHERE title LIKE ? OR content LIKE ? or ka_number LIKE ? ORDER BY created_at DESC" ); stmt.bind([searchTerm, searchTerm, searchTerm]); const results = []; while (stmt.step()) { results.push(stmt.getAsObject()); } stmt.free(); return results; } /** * Creates a new user and returns the newly created user object from the database * @param {string} username - The username for the newly created user * @param {string} email - The email address for the newly created user * @param {string} passHash - The hashed password for validation purposes * @param {string} display_name - The name that will be desplayed when an article is created or updated * @param {string} authProvider - the source of the authentication: 'local' or 'entra' * @param {string} entraId - The ID number for the associated entra account, can be null if auth provider is local * @param {string} role - The user's role permissions (Admin / Editor / User) * @returns {Object} - The user object of the newly created user */ function createUser(username, email, passHash, display_name, authProvider = 'local', entraId = null, role = 'User') { db.run("INSERT INTO users (username, email, pass_hash, display_name, auth_provider, entra_id, role) VALUES (?, ?, ?, ?, ?, ?, ?)", [username, email, passHash, display_name, authProvider, entraId, role] ) // Saving DB with newly created record const data = db.export(); fs.writeFileSync(DB_PATH, Buffer.from(data)); const user = getUserByEmail(email); return user; } /** * Looks up users table and returns the user object for the user profile that matches the provided email * @param {string} email - The email address used for the created user * @returns {Object} - The matching user object from the database */ function getUserByEmail(email) { const stmt = db.prepare("SELECT * FROM users WHERE email = ? LIMIT 1"); stmt.bind([email]); if (stmt.step()) { const user = stmt.getAsObject(); stmt.free(); return user; } stmt.free(); return null; } /** * Looks up the user account with the specified username from the database and returns the user object * @param {*} username - The username associated to the profile being looked up * @returns {Object} - The user object that matches the provided username */ function getUserByUsername(username) { const stmt = db.prepare("SELECT * FROM users WHERE username = ?"); stmt.bind([username]); if (stmt.step()) { const user = stmt.getAsObject(); stmt.free(); return user; } stmt.free(); return null; } /** * Looks up the user by the primary key and returns the user object * @param {Int} id - The id number of the associated user account * @returns {Object} - The user object associated to the primary key provided */ function getUserById(id) { const stmt = db.prepare("SELECT * FROM users WHERE id = ?"); stmt.bind([id]); if (stmt.step()) { const user = stmt.getAsObject(); stmt.free(); return user; } stmt.free(); return null; } /** * Modifies the saved user's role * @param {Int} userId - The Id for the user * @param {string} newRole - The new role to be assigned * @returns {Object} - The updated user object */ function updateUserRole(userId, newRole) { db.run("UPDATE users SET role = ? WHERE id = ?", [newRole, userId] ); const data = db.export(); fs.writeFileSync(DB_PATH, Buffer.from(data)); return getUserById(userId); } /** * Deletes the user from the database * @param {Int} userId - The Id of the user to be deleted */ function deleteUser(userId) { db.run("DELETE FROM users WHERE id = ?", [userId]); const data = db.export(); fs.writeFileSync(DB_PATH. Buffer.from(data)); } /** * Gets all users currently stored in the database * @returns {Array} - an array of user objects */ function getAllUsers() { const result = db.exec( "SELECT id, username, email, display_name, auth_provider, role, created_at FROM users ORDER BY created_at DESC" ); if (result.length === 0) return []; const columns = result[0].columns; const rows = result[0].values; const users = rows.map(row => { const user = {}; columns.forEach((col, index) => { user[col] = row[index]; }); return user; }); return users; } /** * Return all draft articles owned by a user * @param {string} author - The author of the articles * @returns {Array} - An array of article objects */ function getOwnedDrafts(author) { const stmt = db.prepare("SELECT * FROM articles WHERE status = 'draft' AND created_by = ? ORDER BY created_at DESC") stmt.bind([author]); const results = []; while (stmt.step()) { results.push(stmt.getAsObject()); } stmt.free() return results; } /** * Gets all categories * @returns {Array} - Array of category objects */ function getAllCategories() { const result = db.exec("SELECT * FROM categories ORDER BY name ASC"); if (result.length === 0) return []; const columns = result[0].columns; const rows = result[0].values; return rows.map(row => { const category = {}; columns.forEach((col, index) => { category[col] = row[index]; }); return category; }); } /** * Get the existing category object, or create it if it doesn't. * @param {String} name - the name of the category to obtain * @returns {Object} - The category object from the database */ function getOrCreateCategory(name) { const stmt = db.prepare("SELECT * FROM categories WHERE name = ?"); stmt.bind([name]); if (stmt.step()) { const category = stmt.getAsObject(); stmt.free(); return category; } stmt.free(); db.run("INSERT INTO categories (name) VALUES (?)", [name]); const data = db.export(); fs.writeFileSync(DB_PATH, Buffer.from(data)); return getOrCreateCategory(name); } /** * Gets all the tags in the database * @returns {Array} - an array of tag objects */ function getAllTags() { const result = db.exec("SELECT * FROM tags ORDER BY name ASC"); if (result.length === 0) return []; const columns = result[0].columns; const rows = result[0].values; return rows.map(row => { const tag = {}; columns.forEach((col, index) => { tag[col] = row[index]; }); return tag; }); } /** * Get the tag object from the database if it exists, and create it if it doesn't * @param {string} name - the name of the tag * @returns {Object} - the tag object from the database */ function getOrCreateTag(name) { const stmt = db.prepare("SELECT * FROM tags WHERE name = ?"); stmt.bind([name]); if(stmt.step()) { const tag = stmt.getAsObject(); stmt.free() return tag; } stmt.free(); db.run("INSERT INTO tags (name) VALUES (?)",[name]); const data = db.export(); fs.writeFileSync(DB_PATH, Buffer.from(data)); return getOrCreateTag(name); } /** * Set categories for an article * @param {Int} articleId - Artilce ID * @param {Array} categoryNames - An array of category names */ function setArticleCategories(articleId, categoryNames) { db.run("DELETE FROM article_categories WHERE article_id = ?", [articleId]); categoryNames.forEach(name => { const category = getOrCreateCategory(name); db.run("INSERT INTO article_categories (article_id, category_id) VALUES (?, ?)", [articleId, category.id] ); }); const data = db.export(); fs.writeFileSync(DB_PATH, Buffer.from(data)); } /** * Set the tags for an article * @param {Int} articleId - Article ID * @param {Array} tagNames - An array of tag names */ function setArticleTags(articleId, tagNames) { db.run("DELETE FROM article_tags WHERE article_id = ?", [articleId]); tagNames.forEach(name => { const tag = getOrCreateTag(name); db.run("INSERT INTO article_tags (article_id, tag_id) VALUES (?, ?)", [articleId, tag.id]); }); const data = db.export(); fs.writeFileSync(DB_PATH, Buffer.from(data)); } /** * Get the categories of an article * @param {int} articleId - Article ID * @returns {Array} - An array of category names */ function getArticleCategories(articleId) { const result = db.exec(` SELECT c.name FROM categories c JOIN article_categories ac ON c.id = ac.category_id WHERE ac.article_id = ? ORDER BY c.name ASC `, [articleId]); if (result.length === 0) return []; return result[0].values.map(row => row[0]); } /** * Get the tags on an article * @param {Int} articleId - Article ID * @returns {Array} - An array of tag names */ function getArticleTags(articleId) { const result = db.exec(` SELECT t.name FROM tags t JOIN article_tags at ON t.id = at.tag_id WHERE ta.article_id = ? ORDER BY t.name ASC `, [articleId]); if(result.length === 0) return []; return result[0].values.map(row => row[0]); } module.exports = { initDb, getAllArticles, getArticle, createArticle, updateArticle, deleteArticle, searchArticles, createUser, getUserByUsername, getUserByEmail, getUserById, updateUserRole, deleteUser, getAllUsers, getOwnedDrafts, getAllCategories, getAllTags, getOrCreateCategory, getOrCreateTag, getArticleCategories, getArticleTags, setArticleCategories, setArticleTags };