feat: hồ sơ khách (ngày sinh, tỉnh/phường theo mã), gộp menu + nút tắt, thông báo theo uuid

- 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 <noreply@anthropic.com>
This commit is contained in:
baodev0
2026-10-13 16:03:18 +07:00
co-authored by Claude Opus 5.5
parent 12d5b4951c
commit bf78f3b810
30 changed files with 461 additions and 183 deletions
@@ -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']);
}
}