- 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:
@@ -16,6 +16,7 @@ use FatBottom\Services\MenuPdfExporter;
|
||||
use FatBottom\Services\MySqlMigrator;
|
||||
use FatBottom\Services\PaymentGateway;
|
||||
use FatBottom\Services\StatsService;
|
||||
use FatBottom\Services\OrderStatusService;
|
||||
|
||||
final class AdminController extends BaseController
|
||||
{
|
||||
@@ -26,7 +27,8 @@ final class AdminController extends BaseController
|
||||
private readonly Database $db,
|
||||
private readonly StatsService $stats,
|
||||
private readonly PaymentGateway $payments,
|
||||
private readonly Mailer $mailer
|
||||
private readonly Mailer $mailer,
|
||||
private readonly OrderStatusService $orderStatuses
|
||||
) {
|
||||
parent::__construct($config, $auth, $cart);
|
||||
}
|
||||
@@ -132,6 +134,9 @@ final class AdminController extends BaseController
|
||||
WHERE r.order_id = ? ORDER BY r.created_at DESC',
|
||||
[$orderId]
|
||||
),
|
||||
'customer' => !empty($order['customer_id'])
|
||||
? $this->db->fetch('SELECT * FROM customers WHERE id = ?', [(int) $order['customer_id']])
|
||||
: null,
|
||||
]));
|
||||
}
|
||||
|
||||
@@ -141,24 +146,124 @@ final class AdminController extends BaseController
|
||||
$staff = $this->auth->requireStaff();
|
||||
$orderId = (int) ($_POST['order_id'] ?? 0);
|
||||
$status = Security::clean((string) ($_POST['status'] ?? ''));
|
||||
$allowed = ['paid', 'preparing', 'ready', 'completed', 'cancelled'];
|
||||
if (!in_array($status, $allowed, true)) {
|
||||
Http::flash('error', 'Choose a valid order status.');
|
||||
Http::redirect('/admin/orders');
|
||||
$note = Security::clean((string) ($_POST['status_note'] ?? ''));
|
||||
try {
|
||||
$result = $this->orderStatuses->update(
|
||||
$orderId,
|
||||
$status,
|
||||
$note,
|
||||
isset($_POST['customer_visible']),
|
||||
isset($_POST['send_email']),
|
||||
(int) $staff['id']
|
||||
);
|
||||
if ($result['email_error'] !== null) {
|
||||
Http::flash('warning', 'Status updated, but the email could not be sent: ' . $result['email_error']);
|
||||
} else {
|
||||
Http::flash(
|
||||
'success',
|
||||
'Order status updated' . ($result['email_requested'] ? ' and the customer was emailed.' : '.')
|
||||
);
|
||||
}
|
||||
} catch (\Throwable $error) {
|
||||
Http::flash('error', $error->getMessage());
|
||||
}
|
||||
$notes = Security::clean((string) ($_POST['staff_note'] ?? ''));
|
||||
$this->db->execute(
|
||||
'UPDATE orders SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
||||
[$status, $orderId]
|
||||
);
|
||||
$this->db->execute(
|
||||
'INSERT INTO order_events (order_id, user_id, event_type, details) VALUES (?, ?, ?, ?)',
|
||||
[$orderId, (int) $staff['id'], 'status_changed', 'Status changed to ' . $status . ($notes ? '. ' . $notes : '.')]
|
||||
);
|
||||
Http::flash('success', 'Order status updated.');
|
||||
Http::redirect('/admin/order?id=' . $orderId);
|
||||
}
|
||||
|
||||
public function customers(): void
|
||||
{
|
||||
$this->auth->requireStaff();
|
||||
$query = Security::clean((string) ($_GET['q'] ?? ''));
|
||||
$active = Security::clean((string) ($_GET['active'] ?? ''));
|
||||
$where = ['1 = 1'];
|
||||
$parameters = [];
|
||||
if ($query !== '') {
|
||||
$where[] = '(c.full_name LIKE ? OR c.email LIKE ? OR c.phone LIKE ?)';
|
||||
$parameters = ['%' . $query . '%', '%' . $query . '%', '%' . $query . '%'];
|
||||
}
|
||||
if ($active === '1' || $active === '0') {
|
||||
$where[] = 'c.active = ?';
|
||||
$parameters[] = (int) $active;
|
||||
}
|
||||
View::render('admin/customers', $this->shared([
|
||||
'title' => 'Customers',
|
||||
'adminPage' => 'customers',
|
||||
'customers' => $this->db->fetchAll(
|
||||
'SELECT c.*, u.email AS account_email
|
||||
FROM customers c LEFT JOIN users u ON u.id = c.user_id
|
||||
WHERE ' . implode(' AND ', $where) . '
|
||||
ORDER BY c.last_order_at DESC, c.full_name',
|
||||
$parameters
|
||||
),
|
||||
'query' => $query,
|
||||
'activeFilter' => $active,
|
||||
]));
|
||||
}
|
||||
|
||||
public function customerDetail(): void
|
||||
{
|
||||
$this->auth->requireStaff();
|
||||
$customerId = (int) ($_GET['id'] ?? 0);
|
||||
$customer = $this->db->fetch(
|
||||
'SELECT c.*, u.email AS account_email, u.active AS account_active
|
||||
FROM customers c LEFT JOIN users u ON u.id = c.user_id
|
||||
WHERE c.id = ?',
|
||||
[$customerId]
|
||||
);
|
||||
if ($customer === null) {
|
||||
Http::abort(404, 'Customer not found.');
|
||||
}
|
||||
View::render('admin/customer-detail', $this->shared([
|
||||
'title' => $customer['full_name'],
|
||||
'adminPage' => 'customers',
|
||||
'customer' => $customer,
|
||||
'orders' => $this->db->fetchAll(
|
||||
'SELECT * FROM orders WHERE customer_id = ? ORDER BY placed_at DESC',
|
||||
[$customerId]
|
||||
),
|
||||
]));
|
||||
}
|
||||
|
||||
public function saveCustomer(): void
|
||||
{
|
||||
Security::verifyCsrf();
|
||||
$this->auth->requireStaff();
|
||||
$customerId = (int) ($_POST['customer_id'] ?? 0);
|
||||
$customer = $this->db->fetch('SELECT * FROM customers WHERE id = ?', [$customerId]);
|
||||
if ($customer === null) {
|
||||
Http::abort(404, 'Customer not found.');
|
||||
}
|
||||
$name = Security::clean((string) ($_POST['full_name'] ?? ''));
|
||||
if ($name === '') {
|
||||
Http::flash('error', 'Customer name is required.');
|
||||
Http::redirect('/admin/customer?id=' . $customerId);
|
||||
}
|
||||
$this->db->execute(
|
||||
'UPDATE customers SET full_name = ?, phone = ?, active = ?, internal_notes = ?,
|
||||
updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
||||
[
|
||||
$name,
|
||||
Security::clean((string) ($_POST['phone'] ?? '')),
|
||||
isset($_POST['active']) ? 1 : 0,
|
||||
Security::clean((string) ($_POST['internal_notes'] ?? '')),
|
||||
$customerId,
|
||||
]
|
||||
);
|
||||
Http::flash('success', 'Customer record saved.');
|
||||
Http::redirect('/admin/customer?id=' . $customerId);
|
||||
}
|
||||
|
||||
public function deleteCustomer(): void
|
||||
{
|
||||
Security::verifyCsrf();
|
||||
$this->auth->requireStaff();
|
||||
$customerId = (int) ($_POST['customer_id'] ?? 0);
|
||||
$this->db->execute('UPDATE orders SET customer_id = NULL WHERE customer_id = ?', [$customerId]);
|
||||
$this->db->execute('DELETE FROM customers WHERE id = ?', [$customerId]);
|
||||
Http::flash('success', 'Customer record deleted. Historical orders were kept.');
|
||||
Http::redirect('/admin/customers');
|
||||
}
|
||||
|
||||
public function refundOrder(): void
|
||||
{
|
||||
Security::verifyCsrf();
|
||||
@@ -478,6 +583,22 @@ final class AdminController extends BaseController
|
||||
$this->config->set($key, trim((string) $_POST[$group][$field]));
|
||||
}
|
||||
}
|
||||
$emailTemplates = isset($_POST['order_emails']) && is_array($_POST['order_emails'])
|
||||
? $_POST['order_emails']
|
||||
: [];
|
||||
foreach (OrderStatusService::STATUSES as $status) {
|
||||
if (!isset($emailTemplates[$status]) || !is_array($emailTemplates[$status])) {
|
||||
continue;
|
||||
}
|
||||
$this->config->set(
|
||||
'order_emails.' . $status . '.subject',
|
||||
trim((string) ($emailTemplates[$status]['subject'] ?? ''))
|
||||
);
|
||||
$this->config->set(
|
||||
'order_emails.' . $status . '.body',
|
||||
trim((string) ($emailTemplates[$status]['body'] ?? ''))
|
||||
);
|
||||
}
|
||||
foreach (['square.access_token', 'mailgun.api_key', 'database.mysql_password'] as $secretKey) {
|
||||
[$group, $field] = explode('.', $secretKey, 2);
|
||||
$secret = isset($_POST[$group]) && is_array($_POST[$group])
|
||||
|
||||
@@ -154,6 +154,14 @@ final class AuthController extends BaseController
|
||||
Http::flash('error', 'Your account could not be loaded after registration.');
|
||||
Http::redirect('/login');
|
||||
}
|
||||
$this->db->execute(
|
||||
'UPDATE customers SET user_id = ?, updated_at = CURRENT_TIMESTAMP WHERE email = ?',
|
||||
[(int) $user['id'], $clean['email']]
|
||||
);
|
||||
$this->db->execute(
|
||||
'UPDATE orders SET user_id = ? WHERE user_id IS NULL AND customer_email = ?',
|
||||
[(int) $user['id'], $clean['email']]
|
||||
);
|
||||
$this->auth->login($user);
|
||||
Http::flash('success', 'Welcome, ' . $user['full_name'] . '. Your account is ready.');
|
||||
Http::redirect('/account');
|
||||
|
||||
@@ -11,8 +11,8 @@ use FatBottom\Core\Http;
|
||||
use FatBottom\Core\Security;
|
||||
use FatBottom\Core\View;
|
||||
use FatBottom\Services\CartService;
|
||||
use FatBottom\Services\Mailer;
|
||||
use FatBottom\Services\OrderService;
|
||||
use FatBottom\Services\OrderStatusService;
|
||||
|
||||
final class StoreController extends BaseController
|
||||
{
|
||||
@@ -22,7 +22,7 @@ final class StoreController extends BaseController
|
||||
CartService $cart,
|
||||
private readonly Database $db,
|
||||
private readonly OrderService $orders,
|
||||
private readonly Mailer $mailer
|
||||
private readonly OrderStatusService $orderStatuses
|
||||
) {
|
||||
parent::__construct($config, $auth, $cart);
|
||||
}
|
||||
@@ -169,13 +169,11 @@ final class StoreController extends BaseController
|
||||
$user ? (int) $user['id'] : null,
|
||||
(string) ($_POST['square_token'] ?? '')
|
||||
);
|
||||
$this->mailer->send(
|
||||
$input['customer_email'],
|
||||
'We received order ' . $order['order_number'],
|
||||
'<p>Thanks, ' . htmlspecialchars($input['customer_name'], ENT_QUOTES, 'UTF-8') . '.</p>'
|
||||
. '<p>Your order <strong>' . htmlspecialchars((string) $order['order_number'], ENT_QUOTES, 'UTF-8')
|
||||
. '</strong> has been paid and sent to the kitchen.</p>'
|
||||
);
|
||||
try {
|
||||
$this->orderStatuses->sendStatusEmail($order, 'pending');
|
||||
} catch (\Throwable) {
|
||||
Http::flash('warning', 'Your order was placed, but the confirmation email could not be sent.');
|
||||
}
|
||||
setcookie('fatbottom_cart', '', ['expires' => time() - 3600, 'path' => '/']);
|
||||
$_SESSION['last_order_id'] = (int) $order['id'];
|
||||
Http::redirect('/order/complete');
|
||||
@@ -198,6 +196,44 @@ final class StoreController extends BaseController
|
||||
'title' => 'Order Received',
|
||||
'order' => $order,
|
||||
'items' => $items,
|
||||
'trackingUrl' => $this->orderStatuses->trackingUrl($order),
|
||||
]));
|
||||
}
|
||||
|
||||
public function orderTracker(): void
|
||||
{
|
||||
$orderNumber = Security::clean((string) ($_GET['order'] ?? ''));
|
||||
$token = (string) ($_GET['token'] ?? '');
|
||||
$order = $this->db->fetch('SELECT * FROM orders WHERE order_number = ?', [$orderNumber]);
|
||||
if ($order === null) {
|
||||
Http::abort(404, 'Order not found.');
|
||||
}
|
||||
|
||||
$user = $this->auth->user();
|
||||
$ownsOrder = $user !== null && (
|
||||
(int) ($order['user_id'] ?? 0) === (int) $user['id']
|
||||
|| strtolower((string) $order['customer_email']) === strtolower((string) $user['email'])
|
||||
);
|
||||
$validToken = $token !== ''
|
||||
&& (string) $order['tracking_token'] !== ''
|
||||
&& hash_equals((string) $order['tracking_token'], $token);
|
||||
if (!$ownsOrder && !$validToken) {
|
||||
Http::abort(403, 'This tracking link is invalid.');
|
||||
}
|
||||
|
||||
View::render('store/order-tracker', $this->shared([
|
||||
'title' => 'Track ' . $order['order_number'],
|
||||
'order' => $order,
|
||||
'events' => $this->db->fetchAll(
|
||||
"SELECT * FROM order_events
|
||||
WHERE order_id = ? AND (status <> '' OR customer_visible = 1)
|
||||
ORDER BY created_at, id",
|
||||
[(int) $order['id']]
|
||||
),
|
||||
'items' => $this->db->fetchAll(
|
||||
'SELECT * FROM order_items WHERE order_id = ? ORDER BY id',
|
||||
[(int) $order['id']]
|
||||
),
|
||||
]));
|
||||
}
|
||||
|
||||
@@ -228,4 +264,3 @@ final class StoreController extends BaseController
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+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();
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace FatBottom\Services;
|
||||
|
||||
use FatBottom\Core\Database;
|
||||
|
||||
final class CustomerService
|
||||
{
|
||||
public function __construct(private readonly Database $db)
|
||||
{
|
||||
}
|
||||
|
||||
public function capture(array $customer, ?int $userId): int
|
||||
{
|
||||
$email = strtolower(trim((string) $customer['customer_email']));
|
||||
$record = $this->db->fetch('SELECT * FROM customers WHERE email = ?', [$email]);
|
||||
if ($record === null) {
|
||||
$this->db->execute(
|
||||
'INSERT INTO customers (user_id, email, full_name, phone) VALUES (?, ?, ?, ?)',
|
||||
[
|
||||
$userId,
|
||||
$email,
|
||||
(string) $customer['customer_name'],
|
||||
(string) $customer['customer_phone'],
|
||||
]
|
||||
);
|
||||
return (int) $this->db->pdo()->lastInsertId();
|
||||
}
|
||||
|
||||
$this->db->execute(
|
||||
'UPDATE customers SET user_id = ?, full_name = ?, phone = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
||||
[
|
||||
$userId ?: ((int) ($record['user_id'] ?? 0) ?: null),
|
||||
(string) $customer['customer_name'],
|
||||
(string) $customer['customer_phone'],
|
||||
(int) $record['id'],
|
||||
]
|
||||
);
|
||||
return (int) $record['id'];
|
||||
}
|
||||
|
||||
public function refresh(int $customerId): void
|
||||
{
|
||||
$this->db->execute(
|
||||
'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 = ?',
|
||||
[$customerId, $customerId, $customerId, $customerId, $customerId]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,8 @@ final class OrderService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Database $db,
|
||||
private readonly PaymentGateway $payments
|
||||
private readonly PaymentGateway $payments,
|
||||
private readonly CustomerService $customers
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -44,22 +45,27 @@ final class OrderService
|
||||
$pdo = $this->db->pdo();
|
||||
$pdo->beginTransaction();
|
||||
try {
|
||||
$customerId = $this->customers->capture($customer, $userId);
|
||||
$trackingToken = bin2hex(random_bytes(24));
|
||||
$statement = $pdo->prepare(
|
||||
'INSERT INTO orders
|
||||
(order_number, user_id, cart_id, customer_name, customer_email, customer_phone,
|
||||
(order_number, user_id, customer_id, cart_id, tracking_token,
|
||||
customer_name, customer_email, customer_phone,
|
||||
order_type, status, payment_status, payment_provider, payment_reference,
|
||||
subtotal_cents, tax_cents, total_cents, special_instructions, pickup_time)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
);
|
||||
$statement->execute([
|
||||
$orderNumber,
|
||||
$userId,
|
||||
$customerId,
|
||||
(int) $cart['id'],
|
||||
$trackingToken,
|
||||
$customer['customer_name'],
|
||||
$customer['customer_email'],
|
||||
$customer['customer_phone'],
|
||||
'pickup',
|
||||
'paid',
|
||||
'pending',
|
||||
'paid',
|
||||
$payment['provider'],
|
||||
$payment['reference'],
|
||||
@@ -89,11 +95,20 @@ final class OrderService
|
||||
}
|
||||
|
||||
$pdo->prepare(
|
||||
'INSERT INTO order_events (order_id, user_id, event_type, details) VALUES (?, ?, ?, ?)'
|
||||
)->execute([$orderId, $userId, 'order_placed', 'Online pickup order paid successfully.']);
|
||||
'INSERT INTO order_events
|
||||
(order_id, user_id, event_type, details, status, customer_visible)
|
||||
VALUES (?, ?, ?, ?, ?, 1)'
|
||||
)->execute([
|
||||
$orderId,
|
||||
$userId,
|
||||
'order_placed',
|
||||
'Online pickup order paid successfully and is awaiting staff acceptance.',
|
||||
'pending',
|
||||
]);
|
||||
$pdo->prepare(
|
||||
"UPDATE carts SET status = 'converted', converted_at = CURRENT_TIMESTAMP WHERE id = ?"
|
||||
)->execute([(int) $cart['id']]);
|
||||
$this->customers->refresh($customerId);
|
||||
$pdo->commit();
|
||||
} catch (\Throwable $error) {
|
||||
$pdo->rollBack();
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace FatBottom\Services;
|
||||
|
||||
use FatBottom\Core\Config;
|
||||
use FatBottom\Core\Database;
|
||||
use RuntimeException;
|
||||
|
||||
final class OrderStatusService
|
||||
{
|
||||
public const STATUSES = ['pending', 'accepted', 'declined', 'processing', 'ready', 'completed'];
|
||||
|
||||
public function __construct(
|
||||
private readonly Config $config,
|
||||
private readonly Database $db,
|
||||
private readonly Mailer $mailer
|
||||
) {
|
||||
}
|
||||
|
||||
public function update(
|
||||
int $orderId,
|
||||
string $status,
|
||||
string $note,
|
||||
bool $customerVisible,
|
||||
bool $sendEmail,
|
||||
int $staffUserId
|
||||
): array {
|
||||
if (!in_array($status, self::STATUSES, true) || $status === 'pending') {
|
||||
throw new RuntimeException('Choose a valid order status.');
|
||||
}
|
||||
$order = $this->db->fetch('SELECT * FROM orders WHERE id = ?', [$orderId]);
|
||||
if ($order === null) {
|
||||
throw new RuntimeException('Order not found.');
|
||||
}
|
||||
|
||||
$sendEmail = $sendEmail || $status === 'accepted';
|
||||
$pdo = $this->db->pdo();
|
||||
$pdo->beginTransaction();
|
||||
try {
|
||||
$this->db->execute(
|
||||
'UPDATE orders SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
||||
[$status, $orderId]
|
||||
);
|
||||
$this->db->execute(
|
||||
'INSERT INTO order_events
|
||||
(order_id, user_id, event_type, details, status, note, customer_visible, email_sent)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 0)',
|
||||
[
|
||||
$orderId,
|
||||
$staffUserId,
|
||||
'status_changed',
|
||||
'Status changed to ' . order_status_label($status) . ($note !== '' ? '. ' . $note : '.'),
|
||||
$status,
|
||||
$note,
|
||||
$customerVisible && $note !== '' ? 1 : 0,
|
||||
]
|
||||
);
|
||||
$eventId = (int) $pdo->lastInsertId();
|
||||
$pdo->commit();
|
||||
} catch (\Throwable $error) {
|
||||
$pdo->rollBack();
|
||||
throw $error;
|
||||
}
|
||||
|
||||
$emailError = null;
|
||||
if ($sendEmail) {
|
||||
$order['status'] = $status;
|
||||
try {
|
||||
$this->sendStatusEmail($order, $status, $customerVisible ? $note : '');
|
||||
$this->db->execute('UPDATE order_events SET email_sent = 1 WHERE id = ?', [$eventId]);
|
||||
} catch (\Throwable $error) {
|
||||
$emailError = $error->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
return ['email_requested' => $sendEmail, 'email_error' => $emailError];
|
||||
}
|
||||
|
||||
public function sendStatusEmail(array $order, string $status, string $visibleNote = ''): void
|
||||
{
|
||||
$subjectTemplate = (string) $this->config->get(
|
||||
'order_emails.' . $status . '.subject',
|
||||
'{{business_name}} order {{order_number}}: {{status_label}}'
|
||||
);
|
||||
$bodyTemplate = (string) $this->config->get(
|
||||
'order_emails.' . $status . '.body',
|
||||
"Hi {{customer_name}},\n\nYour order {{order_number}} is now {{status_label}}.\n\n{{note}}\n\n{{tracking_url}}"
|
||||
);
|
||||
$values = [
|
||||
'{{business_name}}' => (string) $this->config->get('business.name', 'Fat Bottom Grille'),
|
||||
'{{customer_name}}' => (string) $order['customer_name'],
|
||||
'{{order_number}}' => (string) $order['order_number'],
|
||||
'{{status_label}}' => order_status_label($status),
|
||||
'{{note}}' => $visibleNote,
|
||||
'{{rejection_reason}}' => $status === 'declined' ? $visibleNote : '',
|
||||
'{{tracking_url}}' => $this->trackingUrl($order),
|
||||
];
|
||||
$subject = trim(strip_tags(strtr($subjectTemplate, $values)));
|
||||
|
||||
$html = nl2br(strtr(htmlspecialchars($bodyTemplate, ENT_QUOTES, 'UTF-8'), [
|
||||
'{{business_name}}' => e($values['{{business_name}}']),
|
||||
'{{customer_name}}' => e($values['{{customer_name}}']),
|
||||
'{{order_number}}' => e($values['{{order_number}}']),
|
||||
'{{status_label}}' => e($values['{{status_label}}']),
|
||||
'{{note}}' => e($values['{{note}}']),
|
||||
'{{rejection_reason}}' => e($values['{{rejection_reason}}']),
|
||||
'{{tracking_url}}' => '<a href="' . e($values['{{tracking_url}}']) . '">View your order tracker</a>',
|
||||
]));
|
||||
|
||||
$this->mailer->send((string) $order['customer_email'], $subject, '<p>' . $html . '</p>');
|
||||
}
|
||||
|
||||
public function trackingUrl(array $order): string
|
||||
{
|
||||
return rtrim((string) $this->config->get('app.url'), '/')
|
||||
. '/order/track?order=' . rawurlencode((string) $order['order_number'])
|
||||
. '&token=' . rawurlencode((string) $order['tracking_token']);
|
||||
}
|
||||
}
|
||||
@@ -49,7 +49,7 @@ final class StatsService
|
||||
AND DATE(o.placed_at) = CURRENT_DATE"
|
||||
)['value'],
|
||||
'open_orders' => (int) $this->db->fetch(
|
||||
"SELECT COUNT(*) AS value FROM orders WHERE status IN ('paid', 'preparing', 'ready')"
|
||||
"SELECT COUNT(*) AS value FROM orders WHERE status IN ('pending', 'accepted', 'processing', 'ready')"
|
||||
)['value'],
|
||||
'abandoned_carts' => (int) $this->db->fetch(
|
||||
"SELECT COUNT(*) AS value FROM carts
|
||||
@@ -81,7 +81,7 @@ final class StatsService
|
||||
AND date(o.placed_at, 'localtime') = date('now', 'localtime')"
|
||||
)['value'],
|
||||
'open_orders' => (int) $this->db->fetch(
|
||||
"SELECT COUNT(*) AS value FROM orders WHERE status IN ('paid', 'preparing', 'ready')"
|
||||
"SELECT COUNT(*) AS value FROM orders WHERE status IN ('pending', 'accepted', 'processing', 'ready')"
|
||||
)['value'],
|
||||
'abandoned_carts' => (int) $this->db->fetch(
|
||||
"SELECT COUNT(*) AS value FROM carts
|
||||
|
||||
@@ -45,6 +45,21 @@ function checked(mixed $value): string
|
||||
return (bool) $value ? 'checked' : '';
|
||||
}
|
||||
|
||||
function order_status_label(string $status): string
|
||||
{
|
||||
return [
|
||||
'pending' => 'Awaiting acceptance',
|
||||
'paid' => 'Awaiting acceptance',
|
||||
'accepted' => 'Accepted',
|
||||
'declined' => 'Declined',
|
||||
'processing' => 'Processing / Active',
|
||||
'preparing' => 'Processing / Active',
|
||||
'ready' => 'Ready for pickup',
|
||||
'completed' => 'Complete',
|
||||
'cancelled' => 'Declined',
|
||||
][$status] ?? ucwords(str_replace('_', ' ', $status));
|
||||
}
|
||||
|
||||
function store_datetime(?string $value, string $format = 'M j, Y g:i A'): string
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
|
||||
Reference in New Issue
Block a user