- 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:
Ty Clifford
2026-06-14 13:35:31 -04:00
parent 3417589a7c
commit 03348cad79
24 changed files with 1218 additions and 72 deletions
+136 -15
View File
@@ -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])
+8
View File
@@ -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');
+45 -10
View File
@@ -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
View File
@@ -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();
+57
View File
@@ -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]
);
}
}
+21 -6
View File
@@ -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();
+121
View File
@@ -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']);
}
}
+2 -2
View File
@@ -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
+15
View File
@@ -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 === '') {