const fs = require("fs"); const path = require("path"); const { DatabaseSync } = require("node:sqlite"); const config = require("./config"); const { now, makeSlug } = require("./utils"); fs.mkdirSync(path.dirname(config.databasePath), { recursive: true }); fs.mkdirSync(config.uploadDir, { recursive: true }); fs.mkdirSync(config.backupDir, { recursive: true }); const db = new DatabaseSync(config.databasePath); db.exec("PRAGMA journal_mode = WAL"); db.exec("PRAGMA foreign_keys = ON"); function migrate() { db.exec(` CREATE TABLE IF NOT EXISTS administrators ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, email TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'owner', created_at TEXT NOT NULL, updated_at TEXT NOT NULL, last_login_at TEXT ); CREATE TABLE IF NOT EXISTS sessions ( sid TEXT PRIMARY KEY, sess TEXT NOT NULL, expired_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS pages ( id INTEGER PRIMARY KEY AUTOINCREMENT, slug TEXT NOT NULL UNIQUE, title TEXT NOT NULL, subtitle TEXT, intro TEXT, header_image_id INTEGER, background_image_id INTEGER, meta_title TEXT, meta_description TEXT, og_title TEXT, og_description TEXT, og_image_id INTEGER, canonical_url TEXT, search_visible INTEGER NOT NULL DEFAULT 1, status TEXT NOT NULL DEFAULT 'published', draft_json TEXT, published_at TEXT, updated_at TEXT NOT NULL, FOREIGN KEY(header_image_id) REFERENCES media(id) ON DELETE SET NULL, FOREIGN KEY(background_image_id) REFERENCES media(id) ON DELETE SET NULL, FOREIGN KEY(og_image_id) REFERENCES media(id) ON DELETE SET NULL ); CREATE TABLE IF NOT EXISTS page_sections ( id INTEGER PRIMARY KEY AUTOINCREMENT, page_id INTEGER NOT NULL, section_key TEXT, title TEXT, body TEXT, layout TEXT NOT NULL DEFAULT 'text', image_id INTEGER, background_image_id INTEGER, background_color TEXT, button_label TEXT, button_url TEXT, display_order INTEGER NOT NULL DEFAULT 0, is_published INTEGER NOT NULL DEFAULT 1, data_json TEXT, updated_at TEXT NOT NULL, FOREIGN KEY(page_id) REFERENCES pages(id) ON DELETE CASCADE, FOREIGN KEY(image_id) REFERENCES media(id) ON DELETE SET NULL, FOREIGN KEY(background_image_id) REFERENCES media(id) ON DELETE SET NULL ); CREATE TABLE IF NOT EXISTS media ( id INTEGER PRIMARY KEY AUTOINCREMENT, file_name TEXT NOT NULL, original_name TEXT NOT NULL, title TEXT, alt_text TEXT, category TEXT, mime_type TEXT NOT NULL, size_bytes INTEGER NOT NULL, width INTEGER, height INTEGER, url TEXT NOT NULL, thumb_url TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS categories ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, slug TEXT NOT NULL UNIQUE, display_order INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS tags ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, slug TEXT NOT NULL UNIQUE ); CREATE TABLE IF NOT EXISTS items ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, slug TEXT NOT NULL UNIQUE, short_description TEXT, full_description TEXT, category_id INTEGER, main_image_id INTEGER, price TEXT, starting_price TEXT, price_range TEXT, contact_for_pricing INTEGER NOT NULL DEFAULT 0, quantity TEXT, availability_status TEXT NOT NULL DEFAULT 'Available', custom_order_available INTEGER NOT NULL DEFAULT 0, color_size_notes TEXT, material_notes TEXT, care_instructions TEXT, featured INTEGER NOT NULL DEFAULT 0, newly_added INTEGER NOT NULL DEFAULT 0, on_sale INTEGER NOT NULL DEFAULT 0, sale_text TEXT, display_order INTEGER NOT NULL DEFAULT 0, is_published INTEGER NOT NULL DEFAULT 1, is_archived INTEGER NOT NULL DEFAULT 0, draft_json TEXT, date_added TEXT NOT NULL, published_at TEXT, updated_at TEXT NOT NULL, FOREIGN KEY(category_id) REFERENCES categories(id) ON DELETE SET NULL, FOREIGN KEY(main_image_id) REFERENCES media(id) ON DELETE SET NULL ); CREATE TABLE IF NOT EXISTS item_images ( id INTEGER PRIMARY KEY AUTOINCREMENT, item_id INTEGER NOT NULL, media_id INTEGER NOT NULL, display_order INTEGER NOT NULL DEFAULT 0, is_main INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(item_id) REFERENCES items(id) ON DELETE CASCADE, FOREIGN KEY(media_id) REFERENCES media(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS item_tags ( item_id INTEGER NOT NULL, tag_id INTEGER NOT NULL, PRIMARY KEY(item_id, tag_id), FOREIGN KEY(item_id) REFERENCES items(id) ON DELETE CASCADE, FOREIGN KEY(tag_id) REFERENCES tags(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS services ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, slug TEXT NOT NULL UNIQUE, image_id INTEGER, short_description TEXT, full_description TEXT, pricing_note TEXT, display_order INTEGER NOT NULL DEFAULT 0, featured INTEGER NOT NULL DEFAULT 0, is_published INTEGER NOT NULL DEFAULT 1, draft_json TEXT, published_at TEXT, updated_at TEXT NOT NULL, FOREIGN KEY(image_id) REFERENCES media(id) ON DELETE SET NULL ); CREATE TABLE IF NOT EXISTS equipment ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, type TEXT, brand TEXT, model TEXT, description TEXT, image_id INTEGER, display_order INTEGER NOT NULL DEFAULT 0, is_published INTEGER NOT NULL DEFAULT 1, updated_at TEXT NOT NULL, FOREIGN KEY(image_id) REFERENCES media(id) ON DELETE SET NULL ); CREATE TABLE IF NOT EXISTS messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, email TEXT NOT NULL, phone TEXT, preferred_contact TEXT, subject TEXT NOT NULL, message TEXT NOT NULL, related_item TEXT, related_service TEXT, consent INTEGER NOT NULL DEFAULT 0, source_page TEXT, status TEXT NOT NULL DEFAULT 'unread', spam_status TEXT NOT NULL DEFAULT 'clean', ip_hash TEXT, user_agent TEXT, email_notification_status TEXT NOT NULL DEFAULT 'not_configured', email_error TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS navigation ( id INTEGER PRIMARY KEY AUTOINCREMENT, label TEXT NOT NULL, url TEXT NOT NULL, kind TEXT NOT NULL DEFAULT 'internal', opens_new_tab INTEGER NOT NULL DEFAULT 0, display_order INTEGER NOT NULL DEFAULT 0, is_published INTEGER NOT NULL DEFAULT 1 ); CREATE TABLE IF NOT EXISTS revisions ( id INTEGER PRIMARY KEY AUTOINCREMENT, record_type TEXT NOT NULL, record_id INTEGER NOT NULL, title TEXT, snapshot_json TEXT NOT NULL, created_by INTEGER, created_at TEXT NOT NULL, FOREIGN KEY(created_by) REFERENCES administrators(id) ON DELETE SET NULL ); CREATE TABLE IF NOT EXISTS audit_logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, admin_id INTEGER, action TEXT NOT NULL, details TEXT, ip_address TEXT, created_at TEXT NOT NULL, FOREIGN KEY(admin_id) REFERENCES administrators(id) ON DELETE SET NULL ); CREATE TABLE IF NOT EXISTS backups ( id INTEGER PRIMARY KEY AUTOINCREMENT, file_name TEXT NOT NULL, file_path TEXT NOT NULL, size_bytes INTEGER NOT NULL, created_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS rate_limits ( key TEXT PRIMARY KEY, count INTEGER NOT NULL, reset_at INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS idx_items_public ON items(is_published, is_archived, name); CREATE INDEX IF NOT EXISTS idx_items_flags ON items(featured, newly_added, on_sale); CREATE INDEX IF NOT EXISTS idx_services_public ON services(is_published, display_order); CREATE INDEX IF NOT EXISTS idx_messages_status ON messages(status, created_at); CREATE INDEX IF NOT EXISTS idx_page_sections_order ON page_sections(page_id, display_order); CREATE INDEX IF NOT EXISTS idx_revisions_record ON revisions(record_type, record_id, created_at); `); } function setSetting(key, value) { db.prepare(` INSERT INTO settings (key, value, updated_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at `).run(key, String(value ?? ""), now()); } function getSetting(key, fallback = "") { const row = db.prepare("SELECT value FROM settings WHERE key = ?").get(key); return row ? row.value : fallback; } function allSettings() { const rows = db.prepare("SELECT key, value FROM settings").all(); return Object.fromEntries(rows.map((row) => [row.key, row.value])); } function ensureCategory(name) { const slug = makeSlug(name, "category"); db.prepare(` INSERT INTO categories (name, slug, display_order) VALUES (?, ?, (SELECT COALESCE(MAX(display_order), 0) + 10 FROM categories)) ON CONFLICT(name) DO NOTHING `).run(name, slug); return db.prepare("SELECT id FROM categories WHERE name = ?").get(name).id; } function seed() { const seeded = getSetting("system.seeded", ""); if (seeded) return; const ts = now(); const settings = { "system.seeded": ts, "business.name": "Debbie Windler Seamstress", "business.tagline": "Alterations, custom sewing, embroidery, and handmade items", "business.email": "replace-with-owner-email@example.com", "business.phone": "", "business.facebook": "", "business.service_area": "Service area to be supplied", "business.hours": "By appointment", "business.preferred_contact": "Email or website contact form", "business.footer_text": "Warm, careful sewing work for everyday clothing and special occasions.", "site.title": "Debbie Windler Seamstress", "site.description": "Alterations, custom sewing, embroidery, and handmade items.", "theme.bg": "#fffaf5", "theme.bg_alt": "#f4e8df", "theme.text": "#342b28", "theme.heading": "#2d2522", "theme.link": "#7d3f45", "theme.button": "#7d3f45", "theme.button_text": "#ffffff", "theme.accent": "#7a8f73", "theme.radius": "8", "theme.spacing": "comfortable", "theme.width": "1120", "theme.heading_font": "Georgia, 'Times New Roman', serif", "theme.body_font": "Arial, Helvetica, sans-serif", "theme.footer_style": "light", "home.hero.title": "Debbie Windler Seamstress", "home.hero.subtitle": "Alterations, custom sewing, embroidery, and handmade pieces, with sample wording ready for Debbie to replace.", "home.hero.contact": "Contact Debbie to talk through your project.", "home.hero.image": "/uploads/sample-hero.png", "home.hero.position": "center", "home.hero.size": "cover", "home.hero.overlay": "light", "home.hero.overlay_opacity": "0.28", "home.hero.text_color": "#2d2522", "home.hero.button1_label": "Contact Debbie", "home.hero.button1_url": "/about-contact#contact-form", "home.hero.button2_label": "View Items", "home.hero.button2_url": "/items", "home.highlight.title": "Featured and Newly Added", "home.highlight.intro": "Sample cards show how items can be featured, marked new, or offered as custom orders.", "home.highlight.background_color": "#f4e8df", "maintenance.enabled": "false", "maintenance.message": "The website is getting a careful refresh. Please check back soon.", "maintenance.return_note": "", "maintenance.artwork": "" }; for (const [key, value] of Object.entries(settings)) setSetting(key, value); const pageInsert = db.prepare(` INSERT INTO pages (slug, title, subtitle, intro, meta_title, meta_description, status, published_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, 'published', ?, ?) `); const pages = [ ["home", "Home", "Welcome to Debbie Windler Seamstress", "Sample home page copy can be replaced from the owner area."], ["items", "Items Available and Custom Orders", "Browse sample items alphabetically.", "Items can be available, made to order, on sale, sold as examples, seasonal, or hidden."], ["services", "Services", "Alterations, repairs, custom sewing, and embroidery.", "Service examples are seeded so Debbie can edit or remove them."], ["about-contact", "About and Contact", "A personal introduction and the full contact form.", "This page keeps Debbie's contact details editable and does not show a private home address by default."] ]; for (const page of pages) pageInsert.run(page[0], page[1], page[2], page[3], page[1], page[3], ts, ts); const pageBySlug = db.prepare("SELECT id FROM pages WHERE slug = ?"); const sectionInsert = db.prepare(` INSERT INTO page_sections (page_id, section_key, title, body, layout, display_order, is_published, data_json, updated_at) VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?) `); sectionInsert.run(pageBySlug.get("home").id, "intro", "Sewing Services With a Personal Touch", "
This sample introduction can describe Debbie's experience, favorite work, and how customers should reach out.
", "image-right", 10, "{}", ts); sectionInsert.run(pageBySlug.get("home").id, "contact-callout", "Have a project in mind?", "Use the contact form to ask about alterations, embroidery, handmade items, or special orders.
", "contact-callout", 30, "{}", ts); sectionInsert.run(pageBySlug.get("services").id, "service-note", "Not sure what to ask for?", "Send a note with the garment or project details. Photos can be discussed later by email if needed.
", "contact-callout", 20, "{}", ts); sectionInsert.run(pageBySlug.get("about-contact").id, "about", "About Debbie", "Sample biography text: Debbie's real sewing history, experience, and photos can be added here from the owner area.
", "text", 10, "{}", ts); const categories = ["Alterations", "Handmade Items", "Embroidery", "Custom Orders", "Seasonal"]; const categoryIds = Object.fromEntries(categories.map((name) => [name, ensureCategory(name)])); const itemInsert = db.prepare(` INSERT INTO items (name, slug, short_description, full_description, category_id, price, starting_price, price_range, contact_for_pricing, availability_status, custom_order_available, material_notes, care_instructions, featured, newly_added, on_sale, sale_text, display_order, is_published, date_added, published_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?) `); const sampleItems = [ ["Baby Blanket Example", "Soft sample listing for a custom baby blanket.", "Replace this with true details, sizes, fabrics, and ordering notes.
", "Handmade Items", "", "Contact for starting price", "", 1, "Made to Order", 1, "Fabric details to be supplied.", "Care instructions to be supplied.", 1, 1, 0, "", 10], ["Embroidered Tote Bag", "Sample tote bag entry for embroidery or custom wording.", "This is sample content only and can be changed or hidden.
", "Embroidery", "", "", "", 1, "Custom Order", 1, "Canvas or fabric notes can go here.", "Spot clean or care note to be supplied.", 1, 1, 0, "", 20], ["Formalwear Alteration Example", "A sold/example listing showing previous-style work without checkout.", "Use sold examples to show the type of work Debbie can discuss with customers.
", "Alterations", "", "", "", 1, "Sold", 0, "", "", 0, 0, 0, "", 30], ["Seasonal Pillow Cover", "Sample seasonal handmade item.", "Mark items seasonal, available, hidden, sold, or made to order.
", "Seasonal", "$00 sample", "", "", 0, "Available", 1, "Material notes to be supplied.", "Care instructions to be supplied.", 1, 0, 1, "Sample sale badge", 40] ]; for (const item of sampleItems) { itemInsert.run(item[0], makeSlug(item[0], "item"), item[1], item[2], categoryIds[item[3]], item[4], item[5], item[6], item[7], item[8], item[9], item[10], item[11], item[12], item[13], item[14], item[15], item[16], ts, ts, ts); } const serviceInsert = db.prepare(` INSERT INTO services (name, slug, short_description, full_description, pricing_note, display_order, featured, is_published, published_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, ?) `); [ ["Clothing Alterations", "Hems, fit adjustments, and everyday garment alterations.", "Sample service details. Debbie can replace this with her exact services and policies.
", "Pricing depends on the garment and work needed.", 10, 1], ["Zipper Repair and Replacement", "Repair or replace zippers on clothing and fabric items.", "Describe accepted items and turnaround once details are known.
", "Contact for pricing.", 20, 1], ["Wedding Dress Alterations", "Fittings and alterations for wedding dresses and formalwear.", "Sample text only. Add appointment expectations and timing when ready.
", "Contact early for availability.", 30, 1], ["Embroidery", "Custom embroidery projects and decorative additions.", "Describe machine embroidery options, setup needs, and project limits.
", "Contact for pricing.", 40, 1], ["Baby Blankets", "Handmade or custom baby blanket projects.", "Describe fabric choices, size options, and custom order timing.
", "Starting price to be supplied.", 50, 0], ["Patches Sewn Onto Clothing", "Patch placement and sewing for jackets, uniforms, and garments.", "Describe accepted materials and any preparation instructions.
", "Contact for pricing.", 60, 0] ].forEach((service) => serviceInsert.run(service[0], makeSlug(service[0], "service"), service[1], service[2], service[3], service[4], service[5], ts, ts)); const equipmentInsert = db.prepare(` INSERT INTO equipment (name, type, brand, model, description, display_order, is_published, updated_at) VALUES (?, ?, ?, ?, ?, ?, 1, ?) `); equipmentInsert.run("Industrial Sewing Machine", "Sewing machine", "", "", "Sample equipment entry. Add the real brand, model, and photo when available.", 10, ts); equipmentInsert.run("Embroidery Machine", "Embroidery machine", "", "", "Sample equipment entry for embroidery work.", 20, ts); equipmentInsert.run("Thread Rack", "Supplies", "", "", "Sample equipment entry for thread colors and materials.", 30, ts); const navInsert = db.prepare("INSERT INTO navigation (label, url, kind, display_order, is_published) VALUES (?, ?, 'internal', ?, 1)"); [ ["Home", "/", 10], ["Items", "/items", 20], ["Services", "/services", 30], ["About and Contact", "/about-contact", 40] ].forEach((nav) => navInsert.run(...nav)); } function saveRevision(type, id, title, snapshot, adminId) { db.prepare(` INSERT INTO revisions (record_type, record_id, title, snapshot_json, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?) `).run(type, id, title || "", JSON.stringify(snapshot), adminId || null, now()); const old = db.prepare(` SELECT id FROM revisions WHERE record_type = ? AND record_id = ? ORDER BY created_at DESC LIMIT -1 OFFSET 10 `).all(type, id); if (old.length) { db.prepare(`DELETE FROM revisions WHERE id IN (${old.map(() => "?").join(",")})`).run(...old.map((row) => row.id)); } } function audit(adminId, action, details, ipAddress) { db.prepare(` INSERT INTO audit_logs (admin_id, action, details, ip_address, created_at) VALUES (?, ?, ?, ?, ?) `).run(adminId || null, action, details || "", ipAddress || "", now()); } migrate(); seed(); if (require.main === module) { console.log(`Database ready at ${config.databasePath}`); } module.exports = { db, migrate, seed, setSetting, getSetting, allSettings, ensureCategory, saveRevision, audit };