feat: email gửi tay/nhắc gia hạn, gói hosting, sửa dữ liệu sản phẩm
- Gửi email theo template cho 1 khách, tab lịch sử email, nhắc gia hạn dịch vụ hàng loạt - Mail lưu from/bcc/sent_by; mẫu MARKETING và mẫu cũ trong TemplateSeeder - Gói hosting: giới hạn, trạng thái, lấy plan Plesk qua CLI gateway - Backfill payment_method và ngày đơn hàng còn thiếu - Bổ sung field CPU/RAM/Disk/Băng thông cho loại sản phẩm vps và cloud Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
8651dde6b1
commit
30898ea46f
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Admin\Customer\Concerns;
|
||||
|
||||
use App\Models\Mail;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
trait ListsCustomerEmails
|
||||
{
|
||||
public function emails(Request $request, string $uuid): JsonResponse
|
||||
{
|
||||
$customer = $this->resolveCustomer($uuid);
|
||||
$search = trim((string) $request->get('search', ''));
|
||||
|
||||
$paginator = Mail::query()
|
||||
->with(['sender', 'template:id,name'])
|
||||
->where('customer_id', $customer->id)
|
||||
->when($search !== '', fn ($q) => $q->where(fn ($q) => $q
|
||||
->where('subject', 'like', "%{$search}%")
|
||||
->orWhere('to', 'like', "%{$search}%")))
|
||||
->latest('id')
|
||||
->paginate(min((int) $request->get('per_page', 10), 100), ['id', 'template_id', 'subject', 'from_address', 'to', 'cc', 'bcc', 'sent_by', 'status', 'created_at']);
|
||||
|
||||
return response()->json([
|
||||
'error' => false,
|
||||
'data' => [
|
||||
'items' => $paginator->getCollection()->map($this->mapCustomerEmail(...))->all(),
|
||||
'total' => $paginator->total(),
|
||||
'current_page' => $paginator->currentPage(),
|
||||
'per_page' => $paginator->perPage(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function emailDetail(string $uuid, int $id): JsonResponse
|
||||
{
|
||||
$customer = $this->resolveCustomer($uuid);
|
||||
$mail = Mail::with(['sender', 'template:id,name'])
|
||||
->where('customer_id', $customer->id)
|
||||
->find($id);
|
||||
|
||||
if (!$mail) {
|
||||
return response()->json(['error' => true, 'message' => 'Không tìm thấy email'], 404);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'error' => false,
|
||||
'data' => [...$this->mapCustomerEmail($mail), 'html' => $mail->message],
|
||||
]);
|
||||
}
|
||||
|
||||
private function mapCustomerEmail(Mail $mail): array
|
||||
{
|
||||
return [
|
||||
'id' => $mail->id,
|
||||
'subject' => $mail->subject,
|
||||
'from_address' => $mail->from_address,
|
||||
'sent_by' => $mail->sender ? ($mail->sender->name ?: $mail->sender->username) : null,
|
||||
'to' => $mail->to,
|
||||
'cc' => $mail->cc,
|
||||
'bcc' => $mail->bcc,
|
||||
'template' => $mail->template?->name,
|
||||
'status' => $mail->status,
|
||||
'created_at' => $mail->created_at,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,26 @@ trait ManagesCustomerAccount
|
||||
return response()->json(['error' => false, 'message' => 'Đã gửi mật khẩu mới cho khách hàng.']);
|
||||
}
|
||||
|
||||
public function emailPreview(Request $request, string $uuid): JsonResponse
|
||||
{
|
||||
$data = $request->validate(['template_id' => 'required|integer']);
|
||||
|
||||
return response()->json([
|
||||
'error' => false,
|
||||
'data' => $this->mailService->previewTemplateEmail($uuid, (int) $data['template_id']),
|
||||
]);
|
||||
}
|
||||
|
||||
public function emailLayout(Request $request, string $uuid): JsonResponse
|
||||
{
|
||||
$data = $request->validate(['message' => 'nullable|string']);
|
||||
|
||||
return response()->json([
|
||||
'error' => false,
|
||||
'data' => ['html' => $this->mailService->wrapWithLayout($uuid, (string) ($data['message'] ?? ''))],
|
||||
]);
|
||||
}
|
||||
|
||||
public function sendEmail(Request $request, string $uuid): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
@@ -43,6 +63,7 @@ trait ManagesCustomerAccount
|
||||
'to' => 'nullable|string|max:1000',
|
||||
'cc' => 'nullable|string|max:1000',
|
||||
'bcc' => 'nullable|string|max:1000',
|
||||
'with_layout' => 'nullable|boolean',
|
||||
]);
|
||||
|
||||
$this->mailService->sendCustomEmail($uuid, $data, Auth::guard('admin')->user(), $request->ip());
|
||||
|
||||
@@ -35,6 +35,7 @@ trait ManagesCustomerServices
|
||||
'requirements' => 'nullable|string',
|
||||
'feedback' => 'nullable|string',
|
||||
'price' => 'nullable|numeric|min:0',
|
||||
'billing_cycle' => 'nullable|in:monthly,quarterly,semi_annually,annually,biennially,triennially',
|
||||
'virtual_machine_id' => 'nullable|integer|exists:virtual_machines,id',
|
||||
'os_id' => 'nullable|integer|exists:os,id',
|
||||
'vm_cpu' => 'nullable|integer|min:1',
|
||||
|
||||
@@ -24,6 +24,7 @@ use App\Http\Controllers\Api\Admin\Customer\Concerns\ManagesCustomerDomains;
|
||||
use App\Http\Controllers\Api\Admin\Customer\Concerns\ManagesCustomerServices;
|
||||
use App\Http\Controllers\Api\Admin\Customer\Concerns\ManagesCustomerBulkActions;
|
||||
use App\Http\Controllers\Api\Admin\Customer\Concerns\ManagesCustomerNotes;
|
||||
use App\Http\Controllers\Api\Admin\Customer\Concerns\ListsCustomerEmails;
|
||||
|
||||
class CustomerController extends Controller
|
||||
{
|
||||
@@ -34,6 +35,7 @@ class CustomerController extends Controller
|
||||
use ManagesCustomerServices;
|
||||
use ManagesCustomerBulkActions;
|
||||
use ManagesCustomerNotes;
|
||||
use ListsCustomerEmails;
|
||||
|
||||
public function __construct(
|
||||
private readonly CustomerRepositoryInterface $customerRepository,
|
||||
|
||||
@@ -16,6 +16,8 @@ use App\Repositories\Api\Domain\DomainOrderRepository;
|
||||
use App\Services\Api\Domain\DomainOrderService;
|
||||
use App\Services\Api\Domain\DomainRegistrantService;
|
||||
use App\Services\Email\EmailTemplateService;
|
||||
use App\Support\EmailTemplateShell;
|
||||
use App\Support\MailGlobals;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
@@ -278,22 +280,46 @@ class AdminDomainOrderController extends Controller
|
||||
return;
|
||||
}
|
||||
|
||||
$setting = \App\Models\Setting::where('key', 'setting')->first();
|
||||
$settingValue = $setting ? json_decode((string) $setting->value, true) : [];
|
||||
|
||||
app(EmailTemplateService::class)->send($scenario, $customer->email, [
|
||||
'company_name' => $settingValue['company'] ?? config('app.name'),
|
||||
'company_logo' => $settingValue['logo'] ?? '',
|
||||
'domain_name' => $domainName,
|
||||
'client_name' => $customer->fullname,
|
||||
'order_num' => $orderNum,
|
||||
'reason' => $reason,
|
||||
...MailGlobals::values(),
|
||||
...$this->domainHandoverVariables($order),
|
||||
'domain_name' => $domainName,
|
||||
'client_name' => $customer->fullname,
|
||||
'customer_email' => $customer->email,
|
||||
'order_num' => $orderNum,
|
||||
'reason' => $reason,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('AdminDomainOrderController: không thể gửi email khách hàng: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function domainHandoverVariables(Order $order): array
|
||||
{
|
||||
$service = $order->orderItems->first()?->service?->fresh();
|
||||
|
||||
if (!$service) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
'registered_at' => $service->started_at?->format('d/m/Y') ?? '',
|
||||
'expires_at' => $service->expires_at?->format('d/m/Y') ?? '',
|
||||
'kyc_block' => $service->kyc_url ? $this->kycBlock($service->kyc_url) : '',
|
||||
];
|
||||
}
|
||||
|
||||
private function kycBlock(string $kycUrl): string
|
||||
{
|
||||
return EmailTemplateShell::heading('📋 Lưu ý quan trọng')
|
||||
. EmailTemplateShell::paragraph('Căn cứ Khoản 2 Điều 9 và Khoản 6 Điều 21 Thông tư số 48/2025/TT-BKHCN ngày 25/12/2025 của Bộ Khoa học và Công nghệ về Quản lý và sử dụng tài nguyên Internet, chủ thể đăng ký tên miền là cá nhân có trách nhiệm hoàn thiện hồ sơ đăng ký thông qua hình thức xác thực điện tử (eKYC) trong vòng <strong>03 ngày</strong> kể từ thời điểm nhận được email này.')
|
||||
. EmailTemplateShell::button(htmlspecialchars($kycUrl, ENT_QUOTES, 'UTF-8'), '✅ Xác thực eKYC ngay')
|
||||
. EmailTemplateShell::note('Việc không hoàn thành xác thực đúng hạn có thể ảnh hưởng đến quyền sử dụng tên miền của Quý khách.', '⏰ Lưu ý:');
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Private formatters
|
||||
// =========================================================================
|
||||
|
||||
@@ -3,15 +3,17 @@
|
||||
namespace App\Http\Controllers\Api\Admin\Hosting;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\Hosting\ServicePlanRequest;
|
||||
use App\Models\DnsServer;
|
||||
use App\Models\ServicePlan;
|
||||
use App\Services\Plesk\PleskServicePlanService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ServicePlanController extends Controller
|
||||
{
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
$plans = ServicePlan::with('plesk', 'dnsServer')->orderBy('title')->get();
|
||||
$plans = ServicePlan::with('plesk', 'dnsServer')->orderBy('sort_order')->orderBy('title')->get();
|
||||
|
||||
return response()->json(['error' => false, 'data' => $plans]);
|
||||
}
|
||||
@@ -27,29 +29,11 @@ class ServicePlanController extends Controller
|
||||
return response()->json(['error' => false, 'data' => $plan]);
|
||||
}
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
public function store(ServicePlanRequest $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'title' => 'required|string|max:255',
|
||||
'vcpu' => 'required|integer|min:0',
|
||||
'ram' => 'required|integer|min:0',
|
||||
'disk' => 'required|integer|min:0',
|
||||
'domain' => 'required|integer|min:0',
|
||||
'dns_manager_server_id' => 'nullable|integer|exists:dns_manager_servers,id',
|
||||
'plesk_plan_name' => 'nullable|string|max:255',
|
||||
'note' => 'nullable|string',
|
||||
]);
|
||||
$plan = ServicePlan::create($request->planAttributes());
|
||||
|
||||
$plan = ServicePlan::create([
|
||||
'title' => $validated['title'],
|
||||
'vcpu' => $validated['vcpu'],
|
||||
'ram' => $validated['ram'],
|
||||
'disk' => $validated['disk'],
|
||||
'domain' => $validated['domain'],
|
||||
'dns_manager_server_id' => $validated['dns_manager_server_id'] ?? null,
|
||||
]);
|
||||
|
||||
$this->savePlesk($plan, $validated);
|
||||
$this->savePlesk($plan, $request->validated());
|
||||
|
||||
return response()->json([
|
||||
'error' => false,
|
||||
@@ -58,7 +42,7 @@ class ServicePlanController extends Controller
|
||||
], 201);
|
||||
}
|
||||
|
||||
public function update(Request $request, int $id): JsonResponse
|
||||
public function update(ServicePlanRequest $request, int $id): JsonResponse
|
||||
{
|
||||
$plan = ServicePlan::find($id);
|
||||
|
||||
@@ -66,27 +50,9 @@ class ServicePlanController extends Controller
|
||||
return response()->json(['error' => true, 'message' => 'Không tìm thấy gói hosting.'], 404);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'title' => 'required|string|max:255',
|
||||
'vcpu' => 'required|integer|min:0',
|
||||
'ram' => 'required|integer|min:0',
|
||||
'disk' => 'required|integer|min:0',
|
||||
'domain' => 'required|integer|min:0',
|
||||
'dns_manager_server_id' => 'nullable|integer|exists:dns_manager_servers,id',
|
||||
'plesk_plan_name' => 'nullable|string|max:255',
|
||||
'note' => 'nullable|string',
|
||||
]);
|
||||
$plan->update($request->planAttributes());
|
||||
|
||||
$plan->update([
|
||||
'title' => $validated['title'],
|
||||
'vcpu' => $validated['vcpu'],
|
||||
'ram' => $validated['ram'],
|
||||
'disk' => $validated['disk'],
|
||||
'domain' => $validated['domain'],
|
||||
'dns_manager_server_id' => $validated['dns_manager_server_id'] ?? null,
|
||||
]);
|
||||
|
||||
$this->savePlesk($plan, $validated);
|
||||
$this->savePlesk($plan, $request->validated());
|
||||
|
||||
return response()->json([
|
||||
'error' => false,
|
||||
@@ -108,6 +74,23 @@ class ServicePlanController extends Controller
|
||||
return response()->json(['error' => false, 'message' => 'Xoá gói hosting thành công.']);
|
||||
}
|
||||
|
||||
public function pleskPlans(int $serverId, PleskServicePlanService $plans): JsonResponse
|
||||
{
|
||||
$server = DnsServer::find($serverId);
|
||||
|
||||
if (!$server || !$server->servesHosting()) {
|
||||
return response()->json(['error' => true, 'message' => 'Server không hỗ trợ hosting.'], 404);
|
||||
}
|
||||
|
||||
$names = $plans->names($server);
|
||||
|
||||
if ($names === null) {
|
||||
return response()->json(['error' => true, 'message' => 'Không lấy được danh sách plan từ Plesk.'], 502);
|
||||
}
|
||||
|
||||
return response()->json(['error' => false, 'data' => $names]);
|
||||
}
|
||||
|
||||
private function savePlesk(ServicePlan $plan, array $data): void
|
||||
{
|
||||
if (empty($data['plesk_plan_name'])) {
|
||||
|
||||
@@ -28,6 +28,8 @@ trait HandlesInvoicePayments
|
||||
// hoá đơn ghi nhận trả một phần và chưa kích hoạt dịch vụ.
|
||||
$validated = $request->validate([
|
||||
'received_amount' => 'nullable|integer|min:1',
|
||||
'recording_mode' => 'nullable|string|in:manual,automatic',
|
||||
'note' => 'nullable|string|max:1000',
|
||||
]);
|
||||
|
||||
try {
|
||||
@@ -37,7 +39,9 @@ trait HandlesInvoicePayments
|
||||
$invoice = $this->invoicePaymentService->markPaidByAdmin(
|
||||
$invoice,
|
||||
auth()->id(),
|
||||
method: $validated['recording_mode'] ?? 'manual',
|
||||
receivedAmount: $validated['received_amount'] ?? null,
|
||||
note: $validated['note'] ?? '',
|
||||
);
|
||||
|
||||
DB::commit();
|
||||
|
||||
@@ -74,6 +74,36 @@ final class MailCampaignController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
public function variableSuggestions(Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'template_id' => 'required|integer|exists:template,id',
|
||||
]);
|
||||
|
||||
$suggestions = MailCampaign::query()
|
||||
->where('template_id', $data['template_id'])
|
||||
->whereNotNull('variables')
|
||||
->where('sent_count', '>', 0)
|
||||
->orderByDesc('created_at')
|
||||
->limit(20)
|
||||
->get(['uuid', 'name', 'variables', 'created_at'])
|
||||
->filter(fn (MailCampaign $campaign) => !empty($campaign->variables))
|
||||
->unique(fn (MailCampaign $campaign) => json_encode($campaign->variables))
|
||||
->take(8)
|
||||
->map(fn (MailCampaign $campaign) => [
|
||||
'uuid' => $campaign->uuid,
|
||||
'name' => $campaign->name,
|
||||
'variables' => $campaign->variables,
|
||||
'created_at' => $campaign->created_at,
|
||||
])
|
||||
->values();
|
||||
|
||||
return response()->json([
|
||||
'error' => false,
|
||||
'data' => $suggestions,
|
||||
]);
|
||||
}
|
||||
|
||||
public function preview(Request $request, MailCampaignRenderer $renderer): JsonResponse
|
||||
{
|
||||
$campaign = $this->draftCampaign($request->validate($this->contentRules()));
|
||||
|
||||
@@ -7,6 +7,7 @@ use App\Http\Requests\OrderRequest;
|
||||
use App\Http\Requests\UpdateOrderRequest;
|
||||
use App\Http\Resources\Api\Admin\OrderResource;
|
||||
use App\Models\Order;
|
||||
use App\Models\DomainPricing;
|
||||
use App\Repositories\Order\OrderRepositoryInterface;
|
||||
use App\Services\OrderService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -42,6 +43,14 @@ class OrderController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
public function domainPricing(): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'error' => false,
|
||||
'data' => DomainPricing::toResponseArray(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(OrderRequest $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
|
||||
@@ -5,12 +5,24 @@ namespace App\Http\Controllers\Api\Admin\Product;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Resources\Api\Admin\ProductCategoryResource;
|
||||
use App\Models\ProductCategories;
|
||||
use App\Services\Product\ProductCategorySampleService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class ProductCategoryController extends Controller
|
||||
{
|
||||
public function seedSample(ProductCategorySampleService $service): JsonResponse
|
||||
{
|
||||
$result = $service->seed();
|
||||
|
||||
return response()->json([
|
||||
'error' => false,
|
||||
'message' => "Đã tạo {$result['created']} và bổ sung thông tin cho {$result['updated']} nhóm dịch vụ.",
|
||||
'data' => $result,
|
||||
]);
|
||||
}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$perPage = min((int) $request->get('per_page', 15), 100);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Http\Controllers\Api\Admin\Product;
|
||||
|
||||
use App\Exports\ProductsExport;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Resources\Api\Admin\ProductResource;
|
||||
use App\Http\Services\NextjsRevalidationService;
|
||||
@@ -12,6 +13,9 @@ use App\Models\Os;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use Maatwebsite\Excel\Excel as ExcelWriter;
|
||||
use Maatwebsite\Excel\Facades\Excel;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
|
||||
class ProductController extends Controller
|
||||
{
|
||||
@@ -40,6 +44,13 @@ class ProductController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
public function export(): BinaryFileResponse
|
||||
{
|
||||
$filename = 'danh-sach-san-pham-' . now()->format('Y-m-d-His') . '.xlsx';
|
||||
|
||||
return Excel::download(new ProductsExport(), $filename, ExcelWriter::XLSX);
|
||||
}
|
||||
|
||||
public function show(int $id): JsonResponse
|
||||
{
|
||||
$product = $this->products->findWithPricings($id);
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Admin\Service;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Service;
|
||||
use App\Services\ServiceRenewalReminderService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class ServiceRenewalReminderController extends Controller
|
||||
{
|
||||
public function __construct(private readonly ServiceRenewalReminderService $reminders) {}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$days = (int) $request->validate(['days' => 'required|integer|min:1|max:365'])['days'];
|
||||
|
||||
$items = $this->reminders->expiring($days)->map(fn (Service $service) => [
|
||||
'id' => $service->id,
|
||||
'server_name' => $service->server_name,
|
||||
'product_name' => $service->product?->name,
|
||||
'expires_at' => $service->expires_at,
|
||||
'days_left' => $this->reminders->daysLeft($service),
|
||||
'auto_renew' => (bool) $service->auto_renew,
|
||||
'customer' => [
|
||||
'uuid' => $service->customer->uuid,
|
||||
'fullname' => $service->customer->fullname,
|
||||
'email' => $service->customer->email,
|
||||
],
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'error' => false,
|
||||
'data' => $items->values(),
|
||||
'truncated' => $items->count() >= ServiceRenewalReminderService::MAX_RESULTS,
|
||||
]);
|
||||
}
|
||||
|
||||
public function send(Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'service_ids' => 'required|array|min:1|max:' . ServiceRenewalReminderService::MAX_RESULTS,
|
||||
'service_ids.*' => 'integer',
|
||||
]);
|
||||
|
||||
$count = $this->reminders->send($data['service_ids'], Auth::guard('admin')->user());
|
||||
|
||||
return response()->json([
|
||||
'error' => false,
|
||||
'message' => "Đã đưa {$count} email nhắc gia hạn vào hàng đợi gửi.",
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user