Files
portal-api/app/Http/Controllers/Api/Admin/MailCampaign/MailCampaignController.php
T

480 lines
18 KiB
PHP
Raw 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\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 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;
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',
'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);
$marketingOnly = $campaign->isMarketing();
$recipients = $data['recipient_type'] === 'all'
? $this->allCustomerRecipients($marketingOnly)
: $this->selectedRecipients($data['uuids'] ?? [], $data['emails'] ?? [], $marketingOnly);
$optedOut = $recipients['opted_out'];
$recipients = $recipients['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}"),
]);
}
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,
];
}
}