Files
order/app/Controllers/AdminController.php
T
Ty Clifford 45ef31e61f - Email 2FA is now optional and disabled by default.
Existing and new accounts default to 2FA off.
Registration signs users in directly.
Users can enable it under My Account.
Administrators can manage it under Users & Staff.
Verified password-only and opted-in email-code login flows.
PHP/JavaScript checks and browser console passed.
2026-06-10 16:40:22 -04:00

656 lines
27 KiB
PHP

<?php
declare(strict_types=1);
namespace FatBottom\Controllers;
use FatBottom\Core\Auth;
use FatBottom\Core\Config;
use FatBottom\Core\Database;
use FatBottom\Core\Http;
use FatBottom\Core\Security;
use FatBottom\Core\View;
use FatBottom\Services\CartService;
use FatBottom\Services\Mailer;
use FatBottom\Services\MenuPdfExporter;
use FatBottom\Services\MySqlMigrator;
use FatBottom\Services\PaymentGateway;
use FatBottom\Services\StatsService;
final class AdminController extends BaseController
{
public function __construct(
Config $config,
Auth $auth,
CartService $cart,
private readonly Database $db,
private readonly StatsService $stats,
private readonly PaymentGateway $payments,
private readonly Mailer $mailer
) {
parent::__construct($config, $auth, $cart);
}
public function dashboard(): void
{
$this->auth->requireStaff();
$recentOrders = $this->db->fetchAll(
'SELECT * FROM orders ORDER BY placed_at DESC LIMIT 10'
);
$popularItems = $this->db->fetchAll(
'SELECT oi.item_name, SUM(oi.quantity) AS quantity, SUM(oi.line_total_cents) AS sales_cents
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
WHERE o.payment_status = ?
GROUP BY oi.item_name
ORDER BY quantity DESC LIMIT 6',
['paid']
);
$traffic = $this->db->isSqlite()
? $this->db->fetchAll(
"SELECT date(created_at, 'localtime') AS day, COUNT(DISTINCT session_id) AS visitors
FROM visitor_events
WHERE created_at >= datetime('now', '-6 days')
GROUP BY date(created_at, 'localtime')
ORDER BY day"
)
: $this->db->fetchAll(
"SELECT DATE(created_at) AS day, COUNT(DISTINCT session_id) AS visitors
FROM visitor_events
WHERE created_at >= DATE_SUB(NOW(), INTERVAL 6 DAY)
GROUP BY DATE(created_at)
ORDER BY day"
);
View::render('admin/dashboard', $this->shared([
'title' => 'Dashboard',
'adminPage' => 'dashboard',
'stats' => $this->stats->dashboard(),
'recentOrders' => $recentOrders,
'popularItems' => $popularItems,
'traffic' => $traffic,
]));
}
public function orders(): void
{
$this->auth->requireStaff();
$status = Security::clean((string) ($_GET['status'] ?? ''));
$query = Security::clean((string) ($_GET['q'] ?? ''));
$where = ['1 = 1'];
$parameters = [];
if ($status !== '') {
$where[] = 'o.status = ?';
$parameters[] = $status;
}
if ($query !== '') {
$where[] = '(o.order_number LIKE ? OR o.customer_name LIKE ? OR o.customer_email LIKE ?)';
$parameters[] = '%' . $query . '%';
$parameters[] = '%' . $query . '%';
$parameters[] = '%' . $query . '%';
}
$orders = $this->db->fetchAll(
'SELECT o.*, COALESCE(SUM(r.amount_cents), 0) AS refunded_cents
FROM orders o
LEFT JOIN refunds r ON r.order_id = o.id
WHERE ' . implode(' AND ', $where) . '
GROUP BY o.id
ORDER BY o.placed_at DESC LIMIT 200',
$parameters
);
View::render('admin/orders', $this->shared([
'title' => 'Orders',
'adminPage' => 'orders',
'orders' => $orders,
'statusFilter' => $status,
'query' => $query,
]));
}
public function orderDetail(): void
{
$this->auth->requireStaff();
$orderId = (int) ($_GET['id'] ?? 0);
$order = $this->db->fetch('SELECT * FROM orders WHERE id = ?', [$orderId]);
if ($order === null) {
Http::abort(404, 'Order not found.');
}
View::render('admin/order-detail', $this->shared([
'title' => 'Order ' . $order['order_number'],
'adminPage' => 'orders',
'order' => $order,
'items' => $this->db->fetchAll('SELECT * FROM order_items WHERE order_id = ?', [$orderId]),
'events' => $this->db->fetchAll(
'SELECT oe.*, u.full_name AS staff_name
FROM order_events oe LEFT JOIN users u ON u.id = oe.user_id
WHERE oe.order_id = ? ORDER BY oe.created_at DESC, oe.id DESC',
[$orderId]
),
'refunds' => $this->db->fetchAll(
'SELECT r.*, u.full_name AS staff_name
FROM refunds r LEFT JOIN users u ON u.id = r.staff_user_id
WHERE r.order_id = ? ORDER BY r.created_at DESC',
[$orderId]
),
]));
}
public function updateOrder(): void
{
Security::verifyCsrf();
$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');
}
$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 refundOrder(): void
{
Security::verifyCsrf();
$staff = $this->auth->requireStaff();
$orderId = (int) ($_POST['order_id'] ?? 0);
$order = $this->db->fetch(
'SELECT o.*, COALESCE(SUM(r.amount_cents), 0) AS refunded_cents
FROM orders o LEFT JOIN refunds r ON r.order_id = o.id
WHERE o.id = ? GROUP BY o.id',
[$orderId]
);
if ($order === null) {
Http::abort(404, 'Order not found.');
}
$amountCents = (int) round(((float) ($_POST['amount'] ?? 0)) * 100);
$available = (int) $order['total_cents'] - (int) $order['refunded_cents'];
$reason = Security::clean((string) ($_POST['reason'] ?? 'Staff refund'));
if ($amountCents <= 0 || $amountCents > $available) {
Http::flash('error', 'Refund amount must be greater than zero and no more than the remaining paid amount.');
Http::redirect('/admin/order?id=' . $orderId);
}
try {
$refund = $this->payments->refund((string) $order['payment_reference'], $amountCents, $reason);
$this->db->execute(
'INSERT INTO refunds (order_id, staff_user_id, amount_cents, reason, provider_reference)
VALUES (?, ?, ?, ?, ?)',
[$orderId, (int) $staff['id'], $amountCents, $reason, $refund['reference']]
);
$newRefunded = (int) $order['refunded_cents'] + $amountCents;
$paymentStatus = $newRefunded >= (int) $order['total_cents'] ? 'refunded' : 'partially_refunded';
$this->db->execute('UPDATE orders SET payment_status = ? WHERE id = ?', [$paymentStatus, $orderId]);
$this->db->execute(
'INSERT INTO order_events (order_id, user_id, event_type, details) VALUES (?, ?, ?, ?)',
[$orderId, (int) $staff['id'], 'refund_issued', sprintf('$%.2f refunded: %s', $amountCents / 100, $reason)]
);
Http::flash('success', 'Refund issued successfully.');
} catch (\Throwable $error) {
Http::flash('error', $error->getMessage());
}
Http::redirect('/admin/order?id=' . $orderId);
}
public function menu(): void
{
$this->auth->requireStaff();
$categories = $this->db->fetchAll('SELECT * FROM menu_categories ORDER BY display_order, name');
$items = $this->db->fetchAll(
'SELECT mi.*, mc.name AS category_name
FROM menu_items mi JOIN menu_categories mc ON mc.id = mi.category_id
ORDER BY mc.display_order, mi.display_order, mi.name'
);
View::render('admin/menu', $this->shared([
'title' => 'Menu Manager',
'adminPage' => 'menu',
'categories' => $categories,
'items' => $items,
]));
}
public function saveCategory(): void
{
Security::verifyCsrf();
$this->auth->requireStaff();
$id = (int) ($_POST['id'] ?? 0);
$name = Security::clean((string) ($_POST['name'] ?? ''));
if ($name === '') {
Http::flash('error', 'Category name is required.');
Http::redirect('/admin/menu');
}
$values = [
$name,
Security::slug((string) ($_POST['slug'] ?? $name)),
Security::clean((string) ($_POST['description'] ?? '')),
(int) ($_POST['display_order'] ?? 0),
isset($_POST['active']) ? 1 : 0,
];
if ($id > 0) {
$values[] = $id;
$this->db->execute(
'UPDATE menu_categories SET name = ?, slug = ?, description = ?, display_order = ?, active = ?,
updated_at = CURRENT_TIMESTAMP WHERE id = ?',
$values
);
} else {
$this->db->execute(
'INSERT INTO menu_categories (name, slug, description, display_order, active) VALUES (?, ?, ?, ?, ?)',
$values
);
}
Http::flash('success', 'Menu category saved.');
Http::redirect('/admin/menu');
}
public function saveItem(): void
{
Security::verifyCsrf();
$this->auth->requireStaff();
$id = (int) ($_POST['id'] ?? 0);
$name = Security::clean((string) ($_POST['name'] ?? ''));
$categoryId = (int) ($_POST['category_id'] ?? 0);
$priceCents = (int) round(((float) ($_POST['price'] ?? 0)) * 100);
if ($name === '' || $categoryId < 1 || $priceCents < 0) {
Http::flash('error', 'Item name, category, and a valid price are required.');
Http::redirect('/admin/menu');
}
$compareAt = trim((string) ($_POST['compare_at_price'] ?? '')) === ''
? null
: (int) round(((float) $_POST['compare_at_price']) * 100);
$tags = array_values(array_filter(array_map(
static fn (string $tag): string => Security::clean($tag),
explode(',', (string) ($_POST['dietary_tags'] ?? ''))
)));
$values = [
$categoryId,
$name,
Security::slug((string) ($_POST['slug'] ?? $name)),
Security::clean((string) ($_POST['description'] ?? '')),
$priceCents,
$compareAt,
Security::clean((string) ($_POST['image_url'] ?? '')),
json_encode($tags, JSON_UNESCAPED_SLASHES),
isset($_POST['featured']) ? 1 : 0,
isset($_POST['active']) ? 1 : 0,
(int) ($_POST['display_order'] ?? 0),
];
if ($id > 0) {
$values[] = $id;
$this->db->execute(
'UPDATE menu_items SET category_id = ?, name = ?, slug = ?, description = ?, price_cents = ?,
compare_at_cents = ?, image_url = ?, dietary_tags = ?, featured = ?, active = ?,
display_order = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
$values
);
} else {
$this->db->execute(
'INSERT INTO menu_items
(category_id, name, slug, description, price_cents, compare_at_cents, image_url,
dietary_tags, featured, active, display_order)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
$values
);
}
Http::flash('success', 'Menu item saved.');
Http::redirect('/admin/menu');
}
public function specials(): void
{
$this->auth->requireStaff();
View::render('admin/specials', $this->shared([
'title' => 'Homepage Specials',
'adminPage' => 'specials',
'specials' => $this->db->fetchAll(
'SELECT s.*, mi.name AS item_name
FROM specials s LEFT JOIN menu_items mi ON mi.id = s.menu_item_id
ORDER BY s.display_order, s.id'
),
'items' => $this->db->fetchAll('SELECT id, name FROM menu_items WHERE active = 1 ORDER BY name'),
]));
}
public function saveSpecial(): void
{
Security::verifyCsrf();
$this->auth->requireStaff();
$id = (int) ($_POST['id'] ?? 0);
$title = Security::clean((string) ($_POST['title'] ?? ''));
if ($title === '') {
Http::flash('error', 'Special title is required.');
Http::redirect('/admin/specials');
}
$price = trim((string) ($_POST['price'] ?? '')) === ''
? null
: (int) round(((float) $_POST['price']) * 100);
$values = [
(int) ($_POST['menu_item_id'] ?? 0) ?: null,
$title,
Security::clean((string) ($_POST['description'] ?? '')),
Security::clean((string) ($_POST['badge'] ?? 'Special')),
$price,
utc_datetime(Security::clean((string) ($_POST['starts_at'] ?? ''))),
utc_datetime(Security::clean((string) ($_POST['ends_at'] ?? ''))),
isset($_POST['active']) ? 1 : 0,
(int) ($_POST['display_order'] ?? 0),
];
if ($id > 0) {
$values[] = $id;
$this->db->execute(
'UPDATE specials SET menu_item_id = ?, title = ?, description = ?, badge = ?, price_cents = ?,
starts_at = ?, ends_at = ?, active = ?, display_order = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
$values
);
} else {
$this->db->execute(
'INSERT INTO specials
(menu_item_id, title, description, badge, price_cents, starts_at, ends_at, active, display_order)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
$values
);
}
Http::flash('success', 'Homepage special saved.');
Http::redirect('/admin/specials');
}
public function users(): void
{
$this->auth->requireAdmin();
$query = Security::clean((string) ($_GET['q'] ?? ''));
$parameters = [];
$where = '1 = 1';
if ($query !== '') {
$where = '(email LIKE ? OR full_name LIKE ? OR phone LIKE ?)';
$parameters = ['%' . $query . '%', '%' . $query . '%', '%' . $query . '%'];
}
View::render('admin/users', $this->shared([
'title' => 'Users & Staff',
'adminPage' => 'users',
'users' => $this->db->fetchAll("SELECT * FROM users WHERE {$where} ORDER BY role, full_name", $parameters),
'query' => $query,
]));
}
public function saveUser(): void
{
Security::verifyCsrf();
$admin = $this->auth->requireAdmin();
$id = (int) ($_POST['id'] ?? 0);
$role = Security::clean((string) ($_POST['role'] ?? 'customer'));
if (!in_array($role, ['customer', 'staff', 'manager', 'admin'], true)) {
$role = 'customer';
}
if ($id === (int) $admin['id'] && !isset($_POST['active'])) {
Http::flash('error', 'You cannot deactivate your own account.');
Http::redirect('/admin/users');
}
if ($id > 0) {
$this->db->execute(
'UPDATE users SET full_name = ?, phone = ?, role = ?, active = ?, newsletter_opt_in = ?,
two_factor_enabled = ?,
updated_at = CURRENT_TIMESTAMP WHERE id = ?',
[
Security::clean((string) ($_POST['full_name'] ?? '')),
Security::clean((string) ($_POST['phone'] ?? '')),
$role,
isset($_POST['active']) ? 1 : 0,
isset($_POST['newsletter_opt_in']) ? 1 : 0,
isset($_POST['two_factor_enabled']) ? 1 : 0,
$id,
]
);
} else {
$email = strtolower(Security::clean((string) ($_POST['email'] ?? '')));
$password = (string) ($_POST['password'] ?? '');
if (!filter_var($email, FILTER_VALIDATE_EMAIL) || strlen($password) < 10) {
Http::flash('error', 'New staff accounts need a valid email and a 10-character password.');
Http::redirect('/admin/users');
}
$this->db->execute(
'INSERT INTO users
(email, password_hash, full_name, phone, role, active, newsletter_opt_in,
two_factor_enabled, email_verified_at)
VALUES (?, ?, ?, ?, ?, 1, ?, ?, CURRENT_TIMESTAMP)',
[
$email,
password_hash($password, PASSWORD_DEFAULT),
Security::clean((string) ($_POST['full_name'] ?? '')),
Security::clean((string) ($_POST['phone'] ?? '')),
$role,
isset($_POST['newsletter_opt_in']) ? 1 : 0,
isset($_POST['two_factor_enabled']) ? 1 : 0,
]
);
}
Http::flash('success', 'User account saved.');
Http::redirect('/admin/users');
}
public function settings(): void
{
$this->auth->requireAdmin();
View::render('admin/settings', $this->shared([
'title' => 'Store Settings',
'adminPage' => 'settings',
'taxRates' => $this->db->fetchAll('SELECT * FROM tax_rates ORDER BY jurisdiction, name'),
]));
}
public function saveSettings(): void
{
Security::verifyCsrf();
$this->auth->requireAdmin();
$textFields = [
'app.url',
'business.name',
'business.phone',
'business.email',
'business.street',
'business.city',
'business.state',
'business.postal_code',
'business.hours',
'business.announcement',
'square.environment',
'square.application_id',
'square.location_id',
'mailgun.domain',
'mailgun.from_name',
'mailgun.from_email',
'database.mysql_host',
'database.mysql_database',
'database.mysql_username',
];
foreach ($textFields as $key) {
[$group, $field] = explode('.', $key, 2);
if (isset($_POST[$group]) && is_array($_POST[$group]) && array_key_exists($field, $_POST[$group])) {
$this->config->set($key, trim((string) $_POST[$group][$field]));
}
}
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])
? trim((string) ($_POST[$group][$field] ?? ''))
: '';
if ($secret !== '') {
$this->config->set($secretKey, $secret);
}
}
$ordering = isset($_POST['ordering']) && is_array($_POST['ordering']) ? $_POST['ordering'] : [];
$square = isset($_POST['square']) && is_array($_POST['square']) ? $_POST['square'] : [];
$mailgun = isset($_POST['mailgun']) && is_array($_POST['mailgun']) ? $_POST['mailgun'] : [];
$database = isset($_POST['database']) && is_array($_POST['database']) ? $_POST['database'] : [];
$this->config->set('ordering.accepting_orders', isset($ordering['accepting_orders']));
$this->config->set('ordering.pickup_eta_minutes', max(5, (int) ($ordering['pickup_eta_minutes'] ?? 25)));
$this->config->set('square.enabled', isset($square['enabled']));
$this->config->set('mailgun.enabled', isset($mailgun['enabled']));
$this->config->set('database.mysql_port', (int) ($database['mysql_port'] ?? 3306));
$this->config->save();
Http::flash('success', 'Store settings saved.');
Http::redirect('/admin/settings');
}
public function saveTax(): void
{
Security::verifyCsrf();
$this->auth->requireAdmin();
$id = (int) ($_POST['id'] ?? 0);
$values = [
Security::clean((string) ($_POST['name'] ?? 'Tax')),
Security::clean((string) ($_POST['jurisdiction'] ?? 'state')),
(int) round(((float) ($_POST['rate'] ?? 0)) * 100),
isset($_POST['active']) ? 1 : 0,
];
if ($id > 0) {
$values[] = $id;
$this->db->execute(
'UPDATE tax_rates SET name = ?, jurisdiction = ?, rate_basis_points = ?, active = ?,
updated_at = CURRENT_TIMESTAMP WHERE id = ?',
$values
);
} else {
$this->db->execute(
'INSERT INTO tax_rates (name, jurisdiction, rate_basis_points, active) VALUES (?, ?, ?, ?)',
$values
);
}
Http::flash('success', 'Tax rate saved.');
Http::redirect('/admin/settings');
}
public function newsletters(): void
{
$this->auth->requireStaff();
View::render('admin/newsletters', $this->shared([
'title' => 'Newsletters',
'adminPage' => 'newsletters',
'newsletters' => $this->db->fetchAll(
'SELECT n.*, u.full_name AS author
FROM newsletters n LEFT JOIN users u ON u.id = n.created_by
ORDER BY n.created_at DESC'
),
'recipientCount' => (int) $this->db->fetch(
'SELECT COUNT(*) AS count FROM users WHERE newsletter_opt_in = 1 AND active = 1'
)['count'],
]));
}
public function sendNewsletter(): void
{
Security::verifyCsrf();
$staff = $this->auth->requireStaff();
$subject = Security::clean((string) ($_POST['subject'] ?? ''));
$content = trim((string) ($_POST['content'] ?? ''));
if ($subject === '' || $content === '') {
Http::flash('error', 'Newsletter subject and message are required.');
Http::redirect('/admin/newsletters');
}
$recipients = $this->db->fetchAll(
'SELECT id, email, full_name FROM users WHERE newsletter_opt_in = 1 AND active = 1 ORDER BY id'
);
$sent = 0;
foreach ($recipients as $recipient) {
$signature = hash_hmac('sha256', (string) $recipient['id'], $this->newsletterKey());
$unsubscribe = rtrim((string) $this->config->get('app.url'), '/')
. '/unsubscribe?user=' . $recipient['id'] . '&signature=' . $signature;
$html = '<p>Hi ' . htmlspecialchars((string) $recipient['full_name'], ENT_QUOTES, 'UTF-8') . ',</p>'
. '<div>' . nl2br(htmlspecialchars($content, ENT_QUOTES, 'UTF-8')) . '</div>'
. '<hr><p style="font-size:12px">You received this because you joined the Fat Bottom Grille news list. '
. '<a href="' . htmlspecialchars($unsubscribe, ENT_QUOTES, 'UTF-8') . '">Unsubscribe</a></p>';
try {
$this->mailer->send((string) $recipient['email'], $subject, $html);
$sent++;
} catch (\Throwable) {
continue;
}
}
$this->db->execute(
'INSERT INTO newsletters (subject, content_html, status, recipient_count, created_by, sent_at)
VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)',
[$subject, nl2br(htmlspecialchars($content, ENT_QUOTES, 'UTF-8')), 'sent', $sent, (int) $staff['id']]
);
Http::flash('success', "Newsletter sent to {$sent} subscribed users.");
Http::redirect('/admin/newsletters');
}
public function exportMenuPdf(): void
{
$this->auth->requireStaff();
$categories = $this->db->fetchAll('SELECT * FROM menu_categories WHERE active = 1 ORDER BY display_order, name');
foreach ($categories as &$category) {
$category['items'] = $this->db->fetchAll(
'SELECT * FROM menu_items WHERE category_id = ? AND active = 1 ORDER BY display_order, name',
[(int) $category['id']]
);
}
unset($category);
(new MenuPdfExporter())->output((string) $this->config->get('business.name'), $categories);
}
public function exportOrdersCsv(): never
{
$this->auth->requireStaff();
$orders = $this->db->fetchAll('SELECT * FROM orders ORDER BY placed_at DESC');
header('Content-Type: text/csv');
header('Content-Disposition: attachment; filename="fat-bottom-orders-' . date('Y-m-d') . '.csv"');
$output = fopen('php://output', 'wb');
fputcsv($output, [
'Order', 'Placed', 'Customer', 'Email', 'Phone', 'Status', 'Payment',
'Subtotal', 'Tax', 'Total', 'Instructions',
]);
foreach ($orders as $order) {
fputcsv($output, [
$order['order_number'],
$order['placed_at'],
$order['customer_name'],
$order['customer_email'],
$order['customer_phone'],
$order['status'],
$order['payment_status'],
number_format((int) $order['subtotal_cents'] / 100, 2, '.', ''),
number_format((int) $order['tax_cents'] / 100, 2, '.', ''),
number_format((int) $order['total_cents'] / 100, 2, '.', ''),
$order['special_instructions'],
]);
}
fclose($output);
exit;
}
public function migrateMySql(): void
{
Security::verifyCsrf();
$this->auth->requireAdmin();
try {
$result = (new MySqlMigrator($this->config))->migrate();
if (isset($_POST['switch_driver'])) {
$this->config->set('database.driver', 'mysql');
$this->config->save();
}
Http::flash(
'success',
sprintf('Copied %d tables and %d rows to MySQL%s.', $result['tables'], $result['rows'], isset($_POST['switch_driver']) ? ' and enabled MySQL mode' : '')
);
} catch (\Throwable $error) {
Http::flash('error', $error->getMessage());
}
Http::redirect('/admin/settings');
}
private function newsletterKey(): string
{
return hash('sha256', (string) $this->config->get('app.key') . '|newsletter');
}
}