diff --git a/app/Http/Controllers/Api/Admin/Customer/Concerns/ManagesCustomerServices.php b/app/Http/Controllers/Api/Admin/Customer/Concerns/ManagesCustomerServices.php index e28f2c3..49bab95 100644 --- a/app/Http/Controllers/Api/Admin/Customer/Concerns/ManagesCustomerServices.php +++ b/app/Http/Controllers/Api/Admin/Customer/Concerns/ManagesCustomerServices.php @@ -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); } } diff --git a/app/Http/Controllers/Api/Admin/MailCampaign/MailCampaignController.php b/app/Http/Controllers/Api/Admin/MailCampaign/MailCampaignController.php index 4565f54..5375f2e 100644 --- a/app/Http/Controllers/Api/Admin/MailCampaign/MailCampaignController.php +++ b/app/Http/Controllers/Api/Admin/MailCampaign/MailCampaignController.php @@ -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 $emails + * @return array + */ + private function normalizeEmails(array $emails): array + { + return array_values(array_unique(array_map(fn ($email) => mb_strtolower(trim((string) $email)), $emails))); + } + + /** + * @param array $emails + * @return Collection + */ + 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 [ diff --git a/app/Http/Controllers/Api/Admin/Product/ProductController.php b/app/Http/Controllers/Api/Admin/Product/ProductController.php index e3d87e6..da57f5b 100644 --- a/app/Http/Controllers/Api/Admin/Product/ProductController.php +++ b/app/Http/Controllers/Api/Admin/Product/ProductController.php @@ -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', diff --git a/app/Services/Customer/CustomerServiceProvisioningService.php b/app/Services/Customer/CustomerServiceProvisioningService.php index 74ec7ce..47be794 100644 --- a/app/Services/Customer/CustomerServiceProvisioningService.php +++ b/app/Services/Customer/CustomerServiceProvisioningService.php @@ -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]; } } diff --git a/modules/Lead/App/Http/Controllers/Api/Admin/LeadController.php b/modules/Lead/App/Http/Controllers/Api/Admin/LeadController.php index 1b63d2c..7839900 100644 --- a/modules/Lead/App/Http/Controllers/Api/Admin/LeadController.php +++ b/modules/Lead/App/Http/Controllers/Api/Admin/LeadController.php @@ -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)); diff --git a/routes/api/admin/system.php b/routes/api/admin/system.php index 4767d86..f58628f 100644 --- a/routes/api/admin/system.php +++ b/routes/api/admin/system.php @@ -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'); diff --git a/storage/app/.~whmcs-mapping.xlsx b/storage/app/.~whmcs-mapping.xlsx deleted file mode 100644 index e447015..0000000 Binary files a/storage/app/.~whmcs-mapping.xlsx and /dev/null differ diff --git a/tests/Feature/Api/Admin/LeadApiTest.php b/tests/Feature/Api/Admin/LeadApiTest.php index d9d1923..a187e95 100644 --- a/tests/Feature/Api/Admin/LeadApiTest.php +++ b/tests/Feature/Api/Admin/LeadApiTest.php @@ -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']); diff --git a/tests/Feature/Api/Admin/MailCampaignBulkActionTest.php b/tests/Feature/Api/Admin/MailCampaignBulkActionTest.php index b1d413d..bc22c12 100644 --- a/tests/Feature/Api/Admin/MailCampaignBulkActionTest.php +++ b/tests/Feature/Api/Admin/MailCampaignBulkActionTest.php @@ -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(); diff --git a/tests/Feature/Api/Admin/MailCampaignResolveEmailsTest.php b/tests/Feature/Api/Admin/MailCampaignResolveEmailsTest.php new file mode 100644 index 0000000..20f231c --- /dev/null +++ b/tests/Feature/Api/Admin/MailCampaignResolveEmailsTest.php @@ -0,0 +1,85 @@ + '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' => '

Hi

', + '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); + } +}