Files
order/app/Core/Database.php
T
Ty Clifford b14f4c1796 - Implemented the Lakeside Orders demo rebrand:
New licensed lake, picnic, dining, server, burger, and food photography.
Rebuilt homepage gallery, logo, colors, copy, menu, specials, and 
exports.
Added dashboard-configurable branding in [settings.php (line 
18)](/Users/tyemeclifford/Documents/GH/fatbottomgrille/views/admin/settings.php:18).
Added a public attribution page and full [image 
credits](/Users/tyemeclifford/Documents/GH/fatbottomgrille/public/assets/images/CREDITS.md).
Migrated existing SQLite menu data and admin email to 
admin@lakesideorders.test.
Verified 51 PHP files, JSON/JavaScript, storefront pages, database 
migration, admin settings, and menu PDF. All passed.
2026-06-14 14:26:09 -04:00

647 lines
28 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 app_migrations (
id TEXT PRIMARY KEY,
applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
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 customers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
email TEXT NOT NULL UNIQUE,
full_name TEXT NOT NULL,
phone TEXT NOT NULL DEFAULT '',
active INTEGER NOT NULL DEFAULT 1,
internal_notes TEXT NOT NULL DEFAULT '',
order_count INTEGER NOT NULL DEFAULT 0,
lifetime_value_cents INTEGER NOT NULL DEFAULT 0,
first_order_at TEXT,
last_order_at TEXT,
created_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
);
CREATE TABLE IF NOT EXISTS orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_number TEXT NOT NULL UNIQUE,
user_id INTEGER,
customer_id INTEGER,
cart_id INTEGER,
tracking_token TEXT NOT NULL DEFAULT '',
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 'pending',
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 (customer_id) REFERENCES customers(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 '',
status TEXT NOT NULL DEFAULT '',
note TEXT NOT NULL DEFAULT '',
customer_visible INTEGER NOT NULL DEFAULT 0,
email_sent INTEGER NOT NULL DEFAULT 0,
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->ensureSqliteColumn('orders', 'customer_id', 'INTEGER');
$this->ensureSqliteColumn('orders', 'tracking_token', "TEXT NOT NULL DEFAULT ''");
$this->ensureSqliteColumn('order_events', 'status', "TEXT NOT NULL DEFAULT ''");
$this->ensureSqliteColumn('order_events', 'note', "TEXT NOT NULL DEFAULT ''");
$this->ensureSqliteColumn('order_events', 'customer_visible', 'INTEGER NOT NULL DEFAULT 0');
$this->ensureSqliteColumn('order_events', 'email_sent', 'INTEGER NOT NULL DEFAULT 0');
$this->backfillOrderTrackingTokens();
$this->runMigration('20260614_order_status_crm', function (): void {
$this->normalizeLegacyOrderStatuses();
$this->syncCustomersFromOrders();
});
$this->runMigration('20260614_lakeside_orders_demo', function (): void {
$this->applyLakesideDemoBranding();
});
$this->pdo->exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_orders_tracking_token ON orders(tracking_token)');
$this->pdo->exec('CREATE INDEX IF NOT EXISTS idx_orders_customer ON orders(customer_id, placed_at)');
$this->pdo->exec('CREATE INDEX IF NOT EXISTS idx_customers_active ON customers(active, last_order_at)');
$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 runMigration(string $id, callable $migration): void
{
$statement = $this->pdo->prepare('SELECT id FROM app_migrations WHERE id = ?');
$statement->execute([$id]);
if ($statement->fetchColumn() !== false) {
return;
}
$this->pdo->beginTransaction();
try {
$migration();
$this->pdo->prepare('INSERT INTO app_migrations (id) VALUES (?)')->execute([$id]);
$this->pdo->commit();
} catch (\Throwable $error) {
$this->pdo->rollBack();
throw $error;
}
}
private function backfillOrderTrackingTokens(): void
{
$orders = $this->pdo->query(
"SELECT id FROM orders WHERE tracking_token IS NULL OR tracking_token = ''"
)->fetchAll();
$update = $this->pdo->prepare('UPDATE orders SET tracking_token = ? WHERE id = ?');
foreach ($orders as $order) {
$update->execute([bin2hex(random_bytes(24)), (int) $order['id']]);
}
}
private function normalizeLegacyOrderStatuses(): void
{
$this->pdo->exec("UPDATE orders SET status = 'pending' WHERE status = 'paid'");
$this->pdo->exec("UPDATE orders SET status = 'processing' WHERE status = 'preparing'");
$this->pdo->exec("UPDATE orders SET status = 'declined' WHERE status = 'cancelled'");
}
private function syncCustomersFromOrders(): void
{
$orders = $this->pdo->query(
'SELECT id, user_id, customer_name, customer_email, customer_phone
FROM orders ORDER BY placed_at, id'
)->fetchAll();
$findCustomer = $this->pdo->prepare('SELECT id, user_id FROM customers WHERE email = ?');
$findUser = $this->pdo->prepare('SELECT id FROM users WHERE email = ?');
$insert = $this->pdo->prepare(
'INSERT INTO customers (user_id, email, full_name, phone) VALUES (?, ?, ?, ?)'
);
$update = $this->pdo->prepare(
'UPDATE customers SET user_id = ?, full_name = ?, phone = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?'
);
$linkOrder = $this->pdo->prepare('UPDATE orders SET customer_id = ? WHERE id = ?');
$customerIds = [];
foreach ($orders as $order) {
$email = strtolower(trim((string) $order['customer_email']));
if ($email === '') {
continue;
}
$findCustomer->execute([$email]);
$customer = $findCustomer->fetch();
$userId = (int) ($order['user_id'] ?? 0) ?: null;
if ($userId === null) {
$findUser->execute([$email]);
$userId = (int) ($findUser->fetchColumn() ?: 0) ?: null;
}
if ($customer === false) {
$insert->execute([$userId, $email, $order['customer_name'], $order['customer_phone']]);
$customerId = (int) $this->pdo->lastInsertId();
} else {
$customerId = (int) $customer['id'];
$update->execute([
$userId ?: ((int) ($customer['user_id'] ?? 0) ?: null),
$order['customer_name'],
$order['customer_phone'],
$customerId,
]);
}
$linkOrder->execute([$customerId, (int) $order['id']]);
$customerIds[$customerId] = true;
}
$refresh = $this->pdo->prepare(
'UPDATE customers SET
order_count = (SELECT COUNT(*) FROM orders WHERE customer_id = ?),
lifetime_value_cents = COALESCE((SELECT SUM(total_cents) FROM orders WHERE customer_id = ?), 0),
first_order_at = (SELECT MIN(placed_at) FROM orders WHERE customer_id = ?),
last_order_at = (SELECT MAX(placed_at) FROM orders WHERE customer_id = ?),
updated_at = CURRENT_TIMESTAMP
WHERE id = ?'
);
foreach (array_keys($customerIds) as $customerId) {
$refresh->execute([$customerId, $customerId, $customerId, $customerId, $customerId]);
}
}
private function applyLakesideDemoBranding(): void
{
$categories = [
[1, 'Dockside Burgers', 'dockside-burgers', 'Flame-grilled favorites stacked for hungry lake days.'],
[2, 'Picnic Baskets', 'picnic-baskets', 'Wraps, bowls, and handhelds made to travel well.'],
[3, 'Lakehouse Plates', 'lakehouse-plates', 'Relaxed comfort food for a proper table by the water.'],
[4, 'Boardwalk Sides', 'boardwalk-sides', 'Shareable extras for the dock, blanket, or patio.'],
[5, 'Cold Drinks', 'cold-drinks', 'Fresh pours for sunny afternoons.'],
];
$categoryStatement = $this->pdo->prepare(
'UPDATE menu_categories
SET name = ?, slug = ?, description = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?'
);
foreach ($categories as [$id, $name, $slug, $description]) {
$categoryStatement->execute([$name, $slug, $description, $id]);
}
$items = [
[1, 1, 'Lakeside Double', 'lakeside-double', 'Two grilled patties, cheddar, lettuce, tomato, pickles, and dock sauce.', 1299, '/assets/images/lakeside-burger.jpg', '["House favorite"]', 1],
[2, 1, 'Dockmaster Bacon Burger', 'dockmaster-bacon-burger', 'Two patties, smoked bacon, cheddar, crisp lettuce, tomato, and onion.', 1399, '/assets/images/lakeside-burger.jpg', '[]', 1],
[3, 1, 'Sunset Mushroom Melt', 'sunset-mushroom-melt', 'Grilled beef, Swiss, mushrooms, caramelized onions, and peppercorn mayo.', 1299, '/assets/images/lakeside-burger.jpg', '[]', 0],
[4, 2, 'Marina Chicken Wrap', 'marina-chicken-wrap', 'Chicken, spinach, tomato, cucumber, and creamy herb dressing in a soft wrap.', 1199, '/assets/images/picnic-wrap.jpg', '["Picnic pick"]', 1],
[5, 2, 'Shoreline Falafel Bowl', 'shoreline-falafel-bowl', 'Falafel, seasoned rice, bright slaw, peppers, herbs, lemon, and green tahini.', 1249, '/assets/images/shoreline-bowl.jpg', '["Vegetarian"]', 1],
[6, 3, 'Lakehouse Chicken Plate', 'lakehouse-chicken-plate', 'Seasoned chicken legs with a creamy picnic side and rotating vegetables.', 1499, '/assets/images/lakehouse-chicken.jpg', '[]', 1],
[7, 3, 'Picnic Club Wrap', 'picnic-club-wrap', 'Sliced chicken, greens, tomato, smoky bacon, and herb dressing.', 1299, '/assets/images/picnic-wrap.jpg', '[]', 0],
[8, 4, 'Boardwalk Fries', 'boardwalk-fries', 'Golden fries finished with flaky salt and your choice of dipping sauce.', 599, '/assets/images/boardwalk-fries.jpg', '["Vegetarian"]', 1],
[9, 4, 'Garden Side Bowl', 'garden-side-bowl', 'Rice, chopped vegetables, herbs, lemon, and creamy green dressing.', 649, '/assets/images/shoreline-bowl.jpg', '["Vegetarian"]', 0],
[10, 5, 'Lemon Iced Tea', 'lemon-iced-tea', 'Fresh-brewed iced tea served with lemon.', 299, '/assets/images/lemon-iced-tea.jpg', '[]', 0],
[11, 5, 'Lakeside Lemonade', 'lakeside-lemonade', 'Bright house lemonade poured over ice.', 329, '/assets/images/lemon-iced-tea.jpg', '[]', 0],
];
$itemStatement = $this->pdo->prepare(
'UPDATE menu_items
SET category_id = ?, name = ?, slug = ?, description = ?, price_cents = ?,
image_url = ?, dietary_tags = ?, featured = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?'
);
foreach ($items as [$id, $categoryId, $name, $slug, $description, $price, $image, $tags, $featured]) {
$itemStatement->execute([
$categoryId,
$name,
$slug,
$description,
$price,
$image,
$tags,
$featured,
$id,
]);
}
$specialStatement = $this->pdo->prepare(
'UPDATE specials
SET menu_item_id = ?, title = ?, description = ?, badge = ?, price_cents = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?'
);
$specialStatement->execute([
1,
'Sunset Burger Pick',
'The Lakeside Double at its easygoing online demo price.',
'Dock Deal',
1299,
1,
]);
$specialStatement->execute([
8,
'Boardwalk Break',
'A hot order of Boardwalk Fries for the table or picnic blanket.',
'Picnic Pick',
599,
2,
]);
$this->pdo->exec("UPDATE tax_rates SET name = 'Demo State Sales Tax' WHERE id = 1");
$this->pdo->exec("UPDATE tax_rates SET name = 'Lakeview Demo Tax' WHERE id = 2");
$this->pdo->exec(
"UPDATE users SET email = 'admin@lakesideorders.test'
WHERE email = 'admin@fatbottomgrille.com'
AND NOT EXISTS (
SELECT 1 FROM users AS existing
WHERE existing.email = 'admin@lakesideorders.test'
)"
);
}
private function seed(): void
{
$categoryCount = (int) $this->pdo->query('SELECT COUNT(*) FROM menu_categories')->fetchColumn();
if ($categoryCount > 0) {
return;
}
$this->pdo->beginTransaction();
try {
$categories = [
['Dockside Burgers', 'dockside-burgers', 'Flame-grilled favorites stacked for hungry lake days.', 10],
['Picnic Baskets', 'picnic-baskets', 'Wraps, bowls, and handhelds made to travel well.', 20],
['Lakehouse Plates', 'lakehouse-plates', 'Relaxed comfort food for a proper table by the water.', 30],
['Boardwalk Sides', 'boardwalk-sides', 'Shareable extras for the dock, blanket, or patio.', 40],
['Cold Drinks', 'cold-drinks', 'Fresh pours for sunny afternoons.', 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 = [
['dockside-burgers', 'Lakeside Double', 'lakeside-double', 'Two grilled patties, cheddar, lettuce, tomato, pickles, and dock sauce.', 1299, '/assets/images/lakeside-burger.jpg', 1, '["House favorite"]'],
['dockside-burgers', 'Dockmaster Bacon Burger', 'dockmaster-bacon-burger', 'Two patties, smoked bacon, cheddar, crisp lettuce, tomato, and onion.', 1399, '/assets/images/lakeside-burger.jpg', 1, '[]'],
['dockside-burgers', 'Sunset Mushroom Melt', 'sunset-mushroom-melt', 'Grilled beef, Swiss, mushrooms, caramelized onions, and peppercorn mayo.', 1299, '/assets/images/lakeside-burger.jpg', 0, '[]'],
['picnic-baskets', 'Marina Chicken Wrap', 'marina-chicken-wrap', 'Chicken, spinach, tomato, cucumber, and creamy herb dressing in a soft wrap.', 1199, '/assets/images/picnic-wrap.jpg', 1, '["Picnic pick"]'],
['picnic-baskets', 'Shoreline Falafel Bowl', 'shoreline-falafel-bowl', 'Falafel, seasoned rice, bright slaw, peppers, herbs, lemon, and green tahini.', 1249, '/assets/images/shoreline-bowl.jpg', 1, '["Vegetarian"]'],
['lakehouse-plates', 'Lakehouse Chicken Plate', 'lakehouse-chicken-plate', 'Seasoned chicken legs with a creamy picnic side and rotating vegetables.', 1499, '/assets/images/lakehouse-chicken.jpg', 1, '[]'],
['lakehouse-plates', 'Picnic Club Wrap', 'picnic-club-wrap', 'Sliced chicken, greens, tomato, smoky bacon, and herb dressing.', 1299, '/assets/images/picnic-wrap.jpg', 0, '[]'],
['boardwalk-sides', 'Boardwalk Fries', 'boardwalk-fries', 'Golden fries finished with flaky salt and your choice of dipping sauce.', 599, '/assets/images/boardwalk-fries.jpg', 1, '["Vegetarian"]'],
['boardwalk-sides', 'Garden Side Bowl', 'garden-side-bowl', 'Rice, chopped vegetables, herbs, lemon, and creamy green dressing.', 649, '/assets/images/shoreline-bowl.jpg', 0, '["Vegetarian"]'],
['cold-drinks', 'Lemon Iced Tea', 'lemon-iced-tea', 'Fresh-brewed iced tea served with lemon.', 299, '/assets/images/lemon-iced-tea.jpg', 0, '[]'],
['cold-drinks', 'Lakeside Lemonade', 'lakeside-lemonade', 'Bright house lemonade poured over ice.', 329, '/assets/images/lemon-iced-tea.jpg', 0, '[]'],
];
$itemStatement = $this->pdo->prepare(
'INSERT INTO menu_items
(category_id, name, slug, description, price_cents, image_url, 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],
$item[7],
]);
}
$lakesideDoubleId = (int) $this->pdo->query(
"SELECT id FROM menu_items WHERE slug = 'lakeside-double'"
)->fetchColumn();
$boardwalkFriesId = (int) $this->pdo->query(
"SELECT id FROM menu_items WHERE slug = 'boardwalk-fries'"
)->fetchColumn();
$specialStatement = $this->pdo->prepare(
'INSERT INTO specials (menu_item_id, title, description, badge, price_cents, display_order)
VALUES (?, ?, ?, ?, ?, ?)'
);
$specialStatement->execute([
$lakesideDoubleId,
'Sunset Burger Pick',
'The Lakeside Double at its easygoing online demo price.',
'Dock Deal',
1299,
10,
]);
$specialStatement->execute([
$boardwalkFriesId,
'Boardwalk Break',
'A hot order of Boardwalk Fries for the table or picnic blanket.',
'Picnic Pick',
599,
20,
]);
$taxStatement = $this->pdo->prepare(
'INSERT INTO tax_rates (name, jurisdiction, rate_basis_points) VALUES (?, ?, ?)'
);
$taxStatement->execute(['Demo State Sales Tax', 'state', 600]);
$taxStatement->execute(['Lakeview Demo 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@lakesideorders.test',
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);
}
}