- Implemented the full order lifecycle, secure customer tracker, automatic acceptance emails, configurable status templates, and guest/account CRM management.
Key areas: [OrderStatusService.php](/Users/tyemeclifford/Documents/GH/fatbottomgrille/app/Services/OrderStatusService.php), [AdminController.php](/Users/tyemeclifford/Documents/GH/fatbottomgrille/app/Controllers/AdminController.php), and [Database.php](/Users/tyemeclifford/Documents/GH/fatbottomgrille/app/Core/Database.php). SQLite migration applied successfully. PHP/JS syntax, integration workflows, rendered pages, database integrity, and tracker authorization all passed. Invalid tracker tokens correctly return 403.
This commit is contained in:
+140
-1
@@ -62,6 +62,11 @@ final class Database
|
||||
}
|
||||
|
||||
$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,
|
||||
@@ -173,16 +178,35 @@ CREATE TABLE IF NOT EXISTS cart_items (
|
||||
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 'paid',
|
||||
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 '',
|
||||
@@ -194,6 +218,7 @@ CREATE TABLE IF NOT EXISTS orders (
|
||||
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
|
||||
);
|
||||
|
||||
@@ -216,6 +241,10 @@ CREATE TABLE IF NOT EXISTS order_events (
|
||||
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
|
||||
@@ -266,6 +295,20 @@ 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->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();
|
||||
}
|
||||
|
||||
@@ -281,6 +324,102 @@ SQL;
|
||||
$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 seed(): void
|
||||
{
|
||||
$categoryCount = (int) $this->pdo->query('SELECT COUNT(*) FROM menu_categories')->fetchColumn();
|
||||
|
||||
Reference in New Issue
Block a user