feat(mail-campaigns): sender info + resolve typed emails against customers
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
54c14c41d8
commit
e1efc88fd2
@@ -244,8 +244,12 @@ trait ManagesCustomerServices
|
||||
|
||||
public function listAvailableVms(Request $request): JsonResponse
|
||||
{
|
||||
$vms = $this->serviceProvisioning->listAvailableVms(trim((string) $request->get('q', '')));
|
||||
$result = $this->serviceProvisioning->listAvailableVms(
|
||||
trim((string) $request->get('q', '')),
|
||||
max(1, (int) $request->get('page', 1)),
|
||||
max(1, min((int) $request->get('per_page', 200), 200)),
|
||||
);
|
||||
|
||||
return response()->json(['error' => false, 'data' => $vms]);
|
||||
return response()->json(['error' => false] + $result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,28 +6,64 @@ namespace App\Http\Controllers\Api\Admin\MailCampaign;
|
||||
|
||||
use App\Http\Controllers\Api\Admin\MailCampaign\Concerns\HandlesCampaignBulkActions;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Resources\Api\Admin\CustomerResource;
|
||||
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\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\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);
|
||||
|
||||
@@ -107,6 +143,19 @@ final class MailCampaignController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
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()));
|
||||
@@ -156,6 +205,26 @@ final class MailCampaignController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
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' => CustomerResource::collection($customers->values()),
|
||||
'unregistered' => array_values(array_diff($emails, $registered)),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
@@ -163,11 +232,14 @@ final class MailCampaignController extends Controller
|
||||
'recipient_type' => 'required|in:all,selected',
|
||||
'uuids' => 'nullable|array',
|
||||
'uuids.*' => 'string|exists:customers,uuid',
|
||||
'emails' => 'nullable|array|max:1000',
|
||||
'emails.*' => 'email|max:255',
|
||||
'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',
|
||||
], [
|
||||
...self::EMAIL_MESSAGES,
|
||||
'uuids.*.exists' => 'Khách hàng đã chọn không còn tồn tại.',
|
||||
]);
|
||||
|
||||
$campaign = $this->draftCampaign($data);
|
||||
@@ -293,17 +365,18 @@ final class MailCampaignController extends Controller
|
||||
*/
|
||||
private function selectedRecipients(array $uuids, array $emails, bool $marketingOnly): array
|
||||
{
|
||||
$emails = array_values(array_unique(array_map(fn ($email) => mb_strtolower(trim($email)), $emails)));
|
||||
$emails = $this->normalizeEmails($emails);
|
||||
|
||||
$customers = Customer::query()
|
||||
->where(fn ($q) => $q->whereIn('uuid', $uuids)->orWhereIn('email', $emails))
|
||||
->whereIn('uuid', $uuids)
|
||||
->whereNotNull('email')
|
||||
->get(['id', 'email', 'uuid', 'email_marketing']);
|
||||
->get(['id', 'email', 'uuid', 'email_marketing'])
|
||||
->merge($this->customersByEmail($emails));
|
||||
|
||||
$recipients = [];
|
||||
$optedOutEmails = [];
|
||||
foreach ($customers as $customer) {
|
||||
$email = mb_strtolower($customer->email);
|
||||
$email = mb_strtolower(trim($customer->email));
|
||||
if (!in_array($customer->uuid, $uuids, true) && !in_array($email, $emails, true)) {
|
||||
continue;
|
||||
}
|
||||
@@ -325,6 +398,32 @@ final class MailCampaignController extends Controller
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @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 mapCampaign(MailCampaign $campaign): array
|
||||
{
|
||||
return [
|
||||
|
||||
@@ -79,6 +79,8 @@ class ProductController extends Controller
|
||||
'product_type' => 'nullable|string',
|
||||
'category' => 'nullable|string|max:255',
|
||||
'thumbnail' => 'nullable|string|max:2048',
|
||||
'short_description' => 'nullable|string',
|
||||
'description' => 'nullable|string',
|
||||
'is_active' => 'boolean',
|
||||
'setup_fee' => 'nullable|numeric|min:0',
|
||||
'cpu' => 'nullable|numeric|min:0',
|
||||
@@ -114,6 +116,8 @@ class ProductController extends Controller
|
||||
'service_plan_id' => $validated['service_plan_id'] ?? null,
|
||||
'product_type' => $productType,
|
||||
'thumbnail' => ($validated['thumbnail'] ?? null) ?: null,
|
||||
'short_description' => $validated['short_description'] ?? null,
|
||||
'description' => $validated['description'] ?? null,
|
||||
'is_active' => $validated['is_active'] ?? true,
|
||||
'setup_fee' => $validated['setup_fee'] ?? 0,
|
||||
'cpu' => $validated['cpu'] ?? null,
|
||||
@@ -148,6 +152,8 @@ class ProductController extends Controller
|
||||
'service_plan_id' => 'nullable|integer|exists:service_plans,id',
|
||||
'product_type' => 'nullable|string',
|
||||
'thumbnail' => 'nullable|string|max:2048',
|
||||
'short_description' => 'nullable|string',
|
||||
'description' => 'nullable|string',
|
||||
'is_active' => 'boolean',
|
||||
'setup_fee' => 'nullable|numeric|min:0',
|
||||
'cpu' => 'nullable|numeric|min:0',
|
||||
|
||||
Reference in New Issue
Block a user