From bf78f3b810b903131bbb60b5e7767fa843fc43a2 Mon Sep 17 00:00:00 2001 From: baodev0 Date: Tue, 13 Oct 2026 16:03:18 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20h=E1=BB=93=20s=C6=A1=20kh=C3=A1ch=20(ng?= =?UTF-8?q?=C3=A0y=20sinh,=20t=E1=BB=89nh/ph=C6=B0=E1=BB=9Dng=20theo=20m?= =?UTF-8?q?=C3=A3),=20g=E1=BB=99p=20menu=20+=20n=C3=BAt=20t=E1=BA=AFt,=20t?= =?UTF-8?q?h=C3=B4ng=20b=C3=A1o=20theo=20uuid?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PUT v1/client/update: gender theo enum ClientGender (0 Nam/1 Nữ), thêm birthday (cột mới), province_code/ward_code validate theo bảng provinces/wards (phường phải thuộc tỉnh). Migration nạp dữ liệu hành chính 2025 từ province_ward_import.sql (chỉ bảng trống), API GET v1/provinces[/{code}/wards]. - CustomerObserver/OrderObserver log giá trị cũ bằng getRawOriginal: getOriginal trả enum object, nối chuỗi gây 500 khi khách đổi giới tính. - ClientResource: các cờ trả boolean, CCCD chỉ trả cccd_updated (không lộ ảnh/số). - GET v1/setting/menu trả cả items + quick_links, bỏ v1/setting/quick-links. - Thông báo admin/khách tra theo uuid thay vì id tự tăng. - Chặn gửi template marketing cho khách đã tắt nhận email marketing. - Xoá ProvinceWardSeeder và Customer::$updatable không dùng. Co-Authored-By: Claude Opus 5.5 --- .../MailCampaign/MailCampaignController.php | 58 ++++++++---- .../AdministrativeDivisionController.php | 27 ++++++ .../Api/Client/ClientController.php | 24 ++--- .../Client/Concerns/ManagesClientProfile.php | 26 +----- .../Api/NotificationController.php | 29 +++--- .../Api/Setting/SettingController.php | 13 +-- .../Api/Client/ClientUpdateRequest.php | 20 ++++- app/Http/Requests/CreateCustomerRequest.php | 2 +- .../Resources/Api/Admin/CustomerResource.php | 2 + .../Resources/Api/Client/ClientResource.php | 41 ++++----- app/Jobs/Mail/SendMailCampaignRecipient.php | 6 ++ app/Models/Customer.php | 15 +--- app/Models/MailCampaign.php | 5 ++ app/Models/Notification.php | 10 ++- app/Models/Template.php | 6 ++ app/Observers/CustomerObserver.php | 2 +- app/Observers/OrderObserver.php | 2 +- .../Customer/CustomerAccountService.php | 2 +- app/Services/Customer/CustomerMailService.php | 5 ++ ...000005_add_uuid_to_notifications_table.php | 36 ++++++++ ...10_13_000006_add_birthday_to_customers.php | 21 +++++ ...0_13_000007_import_provinces_and_wards.php | 30 +++++++ database/seeders/ProvinceWardSeeder.php | 30 ------- routes/api/admin/system.php | 12 +-- routes/client.php | 16 ++-- tests/Feature/Api/Admin/ClientMenuTest.php | 16 ++-- .../Api/Admin/MarketingEmailOptOutTest.php | 89 +++++++++++++++++++ .../Api/Client/ClientAuthSecurityTest.php | 89 ++++++++++++++++--- .../Api/Client/ClientTamperingTest.php | 8 +- .../Api/Client/ClientTwoFactorTest.php | 2 +- 30 files changed, 461 insertions(+), 183 deletions(-) create mode 100644 app/Http/Controllers/Api/Client/AdministrativeDivisionController.php create mode 100644 database/migrations/2026_10_13_000005_add_uuid_to_notifications_table.php create mode 100644 database/migrations/2026_10_13_000006_add_birthday_to_customers.php create mode 100644 database/migrations/2026_10_13_000007_import_provinces_and_wards.php delete mode 100644 database/seeders/ProvinceWardSeeder.php create mode 100644 tests/Feature/Api/Admin/MarketingEmailOptOutTest.php diff --git a/app/Http/Controllers/Api/Admin/MailCampaign/MailCampaignController.php b/app/Http/Controllers/Api/Admin/MailCampaign/MailCampaignController.php index cd5ff41..2574bce 100644 --- a/app/Http/Controllers/Api/Admin/MailCampaign/MailCampaignController.php +++ b/app/Http/Controllers/Api/Admin/MailCampaign/MailCampaignController.php @@ -169,14 +169,19 @@ final class MailCampaignController extends Controller $campaign = $this->draftCampaign($data); + $marketingOnly = $campaign->isMarketing(); $recipients = $data['recipient_type'] === 'all' - ? $this->allCustomerRecipients() - : $this->selectedRecipients($data['uuids'] ?? [], $data['emails'] ?? []); + ? $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' => 'Không có người nhận hợp lệ để gửi.', + '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); } @@ -200,7 +205,8 @@ final class MailCampaignController extends Controller return response()->json([ 'error' => false, - 'message' => "Đã đưa {$campaign->total_recipients} email vào hàng đợi gửi.", + '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), ]); } @@ -258,42 +264,62 @@ final class MailCampaignController extends Controller } /** - * @return array + * @return array{recipients: array, opted_out: int} */ - private function allCustomerRecipients(): array + private function allCustomerRecipients(bool $marketingOnly): array { - return Customer::query() + $customers = Customer::query() ->whereNotNull('email') ->where('email', '!=', '') - ->pluck('id', 'email') - ->all(); + ->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 $uuids * @param array $emails - * @return array + * @return array{recipients: array, opted_out: int} */ - private function selectedRecipients(array $uuids, array $emails): array + 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']); + ->get(['id', 'email', 'uuid', 'email_marketing']); $recipients = []; + $optedOutEmails = []; foreach ($customers as $customer) { - if (in_array($customer->uuid, $uuids, true) || in_array(mb_strtolower($customer->email), $emails, true)) { - $recipients[mb_strtolower($customer->email)] = $customer->id; + $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) { - $recipients[$email] ??= null; + if (!isset($optedOutEmails[$email])) { + $recipients[$email] ??= null; + } } - return array_filter($recipients, fn ($id, $email) => filter_var($email, FILTER_VALIDATE_EMAIL), ARRAY_FILTER_USE_BOTH); + 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 diff --git a/app/Http/Controllers/Api/Client/AdministrativeDivisionController.php b/app/Http/Controllers/Api/Client/AdministrativeDivisionController.php new file mode 100644 index 0000000..391f234 --- /dev/null +++ b/app/Http/Controllers/Api/Client/AdministrativeDivisionController.php @@ -0,0 +1,27 @@ +json([ + 'error' => false, + 'data' => Province::query()->orderBy('code')->get(['code', 'name', 'full_name']), + ]); + } + + public function wards(string $province): JsonResponse + { + return response()->json([ + 'error' => false, + 'data' => Ward::query()->where('province_code', $province)->orderBy('name')->get(['code', 'name', 'full_name']), + ]); + } +} diff --git a/app/Http/Controllers/Api/Client/ClientController.php b/app/Http/Controllers/Api/Client/ClientController.php index bbb2f07..cb90fa5 100644 --- a/app/Http/Controllers/Api/Client/ClientController.php +++ b/app/Http/Controllers/Api/Client/ClientController.php @@ -50,23 +50,25 @@ class ClientController extends Controller * @OA\Property(property="customer", type="object", * @OA\Property(property="fullname", type="string", example="Nguyen Van A"), * @OA\Property(property="email", type="string", example="client@example.com"), - * @OA\Property(property="phone", type="string", example="0987654321"), + * @OA\Property(property="phone", type="string", example="*******321", description="Chỉ hiển thị 3 số cuối"), * @OA\Property(property="address", type="string", example="123 Street"), * @OA\Property(property="city", type="string", example="Hanoi"), - * @OA\Property(property="ward", type="string", example="Ward 1"), - * @OA\Property(property="province", type="string", example="Hanoi"), - * @OA\Property(property="gender", type="string", example="Nam"), + * @OA\Property(property="ward", type="string", example="Phường Bình Trị Đông"), + * @OA\Property(property="ward_code", type="string", nullable=true, example="27460"), + * @OA\Property(property="province", type="string", example="Hồ Chí Minh"), + * @OA\Property(property="province_code", type="string", nullable=true, example="79"), + * @OA\Property(property="gender", type="integer", nullable=true, example=0, description="0 = Nam, 1 = Nữ"), + * @OA\Property(property="gender_label", type="string", example="Nam"), + * @OA\Property(property="birthday", type="string", format="date", nullable=true, example="1995-06-20"), * @OA\Property(property="date", type="string", format="date-time", example="2024-01-01 12:00:00"), * @OA\Property(property="company", type="string", example="Company Ltd"), * @OA\Property(property="avatar", type="string", example="/uploads/avatar.jpg"), - * @OA\Property(property="cmnd_after", type="string", example="cmnd_back.jpg"), - * @OA\Property(property="cmnd_before", type="string", example="cmnd_front.jpg"), - * @OA\Property(property="cmnd", type="string", example="123456789"), + * @OA\Property(property="cccd_updated", type="boolean", example=true), * @OA\Property(property="fb_link", type="string", nullable=true), - * @OA\Property(property="active_2fa", type="integer", example=0), - * @OA\Property(property="off_mail_marketing", type="integer", example=0), - * @OA\Property(property="is_recharge_zalo", type="integer", example=0), - * @OA\Property(property="is_verify_telegram", type="integer", example=0) + * @OA\Property(property="active_2fa", type="boolean", example=false), + * @OA\Property(property="off_mail_marketing", type="boolean", example=false), + * @OA\Property(property="is_recharge_zalo", type="boolean", example=false), + * @OA\Property(property="is_verify_telegram", type="boolean", example=false) * ), * @OA\Property(property="credit", type="object", * @OA\Property(property="credit", type="number", example=1000000), diff --git a/app/Http/Controllers/Api/Client/Concerns/ManagesClientProfile.php b/app/Http/Controllers/Api/Client/Concerns/ManagesClientProfile.php index 6ca2d01..2e5a35b 100644 --- a/app/Http/Controllers/Api/Client/Concerns/ManagesClientProfile.php +++ b/app/Http/Controllers/Api/Client/Concerns/ManagesClientProfile.php @@ -5,8 +5,6 @@ namespace App\Http\Controllers\Api\Client\Concerns; use App\Http\Requests\Api\Client\ClientUpdateCccdRequest; use App\Http\Requests\Api\Client\ClientUpdateRequest; use App\Http\Resources\Api\Client\ClientResource; -use App\Models\Customer; -use Illuminate\Support\Facades\Storage; trait ManagesClientProfile { @@ -37,7 +35,7 @@ trait ManagesClientProfile public function updateCccd(ClientUpdateCccdRequest $request) { - // Disk private: ảnh chỉ đọc được qua route signed `api.client.cccd`. + // Disk private: phía client chỉ nhận trạng thái đã cập nhật, không nhận đường dẫn ảnh. $frontUrl = $request->file('cmnd_before')->store('cccd', 'local'); $backUrl = $request->file('cmnd_after')->store('cccd', 'local'); @@ -69,26 +67,4 @@ trait ManagesClientProfile ]); } - /** - * Trả ảnh CCCD từ disk private. Route yêu cầu chữ ký (signed:relative) nên không cần - * Bearer — chữ ký do ClientResource cấp cho đúng chủ tài khoản, hết hạn sau 60 phút. - */ - public function cccdImage(int $customer, string $side) - { - $path = Customer::findOrFail($customer)->{$side === 'front' ? 'front_identity_number' : 'back_identity_number'}; - - abort_unless($path && str_starts_with($path, 'cccd/') && Storage::disk('local')->exists($path), 404); - - $mime = match (strtolower(pathinfo($path, PATHINFO_EXTENSION))) { - 'png' => 'image/png', - 'webp' => 'image/webp', - default => 'image/jpeg', - }; - - return Storage::disk('local')->response($path, "cccd-{$side}", [ - 'Content-Type' => $mime, - 'Content-Disposition' => "inline; filename=\"cccd-{$side}\"", - 'Cache-Control' => 'private, no-store', - ]); - } } diff --git a/app/Http/Controllers/Api/NotificationController.php b/app/Http/Controllers/Api/NotificationController.php index 1437a3a..93717af 100644 --- a/app/Http/Controllers/Api/NotificationController.php +++ b/app/Http/Controllers/Api/NotificationController.php @@ -58,15 +58,9 @@ class NotificationController extends Controller ]); } - public function show(Request $request, string $recipientType, int $id): JsonResponse + public function show(Request $request, string $recipientType, string $uuid): JsonResponse { - $recipient = $this->resolveRecipient($recipientType); - - $notification = Notification::query() - ->where('recipient_type', $recipientType) - ->where('recipient_id', $recipient->id) - ->whereKey($id) - ->first(); + $notification = $this->findOwned($recipientType, $uuid); if (!$notification) { return response()->json(['error' => true, 'message' => 'Notification not found'], 404); @@ -75,15 +69,9 @@ class NotificationController extends Controller return response()->json(['error' => false, 'data' => $notification]); } - public function markRead(Request $request, string $recipientType, int $id): JsonResponse + public function markRead(Request $request, string $recipientType, string $uuid): JsonResponse { - $recipient = $this->resolveRecipient($recipientType); - - $notification = Notification::query() - ->where('recipient_type', $recipientType) - ->where('recipient_id', $recipient->id) - ->whereKey($id) - ->first(); + $notification = $this->findOwned($recipientType, $uuid); if (!$notification) { return response()->json(['error' => true, 'message' => 'Notification not found'], 404); @@ -113,6 +101,15 @@ class NotificationController extends Controller return response()->json(['error' => false, 'message' => 'Marked as read']); } + private function findOwned(string $recipientType, string $uuid): ?Notification + { + return Notification::query() + ->where('recipient_type', $recipientType) + ->where('recipient_id', $this->resolveRecipient($recipientType)->id) + ->where('uuid', $uuid) + ->first(); + } + private function resolveRecipient(string $recipientType) { return match ($recipientType) { diff --git a/app/Http/Controllers/Api/Setting/SettingController.php b/app/Http/Controllers/Api/Setting/SettingController.php index 5123ebe..6225277 100644 --- a/app/Http/Controllers/Api/Setting/SettingController.php +++ b/app/Http/Controllers/Api/Setting/SettingController.php @@ -70,15 +70,10 @@ class SettingController extends Controller { return response()->json([ 'error' => false, - 'data' => ClientMenu::items(), - ]); - } - - public function getQuickLinks(): \Illuminate\Http\JsonResponse - { - return response()->json([ - 'error' => false, - 'data' => ClientQuickLinks::items(), + 'data' => [ + 'items' => ClientMenu::items(), + 'quick_links' => ClientQuickLinks::items(), + ], ]); } diff --git a/app/Http/Requests/Api/Client/ClientUpdateRequest.php b/app/Http/Requests/Api/Client/ClientUpdateRequest.php index f2f50a7..a2559cc 100644 --- a/app/Http/Requests/Api/Client/ClientUpdateRequest.php +++ b/app/Http/Requests/Api/Client/ClientUpdateRequest.php @@ -2,7 +2,9 @@ namespace App\Http\Requests\Api\Client; +use App\Enums\Client\ClientGender; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Validation\Rule; class ClientUpdateRequest extends FormRequest { @@ -15,12 +17,18 @@ class ClientUpdateRequest extends FormRequest { return [ 'fullname' => 'required|string|max:255', - 'phone' => 'required|string|max:20', + 'phone' => 'sometimes|required|string|max:20', 'company' => 'nullable|string|max:255', 'address' => 'nullable|string|max:500', - 'province_code' => 'nullable|string|max:10', - 'ward_code' => 'nullable|string|max:10', - 'gender' => 'nullable|in:1,2,3', + 'province_code' => ['nullable', 'string', Rule::exists('provinces', 'code')], + 'ward_code' => [ + 'nullable', + 'string', + 'required_with:province_code', + Rule::exists('wards', 'code')->where('province_code', $this->input('province_code')), + ], + 'gender' => ['nullable', Rule::enum(ClientGender::class)], + 'birthday' => ['nullable', 'date_format:Y-m-d', 'before_or_equal:today', 'after:1900-01-01'], ]; } @@ -29,6 +37,10 @@ class ClientUpdateRequest extends FormRequest return [ 'fullname.required' => 'Họ tên là bắt buộc', 'phone.required' => 'Số điện thoại là bắt buộc', + 'province_code.exists' => 'Tỉnh/Thành phố không hợp lệ', + 'ward_code.required_with' => 'Vui lòng chọn Phường/Xã', + 'ward_code.exists' => 'Phường/Xã không thuộc Tỉnh/Thành phố đã chọn', + 'birthday.before_or_equal' => 'Ngày sinh không được ở tương lai', ]; } } diff --git a/app/Http/Requests/CreateCustomerRequest.php b/app/Http/Requests/CreateCustomerRequest.php index e76dda4..11d334a 100644 --- a/app/Http/Requests/CreateCustomerRequest.php +++ b/app/Http/Requests/CreateCustomerRequest.php @@ -33,7 +33,7 @@ class CreateCustomerRequest extends FormRequest 'state' => 'nullable', 'postcode' => 'nullable', 'country' => 'nullable', - 'email_marketing' => 'nullable', + 'email_marketing' => 'nullable|boolean', 'note' => 'nullable|string', ]; } diff --git a/app/Http/Resources/Api/Admin/CustomerResource.php b/app/Http/Resources/Api/Admin/CustomerResource.php index 1ae0610..dfedf51 100644 --- a/app/Http/Resources/Api/Admin/CustomerResource.php +++ b/app/Http/Resources/Api/Admin/CustomerResource.php @@ -19,6 +19,8 @@ class CustomerResource extends JsonResource 'note' => $this->note, 'status' => (int) $this->status, 'total' => (int) $this->total, + 'email_marketing' => (bool) $this->email_marketing, + 'receive_deposit' => (bool) $this->receive_deposit, 'created_at' => $this->created_at, ]; } diff --git a/app/Http/Resources/Api/Client/ClientResource.php b/app/Http/Resources/Api/Client/ClientResource.php index 2371ae4..4807169 100644 --- a/app/Http/Resources/Api/Client/ClientResource.php +++ b/app/Http/Resources/Api/Client/ClientResource.php @@ -5,8 +5,6 @@ namespace App\Http\Resources\Api\Client; use App\Services\Api\ServiceClient\ServiceClientService; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; -use Illuminate\Support\Facades\URL; -use Illuminate\Support\Str; class ClientResource extends JsonResource { @@ -25,25 +23,31 @@ class ClientResource extends JsonResource 'customer' => [ 'fullname' => $this->fullname, 'email' => $this->email, - 'phone' => $this->phone, + 'phone' => $this->maskedPhone(), 'address' => $this->address, 'city' => $this->city, 'ward' => $this->ward->name ?? '', + 'ward_code' => $this->ward_code, 'province' => $this->province->name ?? '', - 'gender' => $this->gender_label, + 'province_code' => $this->province_code, + 'gender' => $this->gender?->value, + 'gender_label' => $this->gender_label, + 'birthday' => $this->birthday?->format('Y-m-d'), 'date' => $this->created_at ? $this->created_at->format('Y-m-d H:i:s') : null, 'company' => $this->company ?? '', 'avatar' => $this->avatar, - 'cmnd_before' => $this->cccdUrl('front', $this->front_identity_number), - 'cmnd_after' => $this->cccdUrl('back', $this->back_identity_number), - 'cmnd' => $this->identity_number ? Str::mask($this->identity_number, '*', 0, -4) : null, + 'cccd_updated' => (bool) ( + $this->identity_number + && $this->front_identity_number + && $this->back_identity_number + ), 'fb_link' => null, - 'active_2fa' => (int) $this->two_factor_enabled, - 'off_mail_marketing' => $this->off_mail_marketing, + 'active_2fa' => (bool) $this->two_factor_enabled, + 'off_mail_marketing' => (bool) $this->off_mail_marketing, 'email_marketing' => (bool) $this->email_marketing, 'receive_deposit' => (bool) $this->receive_deposit, - 'is_recharge_zalo' => 0, - 'is_verify_telegram' => 0, + 'is_recharge_zalo' => false, + 'is_verify_telegram' => false, ], 'credit' => [ 'credit' => $this->credit, @@ -60,17 +64,14 @@ class ClientResource extends JsonResource ]; } - /** - * Ảnh CCCD trên disk private → URL ký tương đối (/v1/client/cccd/...), hết hạn 60 phút. - */ - private function cccdUrl(string $side, ?string $path): string + private function maskedPhone(): ?string { - if (!$path) { - return ''; + if (!$this->phone) { + return null; } - // Giá trị import từ portal cũ (/uploads/... trên host cũ) cũng chỉ trả URL ký — không lộ đường dẫn gốc; - // route trả 404 cho chúng vì file không có trên disk private. Vẫn khác rỗng để hasCccd giữ nguyên hành vi. - return URL::temporarySignedRoute('api.client.cccd', now()->addHour(), ['customer' => $this->id, 'side' => $side], false); + $visibleDigits = mb_substr($this->phone, -3); + + return str_repeat('*', max(0, mb_strlen($this->phone) - mb_strlen($visibleDigits))) . $visibleDigits; } } diff --git a/app/Jobs/Mail/SendMailCampaignRecipient.php b/app/Jobs/Mail/SendMailCampaignRecipient.php index 3dc04a6..651f1f3 100644 --- a/app/Jobs/Mail/SendMailCampaignRecipient.php +++ b/app/Jobs/Mail/SendMailCampaignRecipient.php @@ -37,6 +37,12 @@ class SendMailCampaignRecipient implements ShouldQueue } $customer = $this->customerId ? Customer::find($this->customerId) : null; + if ($customer && $campaign->isMarketing() && !$customer->acceptsMarketingEmail()) { + $this->markDone($campaign, true); + + return; + } + $rendered = $renderer->render($campaign, $renderer->recipientValues($campaign, $customer, $this->email)); $cc = EmailRecipients::parse($campaign->cc); $bcc = EmailRecipients::parse($campaign->bcc); diff --git a/app/Models/Customer.php b/app/Models/Customer.php index 219b5ea..35e03e9 100644 --- a/app/Models/Customer.php +++ b/app/Models/Customer.php @@ -65,21 +65,11 @@ class Customer extends Authenticatable implements JWTSubject 'remember_token', 'status', 'gender', + 'birthday', 'province_code', 'ward_code', ]; - protected $updatable = [ - 'fullname', - 'username', - 'phone', - 'company', - 'address', - 'province_code', - 'ward_code', - 'gender', - ]; - /** * Appends for API */ @@ -92,6 +82,7 @@ class Customer extends Authenticatable implements JWTSubject 'gender' => \App\Enums\Client\ClientGender::class, 'email_verified_at' => 'datetime', 'two_factor_enabled' => 'boolean', + 'birthday' => 'date', ]; /** @@ -200,7 +191,7 @@ class Customer extends Authenticatable implements JWTSubject return $this->hasMany(Log::class); } - public function getEmailMarketingBoolAttribute() + public function acceptsMarketingEmail(): bool { return (bool) $this->email_marketing; } diff --git a/app/Models/MailCampaign.php b/app/Models/MailCampaign.php index 2bd32fb..6633166 100644 --- a/app/Models/MailCampaign.php +++ b/app/Models/MailCampaign.php @@ -45,6 +45,11 @@ class MailCampaign extends Model }); } + public function isMarketing(): bool + { + return $this->template === null || $this->template->isMarketing(); + } + public function logs() { return $this->hasMany(Mail::class, 'campaign_id'); diff --git a/app/Models/Notification.php b/app/Models/Notification.php index fca2548..f6c9e87 100644 --- a/app/Models/Notification.php +++ b/app/Models/Notification.php @@ -2,6 +2,7 @@ namespace App\Models; +use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use App\Models\Customer; @@ -9,7 +10,14 @@ use App\Models\User; class Notification extends Model { - use HasFactory; + use HasFactory, HasUuids; + + protected $hidden = ['id']; + + public function uniqueIds(): array + { + return ['uuid']; + } protected $fillable = [ 'recipient_type', diff --git a/app/Models/Template.php b/app/Models/Template.php index fd8a633..d15ff72 100644 --- a/app/Models/Template.php +++ b/app/Models/Template.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Support\EmailScenarioRegistry; use App\Support\EmailTemplateShell; use App\Support\MailGlobals; use App\Support\MailPlaceholders; @@ -53,6 +54,11 @@ class Template extends Model } } + public function isMarketing(): bool + { + return EmailScenarioRegistry::describe($this->key, $this->scenario)['category'] === EmailScenarioRegistry::MARKETING; + } + /** Danh sách variable hợp lệ khai báo trong metadata.variables, key theo variable key. */ public function variables(): array { diff --git a/app/Observers/CustomerObserver.php b/app/Observers/CustomerObserver.php index 2878b8b..297bfd0 100644 --- a/app/Observers/CustomerObserver.php +++ b/app/Observers/CustomerObserver.php @@ -39,7 +39,7 @@ class CustomerObserver if ($customer->getDirty()) { foreach ($customer->getDirty() as $attribute => $value) { { if (! in_array($attribute, $this->conditions)) { - $old_value = $customer->getOriginal($attribute); + $old_value = $customer->getRawOriginal($attribute); $this->logRepository->create([ 'agent' => request()->userAgent(), 'client_ip' => request()->getClientIp(), diff --git a/app/Observers/OrderObserver.php b/app/Observers/OrderObserver.php index b8a0438..36ec351 100644 --- a/app/Observers/OrderObserver.php +++ b/app/Observers/OrderObserver.php @@ -34,7 +34,7 @@ class OrderObserver if ($order->getDirty()) { foreach ($order->getDirty() as $attribute => $value) { { if ($attribute !== 'updated_at') { - $old_value = $order->getOriginal($attribute); + $old_value = $order->getRawOriginal($attribute); $this->logRepository->create([ 'agent' => request()->userAgent(), 'client_ip' => request()->getClientIp(), diff --git a/app/Services/Customer/CustomerAccountService.php b/app/Services/Customer/CustomerAccountService.php index a443c59..ae19e77 100644 --- a/app/Services/Customer/CustomerAccountService.php +++ b/app/Services/Customer/CustomerAccountService.php @@ -30,7 +30,7 @@ class CustomerAccountService unset($data['note']); $data['password'] = Hash::make($data['password']); - $data['email_marketing'] = ($data['email_marketing'] ?? null) === 'on' ? 1 : 0; + $data['email_marketing'] = (bool) ($data['email_marketing'] ?? true); $customer = $this->customerRepository->create($data); diff --git a/app/Services/Customer/CustomerMailService.php b/app/Services/Customer/CustomerMailService.php index 29dea28..3ec5a99 100644 --- a/app/Services/Customer/CustomerMailService.php +++ b/app/Services/Customer/CustomerMailService.php @@ -143,6 +143,11 @@ class CustomerMailService abort_if(!AutoEmailGate::mailEnabled(), 422, 'Hệ thống đang tắt gửi email (Mẫu email → Cài đặt → Bật gửi email).'); $template = empty($data['template_id']) ? null : $this->sendableTemplate((int) $data['template_id']); + abort_if( + $template?->isMarketing() && !$customer->acceptsMarketingEmail(), + 422, + 'Khách hàng đã tắt nhận email marketing, không thể gửi mẫu email marketing.', + ); $subject = trim((string) ($data['subject'] ?? '')); $message = trim((string) ($data['message'] ?? '')); diff --git a/database/migrations/2026_10_13_000005_add_uuid_to_notifications_table.php b/database/migrations/2026_10_13_000005_add_uuid_to_notifications_table.php new file mode 100644 index 0000000..cea56f6 --- /dev/null +++ b/database/migrations/2026_10_13_000005_add_uuid_to_notifications_table.php @@ -0,0 +1,36 @@ +uuid('uuid')->nullable()->after('id'); + }); + + DB::table('notifications')->whereNull('uuid')->orderBy('id')->chunkById(500, function ($rows) { + foreach ($rows as $row) { + DB::table('notifications')->where('id', $row->id)->update(['uuid' => (string) Str::uuid7()]); + } + }); + + Schema::table('notifications', function (Blueprint $table) { + $table->uuid('uuid')->nullable(false)->change(); + $table->unique('uuid'); + }); + } + + public function down(): void + { + Schema::table('notifications', function (Blueprint $table) { + $table->dropUnique(['uuid']); + $table->dropColumn('uuid'); + }); + } +}; diff --git a/database/migrations/2026_10_13_000006_add_birthday_to_customers.php b/database/migrations/2026_10_13_000006_add_birthday_to_customers.php new file mode 100644 index 0000000..c914370 --- /dev/null +++ b/database/migrations/2026_10_13_000006_add_birthday_to_customers.php @@ -0,0 +1,21 @@ +date('birthday')->nullable()->after('gender'); + }); + } + + public function down(): void + { + Schema::table('customers', function (Blueprint $table) { + $table->dropColumn('birthday'); + }); + } +}; diff --git a/database/migrations/2026_10_13_000007_import_provinces_and_wards.php b/database/migrations/2026_10_13_000007_import_provinces_and_wards.php new file mode 100644 index 0000000..29fd34c --- /dev/null +++ b/database/migrations/2026_10_13_000007_import_provinces_and_wards.php @@ -0,0 +1,30 @@ +exists(); + + if (!$filled[$table]) { + DB::unprepared($statement); + } + } + } + + public function down(): void + { + } +}; diff --git a/database/seeders/ProvinceWardSeeder.php b/database/seeders/ProvinceWardSeeder.php deleted file mode 100644 index 18c0476..0000000 --- a/database/seeders/ProvinceWardSeeder.php +++ /dev/null @@ -1,30 +0,0 @@ -command->info('Province/Ward data imported successfully from SQL file.'); - } else { - $this->command->error('File database/province_ward_import.sql not found.'); - } - } -} diff --git a/routes/api/admin/system.php b/routes/api/admin/system.php index 80936c8..ed2f40c 100644 --- a/routes/api/admin/system.php +++ b/routes/api/admin/system.php @@ -121,13 +121,13 @@ Route::prefix('notifications') ->group(function () { Route::get('/', fn() => app(NotificationController::class)->index(request(), 'admin')); Route::get( - '{id}', - fn($id) => app(NotificationController::class)->show(request(), 'admin', (int) $id), - )->whereNumber('id'); + '{uuid}', + fn($uuid) => app(NotificationController::class)->show(request(), 'admin', $uuid), + )->whereUuid('uuid'); Route::patch( - '{id}/read', - fn($id) => app(NotificationController::class)->markRead(request(), 'admin', (int) $id), - ); + '{uuid}/read', + fn($uuid) => app(NotificationController::class)->markRead(request(), 'admin', $uuid), + )->whereUuid('uuid'); Route::patch( 'read-all', fn() => app(NotificationController::class)->markAllRead(request(), 'admin'), diff --git a/routes/client.php b/routes/client.php index cd04178..6d1f674 100644 --- a/routes/client.php +++ b/routes/client.php @@ -13,6 +13,7 @@ use App\Http\Controllers\Api\Service\SnapshotController; use App\Http\Controllers\Api\Service\VmController; use App\Http\Controllers\Api\Service\VmRebuildController; use App\Http\Controllers\Api\Admin\Domain\InetDomainController; +use App\Http\Controllers\Api\Client\AdministrativeDivisionController; use App\Http\Controllers\Api\Client\BankController as ClientBankController; use App\Http\Controllers\Api\Client\Docs\PostCategoryController as ClientPostCategoryController; use App\Http\Controllers\Api\Client\Docs\PostController as ClientPostController; @@ -39,12 +40,6 @@ Route::prefix('v1') Route::post('forgot-password', 'forgotPassword')->middleware(['throttle:client-auth', 'turnstile'])->name('forgot-password'); Route::post('reset-password', 'resetPassword')->middleware(['throttle:client-auth', 'turnstile'])->name('reset-password'); Route::get('reset-password/{token}', 'showResetToken')->middleware('throttle:client-auth')->name('reset-password.show'); - // Ảnh CCCD nằm trên disk private, chỉ truy cập qua URL ký (ClientResource cấp, hết hạn 60 phút) - Route::get('cccd/{customer}/{side}', 'cccdImage') - ->whereNumber('customer') - ->whereIn('side', ['front', 'back']) - ->middleware('signed:relative') - ->name('cccd'); Route::post('impersonate/exchange', 'impersonateExchange')->name('impersonate.exchange'); Route::post('login/two-factor', 'verifyLoginTwoFactor')->middleware('throttle:client-auth')->name('login.two-factor'); Route::post('login/two-factor/resend', 'resendLoginTwoFactor')->middleware('throttle:client-auth')->name('login.two-factor.resend'); @@ -151,7 +146,8 @@ Route::prefix('v1') ->controller(NotificationController::class) ->group(function () { Route::get('/', fn () => app(NotificationController::class)->index(request(), 'client')); - Route::patch('{id}/read', fn ($id) => app(NotificationController::class)->markRead(request(), 'client', (int) $id)); + Route::get('{uuid}', fn ($uuid) => app(NotificationController::class)->show(request(), 'client', $uuid))->whereUuid('uuid'); + Route::patch('{uuid}/read', fn ($uuid) => app(NotificationController::class)->markRead(request(), 'client', $uuid))->whereUuid('uuid'); Route::patch('read-all', fn () => app(NotificationController::class)->markAllRead(request(), 'client')); }); @@ -207,7 +203,6 @@ Route::prefix('v1') Route::get('email', 'getEmailSetting')->name('email'); Route::get('turnstile', 'getTurnstileSetting')->name('turnstile'); Route::get('menu', 'getMenu')->name('menu'); - Route::get('quick-links', 'getQuickLinks')->name('quick-links'); Route::get('popups', 'getPopups')->name('popups'); }); }); @@ -215,6 +210,11 @@ Route::prefix('v1') // Public bank list for deposit form Route::get('banks', [ClientBankController::class, 'index'])->name('banks.list'); + Route::controller(AdministrativeDivisionController::class)->group(function () { + Route::get('provinces', 'provinces')->name('provinces'); + Route::get('provinces/{province}/wards', 'wards')->where('province', '[0-9]+')->name('provinces.wards'); + }); + Route::prefix('post-categories') ->controller(ClientPostCategoryController::class) ->as('client.docs.categories.') diff --git a/tests/Feature/Api/Admin/ClientMenuTest.php b/tests/Feature/Api/Admin/ClientMenuTest.php index 644f7d8..7dbb6df 100644 --- a/tests/Feature/Api/Admin/ClientMenuTest.php +++ b/tests/Feature/Api/Admin/ClientMenuTest.php @@ -40,11 +40,11 @@ class ClientMenuTest extends TestCase $this->withHeaders(['x-access-token' => $this->buildClientAccessToken()]) ->getJson('/v1/setting/menu') ->assertOk() - ->assertJsonPath('data.0.title', 'Dashboard') - ->assertJsonPath('data.0.children', []) - ->assertJsonPath('data.1.href', null) - ->assertJsonPath('data.1.children.0.href', '/dich-vu/vps') - ->assertJsonPath('data.1.children.0.disabled', true); + ->assertJsonPath('data.items.0.title', 'Dashboard') + ->assertJsonPath('data.items.0.children', []) + ->assertJsonPath('data.items.1.href', null) + ->assertJsonPath('data.items.1.children.0.href', '/dich-vu/vps') + ->assertJsonPath('data.items.1.children.0.disabled', true); } public function test_rejects_unsafe_href_and_leaf_without_href(): void @@ -73,10 +73,10 @@ class ClientMenuTest extends TestCase ->assertOk(); $this->withHeaders(['x-access-token' => $this->buildClientAccessToken()]) - ->getJson('/v1/setting/quick-links') + ->getJson('/v1/setting/menu') ->assertOk() - ->assertJsonPath('data.0.title', 'Tạo Cloud VPS') - ->assertJsonPath('data.0.disabled', false); + ->assertJsonPath('data.quick_links.0.title', 'Tạo Cloud VPS') + ->assertJsonPath('data.quick_links.0.disabled', false); $this->actingAs($admin, 'admin') ->putJson('/api/v1/administrator/client-menu/quick-links', ['items' => [ diff --git a/tests/Feature/Api/Admin/MarketingEmailOptOutTest.php b/tests/Feature/Api/Admin/MarketingEmailOptOutTest.php new file mode 100644 index 0000000..206250e --- /dev/null +++ b/tests/Feature/Api/Admin/MarketingEmailOptOutTest.php @@ -0,0 +1,89 @@ + '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; + } + + private function template(?string $key): Template + { + return Template::create([ + 'type_id' => 0, + 'name' => 'T ' . uniqid(), + 'key' => $key, + 'format' => 'html', + 'subject' => 'Ưu đãi', + 'message' => '

Hello

', + 'status' => 1, + ]); + } + + public function test_marketing_campaign_skips_opted_out_customers_but_system_template_does_not(): void + { + Queue::fake(); + $admin = $this->admin(); + $subscribed = Customer::factory()->create(['email_marketing' => 1]); + $optedOut = Customer::factory()->create(['email_marketing' => 0]); + + $this->actingAs($admin, 'admin') + ->postJson('/api/v1/administrator/mail-campaigns', [ + 'template_id' => $this->template(null)->id, + 'recipient_type' => 'selected', + 'uuids' => [$subscribed->uuid, $optedOut->uuid], + 'emails' => [strtoupper($optedOut->email)], + ]) + ->assertOk() + ->assertJsonPath('data.total_recipients', 1); + + Queue::assertPushed(SendMailCampaignRecipient::class, 1); + + $this->actingAs($admin, 'admin') + ->postJson('/api/v1/administrator/mail-campaigns', [ + 'template_id' => $this->template('service_renewal_reminder')->id, + 'recipient_type' => 'selected', + 'uuids' => [$optedOut->uuid], + ]) + ->assertOk() + ->assertJsonPath('data.total_recipients', 1); + } + + public function test_marketing_campaign_with_only_opted_out_recipients_is_rejected(): void + { + Queue::fake(); + $optedOut = Customer::factory()->create(['email_marketing' => 0]); + + $this->actingAs($this->admin(), 'admin') + ->postJson('/api/v1/administrator/mail-campaigns', [ + 'subject' => 'Tin tức', + 'message' => '

Hi

', + 'recipient_type' => 'selected', + 'uuids' => [$optedOut->uuid], + ]) + ->assertStatus(422); + + Queue::assertNotPushed(SendMailCampaignRecipient::class); + } +} diff --git a/tests/Feature/Api/Client/ClientAuthSecurityTest.php b/tests/Feature/Api/Client/ClientAuthSecurityTest.php index 08737c5..4c0aa56 100644 --- a/tests/Feature/Api/Client/ClientAuthSecurityTest.php +++ b/tests/Feature/Api/Client/ClientAuthSecurityTest.php @@ -142,24 +142,45 @@ class ClientAuthSecurityTest extends TestCase $this->api()->withHeaders(['Authorization' => "Bearer {$token}"])->getJson('/v1/client/me')->assertUnauthorized(); } - public function test_cccd_images_are_private_and_served_only_via_signed_url(): void + public function test_cccd_details_are_not_exposed_to_client(): void { Storage::fake('local'); Storage::disk('local')->put('cccd/front.jpg', 'img'); + Storage::disk('local')->put('cccd/back.jpg', 'img'); $customer = Customer::factory()->create([ 'status' => 1, 'identity_number' => '079123456789', 'front_identity_number' => 'cccd/front.jpg', + 'back_identity_number' => 'cccd/back.jpg', ]); $me = $this->actingAs($customer, 'client')->api()->getJson('/v1/client/me')->assertOk(); $data = $me->json('data.client.customer'); - $this->assertSame('********6789', $data['cmnd']); - $this->assertStringStartsWith('/v1/client/cccd/' . $customer->id . '/front?expires=', $data['cmnd_before']); + $this->assertTrue($data['cccd_updated']); + $this->assertArrayNotHasKey('cmnd', $data); + $this->assertArrayNotHasKey('cmnd_before', $data); + $this->assertArrayNotHasKey('cmnd_after', $data); + $this->api()->get("/v1/client/cccd/{$customer->id}/front")->assertNotFound(); + } - $this->api()->get('/v1/client/cccd/' . $customer->id . '/front')->assertStatus(403); - $this->api()->get($data['cmnd_before'])->assertOk(); + public function test_phone_is_masked_to_last_three_digits_in_client_response(): void + { + $customer = Customer::factory()->create([ + 'status' => 1, + 'phone' => '0929768789', + ]); + + $this->actingAs($customer, 'client')->api()->getJson('/v1/client/me') + ->assertOk() + ->assertJsonPath('data.client.customer.phone', '*******789'); + + $this->actingAs($customer->fresh(), 'client')->api()->putJson('/v1/client/update', [ + 'fullname' => 'Bảo', + 'company' => 'Công ty mới', + ])->assertOk(); + + $this->assertSame('0929768789', $customer->fresh()->phone); } public function test_access_token_is_revoked_when_password_changes(): void @@ -262,14 +283,62 @@ class ClientAuthSecurityTest extends TestCase } } - public function test_legacy_cccd_path_is_not_exposed(): void + public function test_incomplete_legacy_cccd_is_reported_only_as_not_updated(): void { $customer = Customer::factory()->create(['status' => 1, 'front_identity_number' => '/uploads/2023/11/06/abc.jpg']); - $url = $this->actingAs($customer, 'client')->api()->getJson('/v1/client/me') - ->json('data.client.customer.cmnd_before'); + $data = $this->actingAs($customer, 'client')->api()->getJson('/v1/client/me') + ->assertOk() + ->json('data.client.customer'); - $this->assertStringNotContainsString('uploads', $url); - $this->api()->get($url)->assertNotFound(); + $this->assertFalse($data['cccd_updated']); + $this->assertArrayNotHasKey('cmnd_before', $data); + } + + public function test_profile_update_accepts_gender_enum_values_only(): void + { + $customer = Customer::factory()->create(['status' => 1, 'gender' => 0])->fresh(); + $payload = ['fullname' => 'Bảo', 'phone' => '0929768789']; + + $this->actingAs($customer, 'client')->api() + ->putJson('/v1/client/update', $payload + ['gender' => 1]) + ->assertOk() + ->assertJsonPath('data.client.customer.gender', 1) + ->assertJsonPath('data.client.customer.gender_label', 'Nữ'); + + $this->actingAs($customer->fresh(), 'client')->api() + ->putJson('/v1/client/update', $payload + ['gender' => 2]) + ->assertStatus(422); + } + + public function test_profile_update_saves_birthday_and_validates_ward_belongs_to_province(): void + { + $customer = Customer::factory()->create(['status' => 1])->fresh(); + $payload = ['fullname' => 'Bảo', 'phone' => '0929768789']; + + $this->actingAs($customer, 'client')->api() + ->putJson('/v1/client/update', $payload + [ + 'birthday' => '1995-06-20', + 'province_code' => '79', + 'ward_code' => '27448', + ]) + ->assertOk() + ->assertJsonPath('data.client.customer.birthday', '1995-06-20') + ->assertJsonPath('data.client.customer.province_code', '79') + ->assertJsonPath('data.client.customer.province', 'Hồ Chí Minh') + ->assertJsonPath('data.client.customer.ward', 'Bình Trị Đông'); + + $this->actingAs($customer->fresh(), 'client')->api() + ->putJson('/v1/client/update', $payload + ['province_code' => '01', 'ward_code' => '27448']) + ->assertStatus(422) + ->assertJsonValidationErrors(['ward_code']); + + $this->actingAs($customer->fresh(), 'client')->api() + ->putJson('/v1/client/update', $payload + ['birthday' => now()->addDay()->toDateString()]) + ->assertStatus(422) + ->assertJsonValidationErrors(['birthday']); + + $this->api()->getJson('/v1/provinces')->assertOk()->assertJsonCount(34, 'data'); + $this->api()->getJson('/v1/provinces/79/wards')->assertOk()->assertJsonFragment(['code' => '27448']); } } diff --git a/tests/Feature/Api/Client/ClientTamperingTest.php b/tests/Feature/Api/Client/ClientTamperingTest.php index 582aada..7961afb 100644 --- a/tests/Feature/Api/Client/ClientTamperingTest.php +++ b/tests/Feature/Api/Client/ClientTamperingTest.php @@ -167,12 +167,16 @@ class ClientTamperingTest extends TestCase public function test_cannot_read_reply_or_close_another_customers_ticket_or_notification(): void { $ticket = Ticket::create(['uuid' => (string) Str::uuid(), 'ticket_num' => 'TK-VICTIM', 'subject' => 'S', 'customer_id' => $this->victim->id, 'description' => 'D', 'status' => 'open']); - $notification = Notification::create(['recipient_type' => 'customer', 'recipient_id' => $this->victim->id, 'type' => 'info', 'title' => 'T', 'body' => 'B', 'is_read' => false]); + $notification = Notification::create(['recipient_type' => 'client', 'recipient_id' => $this->victim->id, 'type' => 'info', 'title' => 'T', 'body' => 'B', 'is_read' => false]); $this->assertDenied($this->as($this->attacker)->getJson("/v1/client/tickets/{$ticket->ticket_num}")); $this->assertDenied($this->as($this->attacker)->postJson("/v1/client/tickets/{$ticket->ticket_num}/reply", ['message' => 'pwned', 'content' => 'pwned'])); $this->assertDenied($this->as($this->attacker)->patchJson("/v1/client/tickets/{$ticket->ticket_num}/close")); - $this->as($this->attacker)->patchJson("/v1/client/notifications/{$notification->id}/read"); + $this->assertDenied($this->as($this->attacker)->patchJson("/v1/client/notifications/{$notification->uuid}/read")); + $this->assertDenied($this->as($this->attacker)->getJson("/v1/client/notifications/{$notification->uuid}")); + $own = $this->as($this->victim)->getJson("/v1/client/notifications/{$notification->uuid}")->assertOk()->json('data'); + $this->assertSame($notification->uuid, $own['uuid']); + $this->assertArrayNotHasKey('id', $own); $this->assertSame('open', $ticket->fresh()->status); $this->assertFalse((bool) $notification->fresh()->is_read); diff --git a/tests/Feature/Api/Client/ClientTwoFactorTest.php b/tests/Feature/Api/Client/ClientTwoFactorTest.php index 9b4e337..af71b98 100644 --- a/tests/Feature/Api/Client/ClientTwoFactorTest.php +++ b/tests/Feature/Api/Client/ClientTwoFactorTest.php @@ -85,7 +85,7 @@ class ClientTwoFactorTest extends TestCase $this->actingAs($customer->fresh(), 'client')->api() ->getJson('/v1/client/me') - ->assertJsonPath('data.client.customer.active_2fa', 1); + ->assertJsonPath('data.client.customer.active_2fa', true); } public function test_login_requires_emailed_code_and_can_remember_device(): void