Files
baodev0andClaude Sonnet 5.5 55a834a2f8 feat: nhắc gia hạn QR theo dịch vụ, failover server hosting, CCCD admin, log cấp phát dịch vụ
- Mail campaign: mẫu nhắc gia hạn thanh toán QR, chọn dịch vụ + chu kỳ từng dòng (renewal_services)
- Hosting: nhiều server Plesk cho một gói (service_plan_servers), chuyển server khi server chết
- Admin xem/sửa giấy tờ định danh khách, tuỳ chọn gia hạn cho khách, nhật ký cấp phát dịch vụ
- Đánh dấu mẫu email dịch vụ là non-marketing

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-30 18:36:41 +07:00

505 lines
20 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\Admin\MailCampaign;
use App\Http\Controllers\Api\Admin\MailCampaign\Concerns\HandlesCampaignBulkActions;
use App\Http\Controllers\Api\Admin\MailCampaign\Concerns\HandlesRenewalCampaigns;
use App\Http\Controllers\Controller;
use App\Jobs\Mail\SendMailCampaignRecipient;
use App\Models\Customer;
use App\Models\Mail;
use App\Models\MailCampaign;
use App\Models\Template;
use App\Services\Email\MailCampaignRenderer;
use App\Support\AutoEmailGate;
use App\Support\MailAttachments;
use App\Support\MailPlaceholders;
use App\Support\MailSettings;
use App\Support\RenewalCampaign;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Illuminate\Validation\ValidationException;
final class MailCampaignController extends Controller
{
use HandlesCampaignBulkActions;
use HandlesRenewalCampaigns;
private const MAX_EMAILS = 1000;
private const EMAIL_RULE = 'required|string|email:rfc|max:255';
private const EMAIL_MESSAGES = [
'emails.required' => 'Nhập ít nhất một email.',
'emails.max' => 'Tối đa ' . self::MAX_EMAILS . ' email mỗi lần.',
'emails.*.required' => 'Email không được để trống.',
'emails.*.email' => 'Email ":input" không hợp lệ.',
'emails.*.max' => 'Email ":input" dài quá 255 ký tự.',
];
public function index(Request $request): JsonResponse
{
$limit = min((int) $request->get('per_page', 20), 200);
$filters = $request->validate([
'search' => 'nullable|string|max:255',
'status' => 'nullable|array',
'status.*' => 'string|in:queued,sending,completed,failed,' . MailCampaign::STATUS_CANCELLED,
'source' => 'nullable|string|in:template,manual',
'date_from' => 'nullable|date',
'date_to' => 'nullable|date',
]);
$search = trim((string) ($filters['search'] ?? ''));
$paginator = MailCampaign::query()
->with(['template', 'creator'])
->when($search !== '', fn ($q) => $q->where(fn ($q) => $q
->where('name', 'like', "%{$search}%")
->orWhere('subject', 'like', "%{$search}%")
->orWhereHas('template', fn ($t) => $t->where('name', 'like', "%{$search}%"))))
->when($filters['status'] ?? null, fn ($q, $status) => $q->whereIn('status', $status))
->when(($filters['source'] ?? null) === 'template', fn ($q) => $q->whereNotNull('template_id'))
->when(($filters['source'] ?? null) === 'manual', fn ($q) => $q->whereNull('template_id'))
->when($filters['date_from'] ?? null, fn ($q, $from) => $q->whereDate('created_at', '>=', $from))
->when($filters['date_to'] ?? null, fn ($q, $to) => $q->whereDate('created_at', '<=', $to))
->orderByDesc('created_at')
->paginate($limit);
return response()->json([
'error' => false,
'data' => [
'items' => $paginator->getCollection()->map($this->mapCampaign(...))->all(),
'total' => $paginator->total(),
'per_page' => $paginator->perPage(),
'current_page' => $paginator->currentPage(),
'last_page' => $paginator->lastPage(),
],
]);
}
public function show(string $uuid): JsonResponse
{
$campaign = MailCampaign::with(['template', 'creator'])->where('uuid', $uuid)->first();
if (!$campaign) {
return response()->json(['error' => true, 'message' => 'Không tìm thấy chiến dịch'], 404);
}
$logs = Mail::with('customer')
->where('campaign_id', $campaign->id)
->orderByDesc('created_at')
->get(['id', 'to', 'customer_id', 'subject', 'status', 'created_at'])
->map(fn (Mail $log) => [
'id' => $log->id,
'to_email' => $log->to,
'to_name' => $log->customer->fullname ?? null,
'customer_id' => $log->customer_id,
'subject' => $log->subject,
'status' => $log->status,
'created_at' => $log->created_at,
]);
return response()->json([
'error' => false,
'data' => [
...$this->mapCampaign($campaign),
'cc' => $campaign->cc,
'bcc' => $campaign->bcc,
'attachments' => collect($campaign->attachments)->pluck('name')->all(),
'variables' => $campaign->variables ?? [],
'logs' => $logs,
],
]);
}
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 sender(): JsonResponse
{
return response()->json([
'error' => false,
'data' => [
'from_address' => config('mail.from.address'),
'from_name' => config('mail.from.name'),
'smtp_configured' => MailSettings::isConfigured(),
'mail_enabled' => AutoEmailGate::mailEnabled(),
],
]);
}
public function preview(Request $request, MailCampaignRenderer $renderer): JsonResponse
{
$campaign = $this->draftCampaign($request->validate($this->contentRules()));
return response()->json([
'error' => false,
'data' => ['html' => $renderer->render($campaign, $renderer->sampleValues($campaign))['html']],
]);
}
public function previewCampaign(string $uuid, MailCampaignRenderer $renderer): JsonResponse
{
$campaign = MailCampaign::where('uuid', $uuid)->first();
if (!$campaign) {
return response()->json(['error' => true, 'message' => 'Không tìm thấy chiến dịch'], 404);
}
return response()->json([
'error' => false,
'data' => ['html' => $renderer->render($campaign, $renderer->sampleValues($campaign))['html']],
]);
}
public function showLog(string $uuid, int $id): JsonResponse
{
$log = Mail::query()
->whereKey($id)
->whereHas('campaign', fn ($q) => $q->where('uuid', $uuid))
->first();
if (!$log) {
return response()->json(['error' => true, 'message' => 'Không tìm thấy email'], 404);
}
return response()->json([
'error' => false,
'data' => [
'id' => $log->id,
'to_email' => $log->to,
'cc' => $log->cc,
'subject' => $log->subject,
'html' => $log->message,
'status' => $log->status,
'created_at' => $log->created_at,
],
]);
}
public function downloadAttachment(string $uuid, int $index): StreamedResponse|JsonResponse
{
$file = MailCampaign::where('uuid', $uuid)->first()?->attachments[$index] ?? null;
if (!$file || !Storage::disk($file['disk'])->exists($file['path'])) {
return response()->json(['error' => true, 'message' => 'Không tìm thấy tệp đính kèm'], 404);
}
return Storage::disk($file['disk'])->download($file['path'], $file['name']);
}
public function resolveEmails(Request $request): JsonResponse
{
$data = $request->validate([
'emails' => 'required|array|min:1|max:' . self::MAX_EMAILS,
'emails.*' => self::EMAIL_RULE,
], self::EMAIL_MESSAGES);
$emails = $this->normalizeEmails($data['emails']);
$customers = $this->customersByEmail($emails);
$registered = $customers->map(fn (Customer $customer) => mb_strtolower(trim($customer->email)))->all();
return response()->json([
'error' => false,
'data' => [
'customers' => $customers->values()->map(fn (Customer $customer) => [
'uuid' => $customer->uuid,
'fullname' => $customer->fullname,
'email' => $customer->email,
]),
'unregistered' => array_values(array_diff($emails, $registered)),
],
]);
}
public function store(Request $request): JsonResponse
{
$data = $request->validate([
...$this->contentRules(),
'recipient_type' => 'required|in:all,selected',
'uuids' => 'nullable|array',
'uuids.*' => 'string|exists:customers,uuid',
'emails' => 'nullable|array|max:' . self::MAX_EMAILS,
'emails.*' => self::EMAIL_RULE,
'name' => 'nullable|string|max:255',
'renewal_services' => 'nullable|array|max:500',
'renewal_services.*.service_id' => 'required|integer|exists:services,id',
'renewal_services.*.months' => 'required|integer|min:1|max:600',
'cc' => 'nullable|string|max:1000',
'bcc' => 'nullable|string|max:1000',
...MailAttachments::rules(),
], [
...self::EMAIL_MESSAGES,
'uuids.*.exists' => 'Khách hàng đã chọn không còn tồn tại.',
]);
$campaign = $this->draftCampaign($data);
$renewalJobs = [];
$optedOut = 0;
if (!empty($data['renewal_services'])) {
$renewalJobs = $this->renewalJobs($campaign, $data['renewal_services']);
$campaign->renewal_services = array_values(array_filter(
$data['renewal_services'],
fn (array $item) => in_array((int) $item['service_id'], array_column($renewalJobs, 'service_id'), true),
));
$campaign->variables = RenewalCampaign::withoutServiceValues($campaign->variables);
$data['recipient_type'] = 'selected';
$recipients = $renewalJobs;
} else {
$marketingOnly = $campaign->isMarketing();
$resolved = $data['recipient_type'] === 'all'
? $this->allCustomerRecipients($marketingOnly)
: $this->selectedRecipients($data['uuids'] ?? [], $data['emails'] ?? [], $marketingOnly);
$optedOut = $resolved['opted_out'];
$recipients = $resolved['recipients'];
}
if ($recipients === []) {
return response()->json([
'error' => true,
'message' => $optedOut > 0
? "Không có người nhận hợp lệ: {$optedOut} khách hàng đã tắt nhận email marketing."
: 'Không có người nhận hợp lệ để gửi.',
], 422);
}
$admin = Auth::guard('admin')->user();
$campaign->fill([
'name' => trim((string) ($data['name'] ?? '')) ?: $campaign->subject,
'cc' => $data['cc'] ?? null,
'bcc' => $data['bcc'] ?? null,
'recipient_type' => $data['recipient_type'],
'total_recipients' => count($recipients),
'sent_count' => 0,
'failed_count' => 0,
'status' => 'queued',
'created_by' => $admin->id ?? null,
])->save();
if (!empty($data['attachments'])) {
$campaign->update([
'attachments' => MailAttachments::store($data['attachments'], "mail-campaigns/{$campaign->uuid}"),
]);
}
if ($renewalJobs !== []) {
foreach ($renewalJobs as $job) {
SendMailCampaignRecipient::dispatch($campaign->id, $job['customer_id'], $job['email'], $job['service_id']);
}
} else {
foreach ($recipients as $email => $customerId) {
SendMailCampaignRecipient::dispatch($campaign->id, $customerId, $email);
}
}
return response()->json([
'error' => false,
'message' => "Đã đưa {$campaign->total_recipients} email vào hàng đợi gửi."
. ($optedOut > 0 ? " Bỏ qua {$optedOut} khách hàng đã tắt nhận email marketing." : ''),
'data' => $this->mapCampaign($campaign),
]);
}
private function contentRules(): array
{
return [
'template_id' => 'nullable|integer|exists:template,id',
'subject' => 'required_without:template_id|nullable|string|max:255',
'message' => 'required_without:template_id|nullable|string',
'variables' => 'nullable|array',
'variables.*' => 'nullable|string|max:5000',
];
}
private function draftCampaign(array $data): MailCampaign
{
$subject = trim((string) ($data['subject'] ?? ''));
$message = trim((string) ($data['message'] ?? ''));
$template = null;
if (!empty($data['template_id'])) {
$template = Template::query()->whereKey($data['template_id'])->where('status', 1)->first();
if (!$template || $template->format === 'json') {
throw ValidationException::withMessages(['template_id' => 'Template không tồn tại, đang bị tắt hoặc không phải email HTML.']);
}
$subject = $subject !== '' ? $subject : trim((string) $template->subject);
$message = $message !== '' ? $message : trim((string) $template->message);
}
if ($subject === '' || $message === '') {
throw ValidationException::withMessages(['subject' => 'Subject và content không được để trống.']);
}
$input = $data['variables'] ?? [];
$declared = $template?->variables() ?? [];
$variables = [];
foreach (MailPlaceholders::extract($subject, $message) as $key) {
$value = trim((string) ($input[$key] ?? ''));
if ($value === '' && !array_key_exists($key, MailPlaceholders::RECIPIENT_FIELDS)) {
$value = (string) ($declared[$key]['example'] ?? '');
}
if ($value !== '') {
$variables[$key] = $value;
}
}
return new MailCampaign([
'template_id' => $template?->id,
'subject' => $subject,
'message' => $message,
'variables' => $variables ?: null,
]);
}
/**
* @return array{recipients: array<string, int>, opted_out: int}
*/
private function allCustomerRecipients(bool $marketingOnly): array
{
$customers = Customer::query()
->whereNotNull('email')
->where('email', '!=', '')
->get(['id', 'email', 'email_marketing']);
[$accepted, $optedOut] = $customers->partition(
fn (Customer $customer) => !$marketingOnly || $customer->acceptsMarketingEmail()
);
return [
'recipients' => $accepted->pluck('id', 'email')->all(),
'opted_out' => $optedOut->count(),
];
}
/**
* @param array<int, string> $uuids
* @param array<int, string> $emails
* @return array{recipients: array<string, int|null>, opted_out: int}
*/
private function selectedRecipients(array $uuids, array $emails, bool $marketingOnly): array
{
$emails = $this->normalizeEmails($emails);
$customers = Customer::query()
->whereIn('uuid', $uuids)
->whereNotNull('email')
->get(['id', 'email', 'uuid', 'email_marketing'])
->merge($this->customersByEmail($emails));
$recipients = [];
$optedOutEmails = [];
foreach ($customers as $customer) {
$email = mb_strtolower(trim($customer->email));
if (!in_array($customer->uuid, $uuids, true) && !in_array($email, $emails, true)) {
continue;
}
if ($marketingOnly && !$customer->acceptsMarketingEmail()) {
$optedOutEmails[$email] = true;
continue;
}
$recipients[$email] = $customer->id;
}
foreach ($emails as $email) {
if (!isset($optedOutEmails[$email])) {
$recipients[$email] ??= null;
}
}
return [
'recipients' => array_filter($recipients, fn ($id, $email) => filter_var($email, FILTER_VALIDATE_EMAIL), ARRAY_FILTER_USE_BOTH),
'opted_out' => count($optedOutEmails),
];
}
/**
* @param array<int, string> $emails
* @return array<int, string>
*/
private function normalizeEmails(array $emails): array
{
return array_values(array_unique(array_map(fn ($email) => mb_strtolower(trim((string) $email)), $emails)));
}
/**
* @param array<int, string> $emails
* @return Collection<int, Customer>
*/
private function customersByEmail(array $emails): Collection
{
if ($emails === []) {
return new Collection();
}
return Customer::query()
->whereIn(DB::raw('LOWER(TRIM(email))'), $emails)
->get()
->unique(fn (Customer $customer) => mb_strtolower(trim($customer->email)))
->toBase();
}
private function displaySubject(MailCampaign $campaign): string
{
$values = array_filter($campaign->variables ?? [], fn ($value) => $value !== null && $value !== '');
$subject = MailPlaceholders::fill((string) $campaign->subject, $values, escapeHtml: false);
return trim(preg_replace('/[\s|\-–,:]+$/u', '', trim($subject)) ?? $subject);
}
private function mapCampaign(MailCampaign $campaign): array
{
return [
'id' => $campaign->id,
'uuid' => $campaign->uuid,
'name' => $campaign->name,
'subject' => $this->displaySubject($campaign),
'template_id' => $campaign->template_id,
'template_name' => $campaign->template->name ?? null,
'total_recipients' => $campaign->total_recipients,
'sent_count' => $campaign->sent_count,
'failed_count' => $campaign->failed_count,
'status' => $campaign->status,
'recipient_type' => $campaign->recipient_type,
'created_by_name' => $campaign->creator->fullname ?? $campaign->creator->username ?? null,
'completed_at' => $campaign->completed_at,
'created_at' => $campaign->created_at,
];
}
}