- 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
+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