// Import necessary modules const sqlite3 = require("sqlite3").verbose(); const { config } = require("dotenv"); const moment = require("moment"); const path = require("path"); const fs = require("fs"); // Load environment variables from .env file config(); // Resolve the target directory path for the database folder const targetPath = path.resolve(process.cwd(), "./database"); // Ensure the database directory exists; create it if it doesn't if (!fs.existsSync(targetPath)) { fs.mkdirSync(targetPath, { recursive: true }); console.log("✅ Created db folder at project root"); } else { console.log("ℹ️ db folder already exists"); } // Determine SQLite DB file path from environment or use default const dbPath = process.env.DB_PATH || path.join(targetPath, "links.db"); /** * Initializes the SQLite database and creates the `urls` table and triggers if they don't exist. * Ensures unique slugs per user or for anonymous users. * @returns {Promise} - Resolves with the database instance. */ const initDB = async () => { return new Promise(async (resolve, reject) => { const db = new sqlite3.Database(dbPath, async (err) => { if (err) { console.error("Failed to open the database", err); reject(err); } else { console.log("Database connected"); // Create the `urls` table with constraints db.run( `CREATE TABLE IF NOT EXISTS urls ( id INTEGER PRIMARY KEY AUTOINCREMENT, long_url TEXT NOT NULL, short_code TEXT NOT NULL, expiry_date TEXT, user_id TEXT DEFAULT '', customized_shortner_slug TEXT )`, function (err) { if (err) { console.error("Error creating table:", err); reject(err); } else { // Create trigger to enforce slug uniqueness logic before insert db.run( ` CREATE TRIGGER IF NOT EXISTS enforce_custom_slug_uniqueness_insert BEFORE INSERT ON urls BEGIN -- Case 1: Anonymous users with non-empty slug must be globally unique SELECT RAISE(ABORT, 'Slug must be unique for anonymous users') WHERE NEW.user_id = '' AND NEW.customized_shortner_slug != '' AND EXISTS ( SELECT 1 FROM urls WHERE user_id = '' AND customized_shortner_slug = NEW.customized_shortner_slug ); -- Case 2: Logged-in users with non-empty slug must be unique per user SELECT RAISE(ABORT, 'Slug must be unique per user') WHERE NEW.user_id != '' AND NEW.customized_shortner_slug != '' AND EXISTS ( SELECT 1 FROM urls WHERE user_id = NEW.user_id AND customized_shortner_slug = NEW.customized_shortner_slug ); -- Case 3: Prevent reinserting same short_code + expiry_date + user_id + slug (if slug is not null) SELECT RAISE(ABORT, 'Duplicate URL record not allowed') WHERE EXISTS ( SELECT 1 FROM urls WHERE short_code = NEW.short_code AND expiry_date = NEW.expiry_date AND user_id = NEW.user_id ); END; `, function (err) { if (err) reject(err); resolve(db); } ); } } ); } }); }); }; /** * Inserts a new shortened URL record into the database. * @param {string} longUrl - The original full URL. * @param {string} shortCode - The generated short code. * @param {string} expiryDate - The expiration date (format: 'YYYY-MM-DD HH:mm:ss'). * @param {string} user_id - The ID of the user (empty string for anonymous). * @param {string} customized_shortner_slug - Optional custom slug provided by user. * @returns {Promise} - Resolves with the new record's ID. */ const insertUrl = async ( longUrl, shortCode, expiryDate, user_id, customized_shortner_slug ) => { const db = await initDB(); return new Promise((resolve, reject) => { const sql = `INSERT INTO urls (long_url, short_code, expiry_date, user_id,customized_shortner_slug) VALUES (?, ?, ?, ?, ?)`; db.run( sql, [longUrl, shortCode, expiryDate, user_id, customized_shortner_slug], function (err) { if (err) { if (err.code === "SQLITE_CONSTRAINT") { reject( new Error( "The custom slug is already taken for this user. Please choose a different one." ) ); } reject(err); } else { resolve(this.lastID); // Return the ID of the inserted row } } ); }); }; /** * Looks up a short URL by its code or custom slug. * If the URL has expired, deletes the record and rejects the promise. * @param {string} shortCode - The short code or slug. * @param {string} userId - The user ID to validate ownership (used during deletion). * @returns {Promise} - Resolves with the original long URL. * @throws {Error} - If not found or expired. */ const findByShortCode = async (shortCode, userId = "") => { const db = await initDB(); return new Promise((resolve, reject) => { const sql = `SELECT * FROM urls WHERE short_code = ? or customized_shortner_slug = ?`; db.get(sql, [shortCode, shortCode], (err, row) => { if (err) { reject(err); } else { if (row) { const now = new Date(); const expiryDate = new Date(row.expiry_date); // Delete and reject if the link has expired if (expiryDate < now) { if (row?.short_code === shortCode) db.run("DELETE FROM urls WHERE short_code = ? and user_id = ?", [ shortCode, userId, ]); else if (row?.customized_shortner_slug === shortCode) db.run( "DELETE FROM urls WHERE customized_shortner_slug = ? and user_id = ?", [shortCode, userId] ); reject(new Error("This link has expired.")); } else { resolve(row.long_url); } } else { reject(new Error("Short code not found.")); } } }); }); }; /** * Checks if a URL already exists with the same expiry date and user. * Useful for avoiding duplicate entries. * @param {string} longUrl - The long/original URL. * @param {string} expiryDate - The expiry date to check against. * @param {string} user_id - The user ID. * @returns {Promise} - Resolves with the row if found, otherwise null. */ const findByLongUrl = async ( longUrl, expiryDate, userId, customizedShortnerSlug ) => { const db = await initDB(); return new Promise((resolve, reject) => { const sql = `SELECT * FROM urls WHERE long_url = ? and expiry_date <= ? and user_id = ? and customized_shortner_slug = ?`; db.get( sql, [longUrl, expiryDate, userId, customizedShortnerSlug], (err, row) => { if (err) { reject(err); } else { resolve(row); } } ); }); }; /** * Retrieves all URL records from the database. * @returns {Promise} - Resolves with an array of all records. */ const getAllData = async () => { const db = await initDB(); return new Promise((resolve, reject) => { const sql = `SELECT * FROM urls`; db.all(sql, (err, row) => { if (err) { reject(err); } else { resolve(row); } }); }); }; /** * Deletes all records that have expired (based on current time). * @returns {Promise} - Resolves with a message indicating how many records were deleted. */ const deleteAllExpired = async () => { const db = await initDB(); return new Promise((resolve, reject) => { const currentTime = moment().format("YYYY-MM-DD HH:mm:ss"); const sql = `DELETE FROM urls WHERE expiry_date < ?`; db.run(sql, [currentTime], function (err) { if (err) { reject(err); } else { resolve(`${this.changes} expired URLs deleted.`); } }); }); }; /* * Retrieves paginated URL records from the database. * @param {number} page - The page number (starting from 1). * @param {number} limit - Number of records per page. * @returns {Promise} - Resolves with an object containing total count, current page, limit, and data. */ const getPaginatedData = async (page = 1, limit = 10, userId = "") => { const db = await initDB(); const offset = (page - 1) * limit; userId = String(userId).trim(); return new Promise((resolve, reject) => { const sqlData = `SELECT * FROM urls where user_id = ? LIMIT ? OFFSET ?`; const sqlCount = `SELECT COUNT(*) as count FROM urls where user_id = ? `; db.all(sqlData, [userId, limit, offset], (err, rows) => { if (err) { reject(err); } else { db.get(sqlCount, [userId], (countErr, countRow) => { if (countErr) { reject(countErr); } else { resolve({ page, limit, total: countRow.count, data: rows, }); } }); } }); }); }; // Export all database utility functions module.exports = { initDB, insertUrl, findByShortCode, findByLongUrl, getAllData, deleteAllExpired, getPaginatedData, };