get('database.driver', 'sqlite'); $this->sqlite = $driver !== 'mysql'; if ($this->sqlite) { $directory = BASE_PATH . '/storage/database'; if (!is_dir($directory)) { mkdir($directory, 0775, true); } $this->pdo = new PDO('sqlite:' . $directory . '/store.sqlite'); $this->pdo->exec('PRAGMA foreign_keys = ON'); $this->pdo->exec('PRAGMA journal_mode = WAL'); } else { $dsn = sprintf( 'mysql:host=%s;port=%d;dbname=%s;charset=utf8mb4', $config->get('database.mysql_host'), (int) $config->get('database.mysql_port', 3306), $config->get('database.mysql_database') ); $this->pdo = new PDO( $dsn, (string) $config->get('database.mysql_username'), (string) $config->get('database.mysql_password') ); $this->pdo->exec("SET time_zone = '+00:00'"); } $this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); $this->pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); $this->pdo->setAttribute(PDO::ATTR_EMULATE_PREPARES, false); } public function pdo(): PDO { return $this->pdo; } public function isSqlite(): bool { return $this->sqlite; } public function migrate(): void { if (!$this->sqlite) { return; } $schema = <<<'SQL' CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, full_name TEXT NOT NULL, phone TEXT NOT NULL DEFAULT '', street_address TEXT NOT NULL DEFAULT '', city TEXT NOT NULL DEFAULT '', state TEXT NOT NULL DEFAULT '', postal_code TEXT NOT NULL DEFAULT '', role TEXT NOT NULL DEFAULT 'customer', newsletter_opt_in INTEGER NOT NULL DEFAULT 1, two_factor_enabled INTEGER NOT NULL DEFAULT 0, email_verified_at TEXT, active INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS login_codes ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, code_hash TEXT NOT NULL, purpose TEXT NOT NULL, expires_at TEXT NOT NULL, used_at TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS menu_categories ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, slug TEXT NOT NULL UNIQUE, description TEXT NOT NULL DEFAULT '', display_order INTEGER NOT NULL DEFAULT 0, active INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS menu_items ( id INTEGER PRIMARY KEY AUTOINCREMENT, category_id INTEGER NOT NULL, name TEXT NOT NULL, slug TEXT NOT NULL UNIQUE, description TEXT NOT NULL DEFAULT '', price_cents INTEGER NOT NULL, compare_at_cents INTEGER, image_url TEXT NOT NULL DEFAULT '', dietary_tags TEXT NOT NULL DEFAULT '[]', featured INTEGER NOT NULL DEFAULT 0, active INTEGER NOT NULL DEFAULT 1, display_order INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (category_id) REFERENCES menu_categories(id) ); CREATE TABLE IF NOT EXISTS specials ( id INTEGER PRIMARY KEY AUTOINCREMENT, menu_item_id INTEGER, title TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', badge TEXT NOT NULL DEFAULT 'Special', price_cents INTEGER, starts_at TEXT, ends_at TEXT, active INTEGER NOT NULL DEFAULT 1, display_order INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (menu_item_id) REFERENCES menu_items(id) ON DELETE SET NULL ); CREATE TABLE IF NOT EXISTS tax_rates ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, jurisdiction TEXT NOT NULL, rate_basis_points INTEGER NOT NULL, active INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS carts ( id INTEGER PRIMARY KEY AUTOINCREMENT, token TEXT NOT NULL UNIQUE, user_id INTEGER, status TEXT NOT NULL DEFAULT 'active', subtotal_cents INTEGER NOT NULL DEFAULT 0, last_seen_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, converted_at TEXT, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL ); CREATE TABLE IF NOT EXISTS cart_items ( id INTEGER PRIMARY KEY AUTOINCREMENT, cart_id INTEGER NOT NULL, menu_item_id INTEGER NOT NULL, quantity INTEGER NOT NULL DEFAULT 1, unit_price_cents INTEGER NOT NULL, notes TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE(cart_id, menu_item_id, notes), FOREIGN KEY (cart_id) REFERENCES carts(id) ON DELETE CASCADE, FOREIGN KEY (menu_item_id) REFERENCES menu_items(id) ); CREATE TABLE IF NOT EXISTS orders ( id INTEGER PRIMARY KEY AUTOINCREMENT, order_number TEXT NOT NULL UNIQUE, user_id INTEGER, cart_id INTEGER, customer_name TEXT NOT NULL, customer_email TEXT NOT NULL, customer_phone TEXT NOT NULL, order_type TEXT NOT NULL DEFAULT 'pickup', status TEXT NOT NULL DEFAULT 'paid', payment_status TEXT NOT NULL DEFAULT 'paid', payment_provider TEXT NOT NULL DEFAULT 'demo', payment_reference TEXT NOT NULL DEFAULT '', subtotal_cents INTEGER NOT NULL, tax_cents INTEGER NOT NULL, total_cents INTEGER NOT NULL, special_instructions TEXT NOT NULL DEFAULT '', pickup_time TEXT, placed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL, FOREIGN KEY (cart_id) REFERENCES carts(id) ON DELETE SET NULL ); CREATE TABLE IF NOT EXISTS order_items ( id INTEGER PRIMARY KEY AUTOINCREMENT, order_id INTEGER NOT NULL, menu_item_id INTEGER, item_name TEXT NOT NULL, quantity INTEGER NOT NULL, unit_price_cents INTEGER NOT NULL, line_total_cents INTEGER NOT NULL, notes TEXT NOT NULL DEFAULT '', FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADE, FOREIGN KEY (menu_item_id) REFERENCES menu_items(id) ON DELETE SET NULL ); CREATE TABLE IF NOT EXISTS order_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, order_id INTEGER NOT NULL, user_id INTEGER, event_type TEXT NOT NULL, details TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL ); CREATE TABLE IF NOT EXISTS refunds ( id INTEGER PRIMARY KEY AUTOINCREMENT, order_id INTEGER NOT NULL, staff_user_id INTEGER, amount_cents INTEGER NOT NULL, reason TEXT NOT NULL DEFAULT '', provider_reference TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (order_id) REFERENCES orders(id), FOREIGN KEY (staff_user_id) REFERENCES users(id) ON DELETE SET NULL ); CREATE TABLE IF NOT EXISTS visitor_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, user_id INTEGER, route TEXT NOT NULL, referrer TEXT NOT NULL DEFAULT '', user_agent TEXT NOT NULL DEFAULT '', ip_hash TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL ); CREATE TABLE IF NOT EXISTS newsletters ( id INTEGER PRIMARY KEY AUTOINCREMENT, subject TEXT NOT NULL, content_html TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'draft', recipient_count INTEGER NOT NULL DEFAULT 0, created_by INTEGER, sent_at TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL ); CREATE INDEX IF NOT EXISTS idx_menu_items_category ON menu_items(category_id, active); CREATE INDEX IF NOT EXISTS idx_orders_status ON orders(status, placed_at); CREATE INDEX IF NOT EXISTS idx_carts_status ON carts(status, last_seen_at); CREATE INDEX IF NOT EXISTS idx_visitors_created ON visitor_events(created_at); CREATE INDEX IF NOT EXISTS idx_users_newsletter ON users(newsletter_opt_in, active); SQL; $this->pdo->exec($schema); $this->ensureSqliteColumn('users', 'two_factor_enabled', 'INTEGER NOT NULL DEFAULT 0'); $this->seed(); } private function ensureSqliteColumn(string $table, string $column, string $definition): void { $columns = $this->pdo->query("PRAGMA table_info(`{$table}`)")->fetchAll(); foreach ($columns as $existing) { if (($existing['name'] ?? '') === $column) { return; } } $this->pdo->exec("ALTER TABLE `{$table}` ADD COLUMN `{$column}` {$definition}"); } private function seed(): void { $categoryCount = (int) $this->pdo->query('SELECT COUNT(*) FROM menu_categories')->fetchColumn(); if ($categoryCount > 0) { return; } $this->pdo->beginTransaction(); try { $categories = [ ['Smash Burgers', 'smash-burgers', 'Hand-pressed beef, crisp edges, and the good stuff.', 10], ['Burritos & Bowls', 'burritos-bowls', 'Big, packed, and built your way.', 20], ['Dinner Plates', 'dinner-plates', 'Comfort-food plates made for a proper supper.', 30], ['Sides', 'sides', 'The supporting cast that tends to steal the show.', 40], ['Drinks', 'drinks', 'Cold drinks for hot food.', 50], ]; $categoryStatement = $this->pdo->prepare( 'INSERT INTO menu_categories (name, slug, description, display_order) VALUES (?, ?, ?, ?)' ); foreach ($categories as $category) { $categoryStatement->execute($category); } $categoryIds = []; foreach ($this->pdo->query('SELECT id, slug FROM menu_categories')->fetchAll() as $category) { $categoryIds[$category['slug']] = (int) $category['id']; } $items = [ ['smash-burgers', 'The Fat Bottom', 'the-fat-bottom', 'Two smashed patties, American cheese, grilled onion, pickles, and house sauce.', 1199, 1, '["House favorite"]'], ['smash-burgers', 'Piedmont Bacon Burger', 'piedmont-bacon-burger', 'Two patties, smoked bacon, cheddar, lettuce, tomato, onion, and burger sauce.', 1349, 1, '[]'], ['smash-burgers', 'Allegheny Melt', 'allegheny-melt', 'Smashed beef, Swiss, caramelized onions, and peppercorn mayo on toasted sourdough.', 1299, 0, '[]'], ['burritos-bowls', 'Mountaineer Burrito', 'mountaineer-burrito', 'Seasoned beef, rice, black beans, queso, pico, lettuce, and crema.', 1249, 1, '["Big appetite"]'], ['burritos-bowls', 'Chicken Fajita Bowl', 'chicken-fajita-bowl', 'Grilled chicken, peppers, onions, rice, corn salsa, avocado crema.', 1199, 0, '["Gluten conscious"]'], ['dinner-plates', 'Country Fried Chicken', 'country-fried-chicken', 'Crispy chicken, peppered gravy, mashed potatoes, and green beans.', 1499, 1, '[]'], ['dinner-plates', 'Keyser Meatloaf', 'keyser-meatloaf', 'House meatloaf with brown gravy, mashed potatoes, and seasonal vegetables.', 1449, 0, '[]'], ['sides', 'Loaded Fries', 'loaded-fries', 'Crispy fries, queso, bacon, scallions, and ranch drizzle.', 699, 1, '[]'], ['sides', 'Mac & Cheese', 'mac-cheese', 'Creamy, baked, and unapologetically cheesy.', 449, 0, '["Vegetarian"]'], ['drinks', 'Sweet Tea', 'sweet-tea', 'Fresh-brewed southern sweet tea.', 299, 0, '[]'], ['drinks', 'Fountain Drink', 'fountain-drink', 'Your choice of fountain soda.', 299, 0, '[]'], ]; $itemStatement = $this->pdo->prepare( 'INSERT INTO menu_items (category_id, name, slug, description, price_cents, featured, dietary_tags) VALUES (?, ?, ?, ?, ?, ?, ?)' ); foreach ($items as $item) { $itemStatement->execute([ $categoryIds[$item[0]], $item[1], $item[2], $item[3], $item[4], $item[5], $item[6], ]); } $fatBottomId = (int) $this->pdo->query( "SELECT id FROM menu_items WHERE slug = 'the-fat-bottom'" )->fetchColumn(); $loadedFriesId = (int) $this->pdo->query( "SELECT id FROM menu_items WHERE slug = 'loaded-fries'" )->fetchColumn(); $specialStatement = $this->pdo->prepare( 'INSERT INTO specials (menu_item_id, title, description, badge, price_cents, display_order) VALUES (?, ?, ?, ?, ?, ?)' ); $specialStatement->execute([ $fatBottomId, 'The Fat Bottom Deal', 'Our signature burger at a special online price.', 'Online Special', 1099, 10, ]); $specialStatement->execute([ $loadedFriesId, 'Loaded-Up Lunch', 'Loaded fries are $1 off weekdays from 11 to 2.', 'Weekday Deal', 599, 20, ]); $taxStatement = $this->pdo->prepare( 'INSERT INTO tax_rates (name, jurisdiction, rate_basis_points) VALUES (?, ?, ?)' ); $taxStatement->execute(['West Virginia Sales Tax', 'state', 600]); $taxStatement->execute(['Keyser Municipal Tax', 'city', 100]); $adminStatement = $this->pdo->prepare( 'INSERT INTO users (email, password_hash, full_name, phone, role, newsletter_opt_in, two_factor_enabled, email_verified_at) VALUES (?, ?, ?, ?, ?, ?, 0, CURRENT_TIMESTAMP)' ); $adminStatement->execute([ 'admin@fatbottomgrille.com', password_hash('ChangeMe123!', PASSWORD_DEFAULT), 'Store Administrator', '', 'admin', 0, ]); $this->pdo->commit(); } catch (\Throwable $error) { $this->pdo->rollBack(); throw new RuntimeException('Unable to seed the store database.', 0, $error); } } public function fetchAll(string $sql, array $parameters = []): array { $statement = $this->pdo->prepare($sql); $statement->execute($parameters); return $statement->fetchAll(); } public function fetch(string $sql, array $parameters = []): ?array { $statement = $this->pdo->prepare($sql); $statement->execute($parameters); $row = $statement->fetch(); return $row === false ? null : $row; } public function execute(string $sql, array $parameters = []): bool { $statement = $this->pdo->prepare($sql); return $statement->execute($parameters); } }