feat: implement admin SMTP settings management and automated order notification system
This commit is contained in:
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -14,7 +14,7 @@ return [
|
||||
|
|
||||
*/
|
||||
|
||||
'paths' => ['api/*', 'sanctum/csrf-cookie', 'login', 'logout'],
|
||||
'paths' => ['api/*', 'v1/*', 'sanctum/csrf-cookie', 'login', 'logout'],
|
||||
|
||||
'allowed_methods' => ['*'],
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user