- 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])