Files
order/app/Core/Database.php
T
Ty Clifford 45ef31e61f - Email 2FA is now optional and disabled by default.
Existing and new accounts default to 2FA off.
Registration signs users in directly.
Users can enable it under My Account.
Administrators can manage it under Users & Staff.
Verified password-only and opted-in email-code login flows.
PHP/JavaScript checks and browser console passed.
2026-06-10 16:40:22 -04:00

417 lines
16 KiB
PHP

<?php
declare(strict_types=1);
namespace FatBottom\Core;
use PDO;
use RuntimeException;
final class Database
{
private PDO $pdo;
private bool $sqlite;
public function __construct(private readonly Config $config)
{
$driver = (string) $config->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, email_verified_at)
VALUES (?, ?, ?, ?, ?, ?, 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);
}
}