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',
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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.
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user