Files
portal-api/app/Http/Controllers/Api/Admin/MailCampaign/MailCampaignController.php
T
baodev0andClaude Opus 5.5 40bf6b54f9 feat(admin): star/tags marks, service product-type filter, campaign bulk actions, SMTP test
- Star (is_starred) and colored tags for services, customers, orders, invoices:
  Markable trait, tags/taggables tables, PATCH administrator/marks/{type}/{key},
  tag CRUD, list filters starred / tag_ids[]
- GET administrator/services accepts product_type (falls back to product type
  for legacy rows without services.type)
- Mail campaigns: bulk cancel / retry failed / delete; cancelled campaigns are
  skipped by SendMailCampaignRecipient
- POST administrator/settings/test-smtp sends a test email through the saved
  mail transport

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 21:53:41 +07:00

348 lines
13 KiB
PHP

<?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\MailPlaceholders;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
final class MailCampaignController extends Controller
{
use HandlesCampaignBulkActions;
public function index(Request $request): JsonResponse
{
$limit = min((int) $request->get('per_page', 20), 200);
$paginator = MailCampaign::query()
->with(['template', 'creator'])
->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,
'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 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 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:1000',
'emails.*' => 'email|max:255',
'name' => 'nullable|string|max:255',
'cc' => 'nullable|string|max:1000',
'bcc' => 'nullable|string|max:1000',
]);
$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();
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 = 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 = array_values(array_unique(array_map(fn ($email) => mb_strtolower(trim($email)), $emails)));
$customers = Customer::query()
->where(fn ($q) => $q->whereIn('uuid', $uuids)->orWhereIn('email', $emails))
->whereNotNull('email')
->get(['id', 'email', 'uuid', 'email_marketing']);
$recipients = [];
$optedOutEmails = [];
foreach ($customers as $customer) {
$email = mb_strtolower($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),
];
}
private function mapCampaign(MailCampaign $campaign): array
{
return [
'id' => $campaign->id,
'uuid' => $campaign->uuid,
'name' => $campaign->name,
'subject' => $campaign->subject,
'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,
];
}
}