feat: implement admin SMTP settings management and automated order notification system

This commit is contained in:
pbao9
2026-05-13 10:26:47 +07:00
parent d82c428f23
commit 7311a20324
8 changed files with 368 additions and 1 deletions
@@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\Admin\Setting;
use App\Http\Controllers\Controller;
use App\Http\Facade\SignRSA;
use App\Models\Setting;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Validator;
class SettingController extends Controller
{
/**
* GET /administrator/settings
* Returns all setting keys: setting, smtp.
*/
public function index(): JsonResponse
{
$setting = Setting::where('key', 'setting')->first();
$smtp = Setting::where('key', 'smtp')->first();
$settingValue = $setting ? json_decode((string) $setting->value, true) : [];
$smtpValue = $smtp ? json_decode((string) $smtp->value, true) : [];
// Never expose raw smtp password
if (isset($smtpValue['password']) && !empty($smtpValue['password'])) {
$smtpValue['password'] = '********';
}
return response()->json([
'error' => false,
'data' => [
'setting' => $settingValue,
'smtp' => $smtpValue,
],
]);
}
/**
* PUT /administrator/settings
* Accepts: { setting: {...}, smtp: {...} }
*/
public function update(Request $request): JsonResponse
{
$validator = Validator::make($request->all(), [
'setting' => 'sometimes|array',
'smtp' => 'sometimes|array',
'smtp.host' => 'sometimes|string',
'smtp.port' => 'sometimes|integer',
'smtp.user' => 'sometimes|email',
'smtp.encryption' => 'sometimes|in:ssl,tls,starttls,',
]);
if ($validator->fails()) {
return response()->json(['error' => true, 'message' => $validator->errors()->first()], 422);
}
if ($request->has('setting')) {
$current = Setting::where('key', 'setting')->first();
$existing = $current ? json_decode((string) $current->value, true) : [];
$merged = array_merge($existing, $request->input('setting'));
Setting::updateOrCreate(
['key' => 'setting'],
['value' => json_encode($merged, JSON_UNESCAPED_UNICODE)]
);
}
if ($request->has('smtp')) {
$smtpData = $request->input('smtp');
$current = Setting::where('key', 'smtp')->first();
$existing = $current ? json_decode((string) $current->value, true) : [];
// Keep existing password if placeholder sent (not changed)
if (isset($smtpData['password']) && $smtpData['password'] === '********') {
$smtpData['password'] = $existing['password'] ?? '';
} elseif (isset($smtpData['password']) && !empty($smtpData['password'])) {
$smtpData['password'] = SignRSA::EncryptFunc($smtpData['password']);
}
$merged = array_merge($existing, $smtpData);
Setting::updateOrCreate(
['key' => 'smtp'],
['value' => json_encode($merged, JSON_UNESCAPED_UNICODE)]
);
// Flush mail config cache so new SMTP settings take effect immediately
Cache::forget('mail_config');
}
return response()->json([
'error' => false,
'message' => 'Cập nhật cài đặt thành công.',
]);
}
}
@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Jobs\Mail\Admin;
use App\Mail\Admin\AdminOrderNotification;
use App\Models\Setting;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
class AdminOrderNotificationJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $timeout = 60;
public function __construct(public readonly array $data) {}
public function handle(): void
{
$setting = Setting::where('key', 'setting')->first();
$settingValue = $setting ? json_decode((string) $setting->value, true) : [];
$adminEmail = $settingValue['admin_email'] ?? null;
if (empty($adminEmail)) {
Log::warning('AdminOrderNotificationJob: admin_email not configured in settings.');
return;
}
$payload = array_merge($this->data, [
'company_name' => $settingValue['company'] ?? 'Portal Admin',
]);
try {
Mail::to($adminEmail)->send(new AdminOrderNotification($payload));
} catch (\Throwable $e) {
Log::error('AdminOrderNotificationJob: failed to send email. ' . $e->getMessage(), [
'admin_email' => $adminEmail,
'order_num' => $this->data['order_num'] ?? null,
]);
}
}
public function failed(\Throwable $e): void
{
Log::error('AdminOrderNotificationJob: job failed.', [
'error' => $e->getMessage(),
'order_num' => $this->data['order_num'] ?? null,
]);
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Mail\Admin;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class AdminOrderNotification extends Mailable
{
use Queueable, SerializesModels;
public function __construct(public readonly array $data) {}
public function envelope(): Envelope
{
return new Envelope(
subject: '[ADMIN] Đơn hàng mới #' . ($this->data['order_num'] ?? 'N/A')
. ' — ' . ($this->data['customer_email'] ?? ''),
);
}
public function content(): Content
{
return new Content(
view: 'emails.admin.order-notification',
with: $this->data,
);
}
public function attachments(): array
{
return [];
}
}
+29
View File
@@ -4,6 +4,7 @@ namespace App\Services\Api\Order;
use App\Enums\Order\OrderStatus;
use App\Helpers\OrderHelper;
use App\Jobs\Mail\Admin\AdminOrderNotificationJob;
use App\Jobs\VirtualMachine\Create\CreateVirtualMachineJob;
use App\Models\Addon;
use App\Models\Log as CustomerActivityLog;
@@ -151,6 +152,8 @@ class OrderService
$this->provisionService($processedOrder, $item, $customer);
}
$this->dispatchAdminNotification($processedOrder, $customer);
return $processedOrder;
});
}
@@ -268,6 +271,32 @@ class OrderService
};
}
private function dispatchAdminNotification(Order $order, $customer): void
{
try {
$item = $order->orderItems->first();
$product = $item?->product;
$productType = $product?->category?->name ?? $product?->type ?? 'N/A';
AdminOrderNotificationJob::dispatch([
'order_num' => $order->order_num,
'customer_name' => trim($customer->firstname . ' ' . $customer->lastname),
'customer_email' => $customer->email,
'client_ip' => $order->client_ip ?? request()?->ip(),
'product_name' => $product?->name ?? 'N/A',
'product_type' => $productType,
'billing_cycle' => $item?->billing_cycle ?? 'N/A',
'total' => $order->total,
'created_at' => now()->format('d/m/Y H:i:s'),
]);
} catch (Exception $e) {
Log::error('OrderService: failed to dispatch admin notification', [
'order_id' => $order->id,
'error' => $e->getMessage(),
]);
}
}
/**
* Enrich raw addon entries with resolved price & subtotal.
*
+1 -1
View File
@@ -14,7 +14,7 @@ return [
|
*/
'paths' => ['api/*', 'sanctum/csrf-cookie', 'login', 'logout'],
'paths' => ['api/*', 'v1/*', 'sanctum/csrf-cookie', 'login', 'logout'],
'allowed_methods' => ['*'],
+3
View File
@@ -29,8 +29,11 @@ class Setting extends Seeder
'company' => 'Thế giới số Portal',
'bot_id' => env('TELEGRAM_BOT_ID'),
'bot_token' => env('TELEGRAM_BOT_TOKEN'),
'telegram_group_id' => env('TELEGRAM_GROUP_ID', ''),
'status' => 1,
'address' => 'HCM',
'hotline' => '',
'admin_email' => env('ADMIN_NOTIFY_EMAIL', ''),
'support' => null,
'brand_color' => '#EF6824'
], JSON_THROW_ON_ERROR)
@@ -0,0 +1,127 @@
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Thông báo đơn hàng mới</title>
<style>
body { font-family: Arial, sans-serif; background: #f4f4f4; margin: 0; padding: 0; }
.wrapper { max-width: 640px; margin: 30px auto; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
.header { background: #1e40af; padding: 24px 32px; }
.header h1 { color: #fff; margin: 0; font-size: 20px; }
.header span { color: #bfdbfe; font-size: 13px; }
.body { padding: 32px; color: #374151; }
.body p { line-height: 1.7; margin: 0 0 12px; }
.section-title { font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: #6b7280; margin: 24px 0 10px; }
.info-box { background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 6px; padding: 16px 20px; }
.info-box table { width: 100%; border-collapse: collapse; }
.info-box td { padding: 5px 0; font-size: 14px; vertical-align: top; }
.info-box td:first-child { color: #6b7280; width: 160px; white-space: nowrap; }
.info-box td:last-child { font-weight: 600; color: #111827; }
.badge { display: inline-block; padding: 2px 10px; border-radius: 999px; font-size: 12px; font-weight: 600; }
.badge-blue { background: #dbeafe; color: #1e40af; }
.badge-green { background: #dcfce7; color: #15803d; }
.divider { border: none; border-top: 1px solid #e5e7eb; margin: 24px 0; }
.footer { background: #f9fafb; border-top: 1px solid #e5e7eb; padding: 16px 32px; text-align: center; font-size: 12px; color: #9ca3af; }
</style>
</head>
<body>
<div class="wrapper">
<div class="header">
<h1>📦 Đơn hàng mới đã được thanh toán</h1>
<span>Thông báo tự động từ hệ thống quản lý</span>
</div>
<div class="body">
<p>Một đơn hàng mới vừa được khách hàng tạo và thanh toán thành công trên hệ thống.</p>
<div class="section-title">Thông tin khách hàng</div>
<div class="info-box">
<table>
<tr>
<td>Tên khách hàng</td>
<td>{{ $customer_name ?? 'N/A' }}</td>
</tr>
<tr>
<td>Email</td>
<td>{{ $customer_email ?? 'N/A' }}</td>
</tr>
<tr>
<td>IP đặt hàng</td>
<td>{{ $client_ip ?? 'N/A' }}</td>
</tr>
</table>
</div>
<div class="section-title">Thông tin đơn hàng</div>
<div class="info-box">
<table>
<tr>
<td>Mã đơn hàng</td>
<td>#{{ $order_num ?? 'N/A' }}</td>
</tr>
<tr>
<td>Sản phẩm</td>
<td>{{ $product_name ?? 'N/A' }}</td>
</tr>
<tr>
<td>Loại dịch vụ</td>
<td><span class="badge badge-blue">{{ $product_type ?? 'N/A' }}</span></td>
</tr>
<tr>
<td>Chu kỳ thanh toán</td>
<td>{{ $billing_cycle ?? 'N/A' }}</td>
</tr>
<tr>
<td>Tổng tiền</td>
<td><strong>{{ number_format($total ?? 0, 0, ',', '.') }} VNĐ</strong></td>
</tr>
<tr>
<td>Trạng thái</td>
<td><span class="badge badge-green">Đã thanh toán</span></td>
</tr>
<tr>
<td>Thời gian</td>
<td>{{ $created_at ?? now()->format('d/m/Y H:i:s') }}</td>
</tr>
</table>
</div>
@if(!empty($username) || !empty($password) || !empty($ip))
<div class="section-title">Thông tin dịch vụ</div>
<div class="info-box">
<table>
@if(!empty($username))
<tr>
<td>Username</td>
<td>{{ $username }}</td>
</tr>
@endif
@if(!empty($password))
<tr>
<td>Password</td>
<td>{{ $password }}</td>
</tr>
@endif
@if(!empty($ip))
<tr>
<td>IP Address</td>
<td>{{ $ip }}</td>
</tr>
@endif
</table>
</div>
@endif
<hr class="divider">
<p style="font-size:13px;color:#6b7280;">
Email này được gửi tự động bởi hệ thống. Vui lòng không trả lời email này.
</p>
</div>
<div class="footer">
© {{ date('Y') }} {{ $company_name ?? 'Portal Admin' }} — Hệ thống quản lý dịch vụ
</div>
</div>
</body>
</html>
+9
View File
@@ -2,6 +2,7 @@
use App\Http\Controllers\admin\VCenterManagerController;
use App\Http\Controllers\Api\Admin\Auth\AuthController;
use App\Http\Controllers\Api\Admin\Setting\SettingController as AdminSettingController;
use App\Http\Controllers\Api\QueueMonitorController;
use App\Http\Controllers\Api\Admin\Staff\StaffOnlineController;
use App\Http\Controllers\Api\Admin\Customer\CustomerController;
@@ -284,6 +285,14 @@ Route::prefix('v1')
Route::delete('{id}', 'destroy')->middleware('perm:domain.manager');
});
// Settings (module: administrator)
Route::prefix('settings')
->controller(AdminSettingController::class)
->group(function () {
Route::get('/', 'index');
Route::put('/', 'update');
});
// Staff Online
Route::prefix('staff-online')
->controller(StaffOnlineController::class)