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:
baodev0
2026-10-22 21:18:53 +07:00
co-authored by Claude Opus 5.5
parent 54c14c41d8
commit e1efc88fd2
10 changed files with 277 additions and 18 deletions
@@ -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',
@@ -349,7 +349,7 @@ class CustomerServiceProvisioningService
* List virtual machines from vCenter inventory for the VPS picker.
* Annotates each VM with `assigned_service_id` if already linked to a service.
*/
public function listAvailableVms(string $search): Collection
public function listAvailableVms(string $search, int $page = 1, int $perPage = 200): array
{
$assignments = DB::table('module_vmware as mv')
->join('services as s', 's.vm_id', '=', 'mv.id')
@@ -358,25 +358,29 @@ class CustomerServiceProvisioningService
->get()
->keyBy('virtual_machine_id');
return DB::table('virtual_machines as vm')
$query = DB::table('virtual_machines as vm')
->leftJoin('module_datacenter as dc', 'dc.id', '=', 'vm.datacenter_id')
->leftJoin('module_host as h', 'h.id', '=', 'vm.host_id')
->leftJoin('module_vcenter as vc', 'vc.id', '=', 'dc.vcenter_id')
->select(
'vm.id', 'vm.vm_id', 'vm.name', 'vm.power_state', 'vm.cpu', 'vm.ram_mb', 'vm.ip',
'vc.host as vcenter_name',
'dc.name as datacenter_name',
'h.name as host_name',
)
->where('vm.is_template', 0)
->where('vm.is_system', 0)
->when($search, fn($q) => $q->where(function ($w) use ($search) {
$w->where('vm.name', 'like', "%{$search}%")
->orWhere('vm.vm_id', 'like', "%{$search}%")
->orWhere('vm.ip', 'like', "%{$search}%");
}))
}));
$total = (clone $query)->count();
$data = $query
->select(
'vm.id', 'vm.vm_id', 'vm.name', 'vm.power_state', 'vm.cpu', 'vm.ram_mb', 'vm.ip',
'vc.host as vcenter_name',
'dc.name as datacenter_name',
'h.name as host_name',
)
->orderBy('vm.name')
->limit(200)
->forPage($page, $perPage)
->get()
->map(function ($vm) use ($assignments) {
$a = $assignments->get($vm->id);
@@ -384,5 +388,7 @@ class CustomerServiceProvisioningService
$vm->assigned_customer_id = $a->service_customer_id ?? null;
return $vm;
});
return ['data' => $data, 'total' => $total];
}
}
@@ -14,6 +14,8 @@ class LeadController extends Controller
{
public function index(Request $request): JsonResponse
{
$request->validate(['date_from' => 'nullable|date', 'date_to' => 'nullable|date']);
$leads = Lead::query()
->with('customer:id,uuid,fullname,email')
->search($request->input('filter'))
@@ -21,6 +23,8 @@ class LeadController extends Controller
->when($request->filled('source'), fn ($query) => $query->where('source', $request->input('source')))
->when($request->input('linked') === '1', fn ($query) => $query->whereNotNull('customer_id'))
->when($request->input('linked') === '0', fn ($query) => $query->whereNull('customer_id'))
->when($request->filled('date_from'), fn ($query) => $query->whereDate('created_at', '>=', $request->input('date_from')))
->when($request->filled('date_to'), fn ($query) => $query->whereDate('created_at', '<=', $request->input('date_to')))
->orderByDesc('id')
->paginate(min(max((int) $request->input('per_page', 20), 1), 100));
+2
View File
@@ -177,7 +177,9 @@ Route::prefix('mail-campaigns')
->group(function () {
Route::get('/', 'index')->middleware('perm:mail-campaign.view');
Route::get('variable-suggestions', 'variableSuggestions')->middleware('perm:mail-campaign.create');
Route::get('sender', 'sender')->middleware('perm:mail-campaign.create');
Route::post('preview', 'preview')->middleware('perm:mail-campaign.create');
Route::post('resolve-emails', 'resolveEmails')->middleware('perm:mail-campaign.create');
Route::post('bulk-action', 'bulkAction')->middleware('perm:mail-campaign.create');
Route::post('bulk-delete', 'bulkDelete')->middleware('perm:mail-campaign.manager');
Route::get('{uuid}/preview', 'previewCampaign')->middleware('perm:mail-campaign.view');
Binary file not shown.
+15
View File
@@ -55,6 +55,21 @@ class LeadApiTest extends TestCase
$this->assertSame(0, Lead::count());
}
public function test_index_filters_by_source_and_recorded_date(): void
{
$admin = $this->admin(['lead.view']);
$match = Lead::create(['email' => 'a@example.com', 'source' => 'whmcs_contact']);
Lead::create(['email' => 'b@example.com', 'source' => 'manual']);
$old = Lead::create(['email' => 'c@example.com', 'source' => 'whmcs_contact']);
$old->forceFill(['created_at' => now()->subMonth()])->saveQuietly();
$this->actingAs($admin, 'admin')
->getJson(self::BASE . '?' . http_build_query(['source' => 'whmcs_contact', 'date_from' => now()->subDay()->toDateString(), 'date_to' => now()->toDateString()]))
->assertOk()
->assertJsonPath('data.total', 1)
->assertJsonPath('data.items.0.uuid', $match->uuid);
}
public function test_permissions_are_enforced(): void
{
$viewer = $this->admin(['lead.view']);
@@ -7,7 +7,10 @@ namespace Tests\Feature\Api\Admin;
use App\Jobs\Mail\SendMailCampaignRecipient;
use App\Models\Mail;
use App\Models\MailCampaign;
use App\Models\Setting;
use App\Models\User;
use App\Support\MailSettings;
use Illuminate\Support\Facades\Cache;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Mail as MailFacade;
use Illuminate\Support\Facades\Queue;
@@ -36,6 +39,41 @@ class MailCampaignBulkActionTest extends TestCase
]);
}
public function test_index_filters_by_search_status_and_date(): void
{
$match = $this->campaign('completed');
$match->update(['name' => 'Khuyến mãi Tết']);
$this->campaign('failed')->update(['name' => 'Khuyến mãi hè']);
$this->campaign('completed')->update(['name' => 'Bảo trì']);
$old = $this->campaign('completed');
$old->update(['name' => 'Khuyến mãi cũ']);
$old->forceFill(['created_at' => now()->subMonth()])->saveQuietly();
$this->actingAs($this->admin, 'admin')
->getJson('/api/v1/administrator/mail-campaigns?' . http_build_query([
'search' => 'khuyến mãi', 'status' => ['completed'], 'date_from' => now()->subDay()->toDateString(),
]))
->assertOk()
->assertJsonPath('data.total', 1)
->assertJsonPath('data.items.0.uuid', $match->uuid);
}
public function test_sender_reports_smtp_from_address(): void
{
Setting::updateOrCreate(['key' => 'smtp'], ['value' => json_encode([
'host' => 'smtp.example.com', 'user' => 'u@example.com', 'from_address' => 'noreply@tgs.test', 'from_name' => 'TGS',
])]);
Cache::forget(MailSettings::cacheKey());
MailSettings::apply();
$this->actingAs($this->admin, 'admin')
->getJson('/api/v1/administrator/mail-campaigns/sender')
->assertOk()
->assertJsonPath('data.from_address', 'noreply@tgs.test')
->assertJsonPath('data.from_name', 'TGS')
->assertJsonPath('data.smtp_configured', true);
}
public function test_cancel_stops_pending_jobs_and_keeps_finished_campaigns(): void
{
MailFacade::fake();
@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Api\Admin;
use App\Jobs\Mail\SendMailCampaignRecipient;
use App\Models\Customer;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
use Tests\TestCase;
class MailCampaignResolveEmailsTest extends TestCase
{
use RefreshDatabase;
private function admin(): User
{
$role = Role::create(['name' => 'role-' . uniqid(), 'guard_name' => 'admin']);
$role->givePermissionTo(Permission::firstOrCreate(['name' => 'mail-campaign.create', 'guard_name' => 'admin']));
$admin = User::factory()->create();
$admin->assignRole($role);
return $admin;
}
public function test_existing_customer_email_is_matched_case_insensitively(): void
{
$customer = Customer::factory()->create(['email' => 'Bao.Dev@Example.com']);
$this->actingAs($this->admin(), 'admin')
->postJson('/api/v1/administrator/mail-campaigns/resolve-emails', [
'emails' => [' bao.dev@example.COM ', 'new.person@example.com'],
])
->assertOk()
->assertJsonCount(1, 'data.customers')
->assertJsonPath('data.customers.0.uuid', $customer->uuid)
->assertJsonPath('data.unregistered', ['new.person@example.com']);
}
public function test_invalid_emails_are_rejected_with_clear_message(): void
{
$this->actingAs($this->admin(), 'admin')
->postJson('/api/v1/administrator/mail-campaigns/resolve-emails', [
'emails' => ['not-an-email'],
])
->assertUnprocessable()
->assertJsonValidationErrors(['emails.0' => 'Email "not-an-email" không hợp lệ.']);
$this->actingAs($this->admin(), 'admin')
->postJson('/api/v1/administrator/mail-campaigns/resolve-emails', ['emails' => []])
->assertUnprocessable()
->assertJsonValidationErrors(['emails' => 'Nhập ít nhất một email.']);
}
public function test_store_attaches_customer_typed_as_email_with_different_case(): void
{
Queue::fake();
$customer = Customer::factory()->create(['email' => 'Bao.Dev@Example.com', 'email_marketing' => 1]);
$this->actingAs($this->admin(), 'admin')
->postJson('/api/v1/administrator/mail-campaigns', [
'subject' => 'Tin tức',
'message' => '<p>Hi</p>',
'recipient_type' => 'selected',
'uuids' => [$customer->uuid],
'emails' => ['bao.dev@example.com'],
])
->assertOk()
->assertJsonPath('data.total_recipients', 1);
Queue::assertPushed(
SendMailCampaignRecipient::class,
fn (SendMailCampaignRecipient $job) => $this->jobCustomerId($job) === $customer->id
);
}
private function jobCustomerId(SendMailCampaignRecipient $job): ?int
{
return (fn () => $this->customerId)->call($job);
}
}