- 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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user