Appearance
Dev Spec — Thu thập & Chuẩn hoá thông tin cá nhân khách hàng
Version: 1.1 Date: 25/03/2026 Author: Dev Team Ref PRD: docs/features/thu-thap-thong-tin-khach-hang/prd.mdComplexity: M
Changelog
| Version | Date | Author | Thay đổi |
|---|---|---|---|
| 1.0 | 25/03/2026 | Dev Team | Initial |
| 1.1 | 27/03/2026 | PO/BA | DEC-009: Occupation seed data dùng options_source reference default_master_data thay vì hardcode options. FE-4 bỏ CRUD occupation inline → link đến Master Data |
C1) Scope
Modules ảnh hưởng
| Module | Platform | Vai trò |
|---|---|---|
controller (Hasura) | Backend | Migration, metadata, seed data cho customer_consent |
customer app | Flutter mobile | Consent screen, Profile update popup, auth flow integration |
settings module | Admin web (Vue 3) | Tab Consent config + thống kê |
core lib | Flutter shared | Extend ServerToAppSetting model |
Exclusions
| Hạng mục | Lý do |
|---|---|
| Go microservices | Toàn bộ CRUD qua Hasura GraphQL, KHÔNG cần viết Go code mới |
| Enforce consent (filter notification, ẩn ảnh) | Out-of-scope Phase 1 (DEC-001) |
| Incentive logic | Out-of-scope (DEC-007) |
| GPS auto-detect | Out-of-scope (DEC-004) |
| i18n / đa ngôn ngữ | Diva chỉ hỗ trợ tiếng Việt |
| Staff app / Admin app consent | Chỉ áp dụng cho customer app |
C2) Impact Summary
| # | Component | Đã có | Cần thêm/sửa | Risk |
|---|---|---|---|---|
| 1 | customer_consent table | Chưa có | Tạo mới (migration + Hasura tracking + permissions) | Low — bảng mới, không ảnh hưởng bảng cũ |
| 2 | app_setting.app_settings JSON | Đã có | Thêm 2 keys: consent_config, profile_update_info (seed data) | Low — thêm key vào JSON, không ảnh hưởng keys cũ |
| 3 | account table | Đã có (birthday, occupation) | READ ONLY — không sửa schema | None |
| 4 | account_address table | Đã có (province_code, province_name) | READ ONLY — không sửa schema | None |
| 5 | ServerToAppSetting (Dart model) | Đã có | Thêm 2 fields: consentConfig, profileUpdateInfo | Low — backward compatible |
| 6 | SplashBloc (Flutter customer) | Đã có | Không sửa trực tiếp. Logic consent check nằm ở auth flow mới | Low |
| 7 | Auth flow navigation | Đã có | Thêm consent + update_info check sau login/signup success | Med — core flow, cần test kỹ |
| 8 | CustomerAndStaffAppEditor (Admin) | Đã có | Thêm tab "Consent" | Low — additive |
| 9 | Hasura metadata public_account.yaml | Đã có | Cần sửa: thêm birthday, occupation vào customer update_permissions | Med — thay đổi permission |
| 10 | Hasura metadata public_account_address.yaml | Đã có | READ ONLY — customer role đã có insert/update province_code | None |
C3) Rules & Formulas
Feature này không có formula phức tạp. Chỉ có stats aggregation queries và retry logic.
STAT-001: Thống kê % cập nhật
- Ref: PRD A5 FR-007, PRD A8
- SQL (Hasura aggregate):
graphql
query ConsentStats {
total: account_aggregate(where: { role: { _eq: "customer" } }) {
aggregate { count }
}
consented: customer_consent_aggregate {
aggregate { count }
}
has_birthday: account_aggregate(
where: { birthday: { _is_null: false }, role: { _eq: "customer" } }
) { aggregate { count } }
has_occupation: account_aggregate(
where: { occupation: { _is_null: false }, role: { _eq: "customer" } }
) { aggregate { count } }
has_province: account_address_aggregate(
where: {
province_code: { _is_null: false }
primary_contact: { _eq: true }
account: { role: { _eq: "customer" } }
}
) { aggregate { count } }
}- % tính:
count / total * 100, hiển thị 1 decimal - Edge case: total = 0 → hiển thị "—" (không hiện 0% hay NaN)
RULE-001: Retry popup logic
- Ref: PRD A5 FR-003
- Điều kiện hiện popup:
update_info_completed = false AND update_info_skip_count < max_skip (default 3) AND app_open_count >= update_info_skip_count * reshow_after_opens (default 4) AND (birthday IS NULL OR occupation IS NULL OR province_code IS NULL) - VD: skip 1 lần → hỏi lại khi app_open_count >= 4. Skip 2 lần → hỏi lại khi app_open_count >= 8. Skip 3 lần → không hỏi nữa.
C4) Data Model
4.1 Bảng hiện có (READ ONLY — KHÔNG sửa schema)
| Table | Database | Columns sử dụng | Ghi chú |
|---|---|---|---|
account | default | id (TEXT PK), birthday (DATE), occupation (TEXT), display_name, avatar_url | Customer role: select 40+ cols, update limited |
account_address | default | id (UUID PK), account_id (TEXT FK), province_code, province_name, primary_contact | Customer role: RLS account_id = X-Hasura-User-Id |
app_setting | default | id (SMALLINT PK), app_settings (JSON) | All roles: select. User role: update |
4.2 Bảng mới: customer_consent
sql
-- Migration: {timestamp}_create_table_customer_consent/up.sql
CREATE TABLE public.customer_consent (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
account_id TEXT NOT NULL,
branch_id UUID NOT NULL,
-- Consent data
consent_data JSONB NOT NULL DEFAULT '{}'::jsonb,
consent_version INTEGER NOT NULL DEFAULT 1,
-- Profile update tracking
update_info_skip_count INTEGER NOT NULL DEFAULT 0,
update_info_completed BOOLEAN NOT NULL DEFAULT false,
app_open_count INTEGER NOT NULL DEFAULT 0,
-- Audit
accepted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-- Foreign keys
CONSTRAINT fk_customer_consent_account
FOREIGN KEY (account_id) REFERENCES public.account(id)
ON DELETE CASCADE,
CONSTRAINT fk_customer_consent_branch
FOREIGN KEY (branch_id) REFERENCES public.branch(id)
ON DELETE RESTRICT
);
-- Unique constraint: 1 record per account
CREATE UNIQUE INDEX idx_customer_consent_account
ON public.customer_consent(account_id);
-- Index cho admin stats queries (filter theo branch)
CREATE INDEX idx_customer_consent_branch
ON public.customer_consent(branch_id);
-- Index cho version-based queries
CREATE INDEX idx_customer_consent_version
ON public.customer_consent(consent_version);
COMMENT ON TABLE public.customer_consent
IS 'Lưu trạng thái consent và tracking cập nhật thông tin cá nhân khách hàng';
COMMENT ON COLUMN public.customer_consent.consent_data
IS 'JSONB chứa trạng thái từng checkbox consent, VD: {"marketing": true, "treatment_photo": false}';
COMMENT ON COLUMN public.customer_consent.consent_version
IS 'Version của consent config tại thời điểm khách đồng ý';
COMMENT ON COLUMN public.customer_consent.update_info_skip_count
IS 'Số lần khách bỏ qua popup đề xuất cập nhật thông tin';
COMMENT ON COLUMN public.customer_consent.update_info_completed
IS 'true khi khách đã cập nhật đủ thông tin (birthday + occupation + province)';
COMMENT ON COLUMN public.customer_consent.app_open_count
IS 'Số lần mở app (auth success), dùng tính điều kiện retry popup';
COMMENT ON COLUMN public.customer_consent.accepted_at
IS 'Thời điểm khách bấm Đồng ý & Tiếp tục (hoặc re-consent)';Down migration:
sql
-- Migration: {timestamp}_create_table_customer_consent/down.sql
DROP TABLE IF EXISTS public.customer_consent;4.3 Seed data: consent_config + profile_update_info trong app_setting
Thêm 2 keys vào JSON field
app_settingscủa recordapp_settinghiện có (id = 1).
sql
-- Migration: {timestamp}_seed_consent_config_app_setting/up.sql
UPDATE public.app_setting
SET app_settings = app_settings || '{
"consent_config": {
"group": "customer_app",
"type": "json",
"value": {
"version": 1,
"title": "Chào mừng bạn đến Viện Thẩm Mỹ Diva!",
"body": "Để phục vụ bạn, chúng tôi sử dụng thông tin cơ bản (tên, SĐT) và gửi thông báo liên quan đến dịch vụ.",
"items": [
{
"key": "marketing",
"label": "Nhận thông tin khuyến mãi",
"description": "SMS, push, Zalo",
"default": true
},
{
"key": "treatment_photo",
"label": "Hiển thị ảnh điều trị",
"description": "trên app của bạn",
"default": true
}
]
}
},
"profile_update_info": {
"group": "customer_app",
"type": "json",
"value": {
"enabled": true,
"max_skip": 3,
"reshow_after_opens": 4,
"title": "Chúng em muốn hiểu thêm về bạn",
"body": "Thông tin dưới đây giúp chúng em cá nhân hoá trải nghiệm và phục vụ bạn tốt hơn",
"fields": [
{
"key": "birthday",
"label": "Ngày sinh",
"type": "date",
"hint": "Cập nhật để nhận voucher sinh nhật từ Diva",
"account_field": "birthday"
},
{
"key": "occupation",
"label": "Nghề nghiệp",
"type": "dropdown_master_data",
"hint": "Giúp đề xuất dịch vụ phù hợp",
"account_field": "occupation",
"options_source": "default_master_data(type='occupation', disabled=false)"
},
{
"key": "province",
"label": "Tỉnh/Thành phố",
"type": "dropdown_province",
"hint": "Giúp gửi ưu đãi đúng khu vực",
"account_field": "account_address.province_code"
}
]
}
}
}'::jsonb
WHERE id = 1;Down migration:
sql
-- Migration: {timestamp}_seed_consent_config_app_setting/down.sql
UPDATE public.app_setting
SET app_settings = app_settings - 'consent_config' - 'profile_update_info'
WHERE id = 1;4.4 ERD (text)
┌──────────────────────┐ ┌──────────────────────────┐
│ account │ │ customer_consent │
│──────────────────────│ │──────────────────────────│
│ id TEXT PK │◄──────│ account_id TEXT FK (UQ) │
│ birthday DATE │ │ id UUID PK │
│ occupation TEXT │ │ branch_id UUID FK │
│ display_name TEXT │ │ consent_data JSONB │
│ avatar_url TEXT │ │ consent_version INT │
│ ... │ │ update_info_skip_count INT│
└──────────────────────┘ │ update_info_completed BOOL│
│ │ app_open_count INT │
│ 1:N │ accepted_at TIMESTAMPTZ │
▼ │ created_at TIMESTAMPTZ │
┌──────────────────────┐ │ updated_at TIMESTAMPTZ │
│ account_address │ └──────────────────────────┘
│──────────────────────│ │
│ id UUID PK │ │ N:1
│ account_id TEXT FK │ ▼
│ province_code TEXT │ ┌──────────────────────────┐
│ province_name TEXT │ │ branch │
│ primary_contact BOOL │ │──────────────────────────│
│ ... │ │ id UUID PK │
└──────────────────────┘ │ name TEXT │
└──────────────────────────┘
┌──────────────────────┐
│ app_setting │
│──────────────────────│
│ id SMALLINT PK │
│ app_settings JSON │ ← consent_config + profile_update_info
│ ... │
└──────────────────────┘C5) API + Hasura
5.1 Hasura Metadata — public_customer_consent.yaml
File:
/diva-backend/services/controller/metadata/databases/default/tables/public_customer_consent.yaml
yaml
table:
name: customer_consent
schema: public
object_relationships:
- name: account
using:
foreign_key_constraint_on: account_id
- name: branch
using:
manual_configuration:
column_mapping:
branch_id: id
insertion_order: null
remote_table:
name: branch
schema: public
insert_permissions:
- role: customer
permission:
check:
account_id:
_eq: X-Hasura-User-Id
set:
account_id: x-hasura-User-Id
columns:
- branch_id
- consent_data
- consent_version
- update_info_skip_count
- update_info_completed
- app_open_count
- accepted_at
- role: user
permission:
check: {}
columns:
- account_id
- branch_id
- consent_data
- consent_version
- update_info_skip_count
- update_info_completed
- app_open_count
- accepted_at
select_permissions:
- role: customer
permission:
columns:
- id
- account_id
- consent_data
- consent_version
- update_info_skip_count
- update_info_completed
- app_open_count
- accepted_at
- created_at
- updated_at
filter:
account_id:
_eq: X-Hasura-User-Id
- role: user
permission:
columns:
- id
- account_id
- branch_id
- consent_data
- consent_version
- update_info_skip_count
- update_info_completed
- app_open_count
- accepted_at
- created_at
- updated_at
filter: {}
allow_aggregations: true
update_permissions:
- role: customer
permission:
columns:
- consent_data
- consent_version
- update_info_skip_count
- update_info_completed
- app_open_count
- accepted_at
- updated_at
filter:
account_id:
_eq: X-Hasura-User-Id
check: null
- role: user
permission:
columns:
- consent_data
- consent_version
- update_info_skip_count
- update_info_completed
- app_open_count
- accepted_at
- updated_at
filter: {}
check: nullGhi chú: Cần thêm table này vào file
tables.yaml:yaml- "!include public_customer_consent.yaml"
5.2 Hasura Metadata — Sửa public_account.yaml (customer update_permissions)
Phát hiện từ codebase discovery: Role
customerhiện tại chỉ được updateis_downloaded,customer_source. Cần thêmbirthday,occupationđể Flutter app (role customer) có thể update trực tiếp.
yaml
# SỬA trong public_account.yaml — update_permissions role customer
update_permissions:
- role: customer
permission:
columns:
- is_downloaded
- customer_source
- birthday # THÊM MỚI
- occupation # THÊM MỚI
filter:
id:
_eq: X-Hasura-User-Id
check: null
set:
updated_by: x-hasura-user-idLưu ý quan trọng: Thêm filter
id: { _eq: X-Hasura-User-Id }cho customer update để đảm bảo khách chỉ update record của chính mình. Hiện tại filter customer update là{}(không filter) — đây là security gap cần review với Tech Lead.
5.3 GraphQL Queries/Mutations
Q1: GetCustomerConsent (Flutter — sau auth success)
graphql
query GetCustomerConsent {
customer_consent {
id
consent_data
consent_version
update_info_skip_count
update_info_completed
app_open_count
}
account_by_pk(id: "self") {
birthday
occupation
addresses(
where: { primary_contact: { _eq: true } }
limit: 1
) {
province_code
province_name
}
}
}Ghi chú: Role customer có filter account_id = X-Hasura-User-Id nên customer_consent chỉ trả về record của chính khách đó. account_by_pk dùng user id từ session.
M1: UpsertCustomerConsent (Flutter — bấm "Đồng ý & Tiếp tục")
graphql
mutation UpsertCustomerConsent(
$branchId: uuid!
$consentData: jsonb!
$consentVersion: Int!
) {
insert_customer_consent_one(
object: {
branch_id: $branchId
consent_data: $consentData
consent_version: $consentVersion
accepted_at: "now()"
}
on_conflict: {
constraint: idx_customer_consent_account
update_columns: [consent_data, consent_version, accepted_at, updated_at]
}
) {
id
consent_version
}
}Ghi chú: account_id được auto-set bởi Hasura permission (set: account_id: x-hasura-User-Id).
M2: UpdateProfile (Flutter — bấm "Cập nhật" trên popup)
graphql
mutation UpdateProfile(
$accountId: String!
$birthday: date
$occupation: String
$provinceCode: String
$provinceName: String
) {
update_account_by_pk(
pk_columns: { id: $accountId }
_set: {
birthday: $birthday
occupation: $occupation
}
) {
id
birthday
occupation
}
insert_account_address_one(
object: {
province_code: $provinceCode
province_name: $provinceName
primary_contact: true
}
on_conflict: {
constraint: account_address_account_id_primary_contact_key
update_columns: [province_code, province_name]
}
) {
id
province_code
province_name
}
update_customer_consent(
where: {}
_set: {
update_info_completed: true
updated_at: "now()"
}
) {
affected_rows
}
}Ghi chú: Mutation này gọi dưới role customer. Hasura RLS đảm bảo chỉ update record của chính khách đó. Chỉ truyền giá trị cho fields mà khách thực sự điền — fields null sẽ không bị ghi đè.
M3: SkipProfileUpdate (Flutter — bấm "Bỏ qua" hoặc X)
graphql
mutation SkipProfileUpdate {
update_customer_consent(
where: {}
_inc: { update_info_skip_count: 1 }
_set: { updated_at: "now()" }
) {
returning {
update_info_skip_count
}
}
}M4: IncrementAppOpen (Flutter — mỗi lần auth success)
graphql
mutation IncrementAppOpen {
update_customer_consent(
where: {}
_inc: { app_open_count: 1 }
_set: { updated_at: "now()" }
) {
affected_rows
}
}Q2: ConsentStats (Admin web — tab Consent)
graphql
query ConsentStats {
total: account_aggregate(
where: { role: { _eq: "customer" } }
) {
aggregate { count }
}
consented: customer_consent_aggregate {
aggregate { count }
}
has_birthday: account_aggregate(
where: {
birthday: { _is_null: false }
role: { _eq: "customer" }
}
) {
aggregate { count }
}
has_occupation: account_aggregate(
where: {
occupation: { _is_null: false }
role: { _eq: "customer" }
}
) {
aggregate { count }
}
has_province: account_address_aggregate(
where: {
province_code: { _is_null: false }
primary_contact: { _eq: true }
}
) {
aggregate { count }
}
}M5: UpsertAppSettings (Admin web — lưu config)
Dùng mutation
UpsertAppSettingsđã có sẵn trong codebase (diva-admin/src/modules/settings/graphql/setting.graphql). Chỉ cần updateapp_settingsJSON với keysconsent_configvàprofile_update_info.
5.4 Error Contract
| Mã lỗi | Scenario | HTTP | Response | Xử lý phía client |
|---|---|---|---|---|
constraint-violation | Upsert conflict lỗi | 400 | {"message": "Uniqueness violation..."} | Retry với on_conflict |
permission-denied | Customer truy cập record người khác | 403 | {"message": "permission denied"} | Redirect to login |
not-found | account_by_pk không tồn tại | 200 | { "account_by_pk": null } | Skip consent flow, vào Dashboard |
validation-error | consent_data không phải JSONB | 400 | {"message": "invalid input syntax"} | Hiện toast lỗi, cho nhập lại |
| Network error | Mất kết nối | N/A | Timeout | Hiện toast "Không thể kết nối", cho retry |
C6) Frontend Components
6.1 Flutter — Customer App
File structure
diva-flutter/customer/lib/presentation/modules/consent/
├── consent_route.dart
├── consent_screen/
│ ├── bloc/
│ │ ├── consent_bloc.dart
│ │ ├── consent_event.dart
│ │ └── consent_state.dart
│ ├── repository/
│ │ ├── consent_repository.dart (abstract)
│ │ └── consent_repository.impl.dart (implementation)
│ └── views/
│ ├── consent_screen.dart
│ └── consent_screen.action.dart
└── profile_update/
├── bloc/
│ ├── profile_update_bloc.dart
│ ├── profile_update_event.dart
│ └── profile_update_state.dart
├── repository/
│ ├── profile_update_repository.dart
│ └── profile_update_repository.impl.dart
└── views/
├── profile_update_dialog.dart
└── profile_update_dialog.action.dartFiles cần sửa (existing)
| File | Thay đổi |
|---|---|
diva-flutter/core/lib/data/models/server_config.dart | Thêm ConsentConfigValue, ProfileUpdateInfoValue vào ServerToAppSetting |
diva-flutter/core/lib/data/models/server_config.g.dart | Auto-gen bởi build_runner |
diva-flutter/customer/lib/presentation/modules/welcome/splash/bloc/splash_bloc.dart | Thêm load consent data song song trong Future.wait |
diva-flutter/customer/lib/presentation/route/route_list.dart | Thêm RouteList.consent constant |
diva-flutter/customer/lib/presentation/route/route.dart | Register consent route |
diva-flutter/customer/lib/di/di.config.dart | Auto-gen bởi injectable |
Core logic — ConsentBloc pseudocode
dart
// consent_bloc.dart
class ConsentBloc extends AppBlocBase<ConsentEvent, ConsentState> {
final ConsentRepository _repo;
final ConfigRepository _configRepo;
Future<void> checkConsent(event, emitter) async {
final consentConfig = _configRepo.currentSetting?.consentConfig?.value;
// Không có config → skip
if (consentConfig == null) {
emitter(ConsentSkipState());
return;
}
final result = await _repo.getCustomerConsent();
final consent = result.customerConsent;
final account = result.account;
// Bước 1: Check consent version
if (consent == null || consent.consentVersion < consentConfig.version) {
emitter(ConsentRequiredState(config: consentConfig));
return;
}
// Bước 2: Check profile update
final profileConfig = _configRepo.currentSetting?.profileUpdateInfo?.value;
if (profileConfig?.enabled != true) {
emitter(ConsentCompleteState());
return;
}
final allFieldsFilled = account.birthday != null
&& account.occupation != null
&& (account.addresses?.firstOrNull?.provinceCode != null);
if (allFieldsFilled || consent.updateInfoCompleted) {
emitter(ConsentCompleteState());
return;
}
if (consent.updateInfoSkipCount >= (profileConfig.maxSkip ?? 3)) {
emitter(ConsentCompleteState());
return;
}
final reshowAfter = profileConfig.reshowAfterOpens ?? 4;
if (consent.appOpenCount < consent.updateInfoSkipCount * reshowAfter) {
emitter(ConsentCompleteState());
return;
}
// Hiện popup — chỉ hiện fields chưa có
emitter(ProfileUpdateRequiredState(
config: profileConfig,
missingBirthday: account.birthday == null,
missingOccupation: account.occupation == null,
missingProvince: account.addresses?.firstOrNull?.provinceCode == null,
));
}
Future<void> submitConsent(event, emitter) async {
await _repo.upsertConsent(
branchId: event.branchId,
consentData: event.consentData, // {"marketing": true, "treatment_photo": false}
consentVersion: event.version,
);
emitter(ConsentSubmittedState());
// Tiếp tục check profile update...
}
Future<void> submitProfileUpdate(event, emitter) async {
await _repo.updateProfile(
birthday: event.birthday,
occupation: event.occupation,
provinceCode: event.provinceCode,
provinceName: event.provinceName,
);
emitter(ProfileUpdateCompleteState());
}
Future<void> skipProfileUpdate(event, emitter) async {
await _repo.skipProfileUpdate();
emitter(ProfileUpdateSkippedState());
}
}Core logic — Auth flow integration
dart
// Trong splash_bloc.dart hoặc auth flow:
// SAU auth success, TRƯỚC navigate to Dashboard
// 1. Increment app_open_count (fire-and-forget, chỉ khi đã có consent record)
if (hasConsentRecord) {
_consentRepo.incrementAppOpen(); // không await
}
// 2. Navigate to ConsentScreen (fullscreen route)
// ConsentScreen tự check và quyết định:
// - Hiện consent form
// - Hiện profile update popup
// - Hoặc skip thẳng vào Dashboard
Navigator.pushReplacementNamed(context, RouteList.consent);ServerToAppSetting extension
dart
// Thêm vào server_config.dart
@JsonSerializable()
class ConsentConfig {
@JsonKey(name: 'version')
final int? version;
@JsonKey(name: 'title')
final String? title;
@JsonKey(name: 'body')
final String? body;
@JsonKey(name: 'items')
final List<ConsentItem>? items;
ConsentConfig({this.version, this.title, this.body, this.items});
factory ConsentConfig.fromJson(Map<String, dynamic> json) =>
_$ConsentConfigFromJson(json);
Map<String, dynamic> toJson() => _$ConsentConfigToJson(this);
}
@JsonSerializable()
class ConsentItem {
@JsonKey(name: 'key')
final String? key;
@JsonKey(name: 'label')
final String? label;
@JsonKey(name: 'description')
final String? description;
@JsonKey(name: 'default', defaultValue: true)
final bool? defaultValue;
ConsentItem({this.key, this.label, this.description, this.defaultValue});
factory ConsentItem.fromJson(Map<String, dynamic> json) =>
_$ConsentItemFromJson(json);
Map<String, dynamic> toJson() => _$ConsentItemToJson(this);
}
@JsonSerializable()
class ProfileUpdateField {
@JsonKey(name: 'key')
final String? key;
@JsonKey(name: 'label')
final String? label;
@JsonKey(name: 'type')
final String? type; // "date" | "dropdown" | "dropdown_province"
@JsonKey(name: 'hint')
final String? hint;
@JsonKey(name: 'account_field')
final String? accountField;
@JsonKey(name: 'options')
final List<DropdownOption>? options;
ProfileUpdateField({
this.key, this.label, this.type, this.hint,
this.accountField, this.options,
});
factory ProfileUpdateField.fromJson(Map<String, dynamic> json) =>
_$ProfileUpdateFieldFromJson(json);
Map<String, dynamic> toJson() => _$ProfileUpdateFieldToJson(this);
}
@JsonSerializable()
class DropdownOption {
@JsonKey(name: 'value')
final String? value;
@JsonKey(name: 'label')
final String? label;
DropdownOption({this.value, this.label});
factory DropdownOption.fromJson(Map<String, dynamic> json) =>
_$DropdownOptionFromJson(json);
Map<String, dynamic> toJson() => _$DropdownOptionToJson(this);
}
@JsonSerializable()
class ProfileUpdateConfig {
@JsonKey(name: 'enabled')
final bool? enabled;
@JsonKey(name: 'max_skip')
final int? maxSkip;
@JsonKey(name: 'reshow_after_opens')
final int? reshowAfterOpens;
@JsonKey(name: 'title')
final String? title;
@JsonKey(name: 'body')
final String? body;
@JsonKey(name: 'fields')
final List<ProfileUpdateField>? fields;
ProfileUpdateConfig({
this.enabled, this.maxSkip, this.reshowAfterOpens,
this.title, this.body, this.fields,
});
factory ProfileUpdateConfig.fromJson(Map<String, dynamic> json) =>
_$ProfileUpdateConfigFromJson(json);
Map<String, dynamic> toJson() => _$ProfileUpdateConfigToJson(this);
}
// ===== Thêm vào class ServerToAppSetting =====
@JsonKey(name: 'consent_config')
ConsentConfigValue? consentConfig; // follows SettingValue pattern
@JsonKey(name: 'profile_update_info')
ProfileUpdateInfoValue? profileUpdateInfo;6.2 Admin Web — Vue 3
File structure
diva-admin/src/modules/settings/components/customer-and-staff-app-setting/
├── CustomerAndStaffAppEditor.tsx ← SỬA: thêm tab "Consent"
└── consent/
├── ConsentConfigTab.tsx ← MỚI: tab chính (compose stats + 2 forms)
├── ConsentStatsPanel.tsx ← MỚI: panel thống kê 5 metrics
├── ConsentConfigForm.tsx ← MỚI: form sửa consent_config
└── ProfileUpdateConfigForm.tsx ← MỚI: form sửa profile_update_infoGraphQL file cần thêm
File:
diva-admin/src/modules/settings/graphql/consent.graphql
graphql
query ConsentStats {
total: account_aggregate(
where: { role: { _eq: "customer" } }
) { aggregate { count } }
consented: customer_consent_aggregate { aggregate { count } }
has_birthday: account_aggregate(
where: { birthday: { _is_null: false }, role: { _eq: "customer" } }
) { aggregate { count } }
has_occupation: account_aggregate(
where: { occupation: { _is_null: false }, role: { _eq: "customer" } }
) { aggregate { count } }
has_province: account_address_aggregate(
where: { province_code: { _is_null: false }, primary_contact: { _eq: true } }
) { aggregate { count } }
}Core logic — ConsentConfigTab pseudocode
typescript
// ConsentConfigTab.tsx
// 1. Load stats từ ConsentStats query (mỗi lần mở tab, không cache)
// 2. Load app_settings.consent_config + profile_update_info
// từ GetGlobalSettingsAndMetadata (đã có sẵn)
// 3. Render:
// - ConsentStatsPanel (read-only)
// - ConsentConfigForm (title, body, items CRUD)
// - Nút "Lưu" → update app_settings giữ version
// - Nút "Lưu & Tăng version" → dialog xác nhận → update + version++
// - ProfileUpdateConfigForm (toggle, max_skip, reshow_after_opens, link → /s/master-data/occupations)
// - Nút "Lưu cài đặt" → update app_settings.profile_update_info
// 4. Save logic (reuse UpsertAppSettings mutation đã có):
const handleSaveConsent = (incrementVersion: boolean) => {
const newConfig = structuredClone(currentConsentConfig);
if (incrementVersion) {
// Hiện confirm dialog trước khi thực hiện
// "Tất cả khách hàng sẽ phải đồng ý lại consent khi mở app"
newConfig.value.version += 1;
}
const updatedSettings = {
...currentAppSettings,
consent_config: newConfig,
};
updateSetting(updatedSettings); // UpsertAppSettings mutation
};
const handleSaveProfileUpdate = () => {
const updatedSettings = {
...currentAppSettings,
profile_update_info: editedProfileUpdateInfo,
};
updateSetting(updatedSettings);
};C7) Migration Strategy
Timestamps và thứ tự
| # | Migration | Timestamp | Mô tả |
|---|---|---|---|
| 1 | create_table_customer_consent | 1774300000000 | Tạo bảng + indexes + comments |
| 2 | seed_consent_config_app_setting | 1774300000001 | Thêm consent_config + profile_update_info vào app_setting |
Lưu ý: Timestamps phải >
1774239369502(latest hiện tại). Giá trị1774300000000và1774300000001là ví dụ — dev cần generate timestamp thực tế khi tạo migration.
Deploy order
1. BE: Apply migration #1 (create table) ← bắt buộc trước
2. BE: Apply migration #2 (seed data) ← bắt buộc trước
3. BE: Apply Hasura metadata (track table + ← bắt buộc trước
sửa public_account.yaml permissions)
4. FE Web: Deploy admin tab ← song song với Mobile
5. Mobile: Deploy Flutter update ← song song với FE WebRollback plan
| Bước | Hành động | Ảnh hưởng |
|---|---|---|
| Rollback migration #2 | Xoá 2 keys khỏi app_settings JSON | Flutter app sẽ skip consent flow (null check) |
| Rollback migration #1 | DROP TABLE customer_consent | Mất data consent nhưng không ảnh hưởng hệ thống cũ |
| Rollback metadata | Untrack table customer_consent + revert account permissions | Flutter app sẽ skip consent flow |
C8) Security
8.1 Permission Matrix
| Role | Table | SELECT | INSERT | UPDATE | DELETE | Filter |
|---|---|---|---|---|---|---|
customer | customer_consent | Có | Có | Có | Không | account_id = X-Hasura-User-Id |
customer | account | Có | Không | Có (limited + birthday, occupation) | Không | id = X-Hasura-User-Id (cần thêm) |
customer | account_address | Có | Có | Có | Có | account_id = X-Hasura-User-Id |
customer | app_setting | Có | Không | Không | Không | {} |
user (admin) | customer_consent | Có (aggregate) | Có | Có | Không | {} (full access) |
user (admin) | account | Có (aggregate) | Không | Có | Có | {} |
user (admin) | app_setting | Có | Có | Có | Không | {} |
anonymous | app_setting | Có | Không | Không | Không | {} |
8.2 Hasura RLS Rules
| Rule | Mô tả |
|---|---|
customer_consent INSERT | check: { account_id: { _eq: X-Hasura-User-Id } } + set: { account_id: x-hasura-User-Id } — auto-set, khách không thể insert record cho người khác |
customer_consent SELECT/UPDATE | filter: { account_id: { _eq: X-Hasura-User-Id } } — chỉ thấy/sửa record của mình |
account UPDATE (customer) | Cần thêm filter id: { _eq: X-Hasura-User-Id } + thêm columns birthday, occupation |
account_address INSERT (customer) | Đã có set: { account_id: x-hasura-User-Id } — auto-set account_id |
8.3 Phát hiện bảo mật từ codebase discovery
QUAN TRỌNG: Hiện tại
public_account.yamlrolecustomercóupdate_permissions.filter: {}(không filter). Điều này có nghĩa customer có thể update bất kỳ account nào (dù chỉ giới hạn 2 columnsis_downloaded,customer_source). Khi thêmbirthday,occupationcần thêm filterid: { _eq: X-Hasura-User-Id }để đảm bảo an toàn. Cần review với Tech Lead trước khi apply.
8.4 Security Rules bổ sung
| Rule | Mô tả |
|---|---|
| JSONB injection | consent_data lưu JSONB — Hasura tự validate type. Frontend chỉ gửi object { key: boolean } |
| Rate limiting | IncrementAppOpen gọi mỗi lần mở app — volume thấp (1 request/session). Không cần rate limit đặc biệt |
| Data exposure | Customer role chỉ thấy record của mình. Admin role thấy all nhưng chỉ dùng cho stats (aggregate) |
| Version tampering | consent_version do client gửi nhưng luôn compare với server config. Client gửi version sai → record vẫn được lưu nhưng sẽ bị ask lại consent lần sau |
C9) NFR — Non-Functional Requirements
| # | Category | Metric | Target | Cách đo |
|---|---|---|---|---|
| NFR-001 | Performance | GetCustomerConsent query response | < 100ms (p95) | Hasura console > Monitoring |
| NFR-002 | Performance | UpsertCustomerConsent mutation | < 200ms (p95) | Hasura console |
| NFR-003 | Performance | ConsentStats aggregate (admin) | < 500ms (p95) | Hasura console. Chấp nhận được vì chỉ admin dùng, load 1 lần khi mở tab |
| NFR-004 | Performance | App startup thêm consent check | < 50ms thêm | So sánh startup time trước/sau |
| NFR-005 | Data volume | customer_consent records | ~50K records (= số khách dùng app) | Bảng nhỏ, 1 row/khách |
| NFR-006 | Payload | consent_config JSON | < 2KB | Khá nhỏ, load song song với app_settings hiện tại |
| NFR-007 | Availability | Consent flow không block app | Luôn đảm bảo | Nếu consent_config = null → skip, vào Dashboard |
| NFR-008 | Backward compat | App cũ không có consent logic | Không ảnh hưởng | consent_config là key mới trong JSON, app cũ ignore |
C10) Observability
Logging
| Event | Level | Data | Nơi log |
|---|---|---|---|
| Consent submitted | INFO | { account_id, consent_version, consent_data_keys } | Flutter analytics + Hasura event log |
| Profile updated | INFO | { account_id, fields_updated: ["birthday", "occupation", "province"] } | Flutter analytics |
| Profile update skipped | INFO | { account_id, skip_count } | Flutter analytics |
| App open counted | DEBUG | { account_id, new_count } | Flutter local log (không gửi server) |
| Consent config saved (admin) | INFO | { admin_id, version_changed: true/false, new_version } | Admin web console log |
| ConsentStats loaded | DEBUG | { total, consented, has_birthday, has_occupation, has_province } | Admin web console log |
Hasura Event Triggers
Không cần tạo event trigger mới cho
customer_consent. Toàn bộ logic phía client-side. Nếu sau này cần audit log chi tiết, thêm event triggercustomer_consent_insert_update→ webhookECOMMERCE_BASE_URL/events.
Firebase Analytics Events (Flutter)
| Event name | Parameters | Khi nào fire |
|---|---|---|
consent_shown | { version } | Khi hiện Consent Screen |
consent_accepted | { version, items: {...} } | Khi bấm "Đồng ý & Tiếp tục" |
profile_update_shown | { missing_fields: [...] } | Khi hiện popup đề xuất |
profile_update_completed | { fields_updated: [...] } | Khi bấm "Cập nhật" |
profile_update_skipped | { skip_count } | Khi bấm "Bỏ qua" hoặc X |
C11) Tasks
BE Team
| Task | Mô tả | Estimate | Dependency | Priority |
|---|---|---|---|---|
| BE-1 | Migration: tạo bảng customer_consent (up.sql + down.sql) | 0.5h | — | P0 |
| BE-2 | Migration: seed consent_config + profile_update_info vào app_setting | 0.5h | — | P0 |
| BE-3 | Hasura metadata: track customer_consent, tạo file public_customer_consent.yaml, thêm vào tables.yaml | 2h | BE-1 | P0 |
| BE-4 | Hasura metadata: sửa public_account.yaml — thêm birthday, occupation vào customer update_permissions + thêm RLS filter | 0.5h | — | P0 |
| BE-5 | Verify: test tất cả GraphQL queries/mutations (Q1, M1-M4, Q2) qua Hasura console | 1h | BE-1, BE-2, BE-3, BE-4 | P0 |
| Subtotal BE | 4.5h (~1 ngày) |
Mobile Team (Flutter)
| Task | Mô tả | Estimate | Dependency | Priority |
|---|---|---|---|---|
| MO-1 | Core model: extend ServerToAppSetting + tạo ConsentConfig, ProfileUpdateConfig, các sub-models. Chạy build_runner | 2h | — | P0 |
| MO-2 | Repository: tạo ConsentRepository (GraphQL queries Q1, mutations M1, M3, M4) | 2h | BE-5 | P0 |
| MO-3 | Repository: tạo ProfileUpdateRepository (GraphQL mutation M2) | 1h | BE-5 | P0 |
| MO-4 | BLoC: tạo ConsentBloc (check logic + submit consent + submit/skip profile update) | 3h | MO-1, MO-2 | P0 |
| MO-5 | UI: tạo ConsentScreen (fullscreen, dynamic content, checkboxes, webview links) | 4h | MO-4 | P0 |
| MO-6 | UI: tạo ProfileUpdateDialog (popup, date picker, dropdowns, conditional fields hiển thị theo data thiếu) | 4h | MO-3, MO-4 | P0 |
| MO-7 | Integration: sửa auth flow (splash_bloc / welcome route), thêm consent route, increment app_open | 3h | MO-5, MO-6 | P0 |
| MO-8 | Analytics: thêm Firebase events (consent_shown, accepted, profile_update_*) | 1h | MO-5, MO-6 | P1 |
| MO-9 | Testing: unit test BLoC logic + integration test flow | 2h | MO-7 | P1 |
| Subtotal Mobile | 22h (~4 ngày) |
FE Web Team (Admin)
| Task | Mô tả | Estimate | Dependency | Priority |
|---|---|---|---|---|
| FE-1 | GraphQL: thêm consent.graphql (ConsentStats query) + chạy codegen | 1h | BE-3 | P0 |
| FE-2 | Component: tạo ConsentStatsPanel (hiển thị 5 metrics + % với 1 decimal) | 2h | FE-1 | P0 |
| FE-3 | Component: tạo ConsentConfigForm (title, body, items CRUD, 2 nút Lưu) | 3h | FE-1 | P0 |
| FE-4 | Component: tạo ProfileUpdateConfigForm (toggle bật/tắt, max_skip, reshow_after_opens, link đến cấu hình nghề nghiệp tại /s/master-data/occupations) | 1.5h | FE-1 | P0 |
| FE-5 | Component: tạo ConsentConfigTab (compose stats + 2 forms) | 1h | FE-2, FE-3, FE-4 | P0 |
| FE-6 | Integration: thêm tab "Consent" vào CustomerAndStaffAppEditor.tsx | 1h | FE-5 | P0 |
| FE-7 | Dialog: confirm "Lưu & Tăng version" với warning text | 0.5h | FE-3 | P1 |
| FE-8 | Testing: manual test toàn bộ flow admin (config + stats) | 1h | FE-6, FE-7 | P1 |
| Subtotal FE Web | 11.5h (~2 ngày) |
Tổng Effort
| Team | Effort |
|---|---|
| BE | ~1 ngày |
| Mobile | ~4 ngày |
| FE Web | ~2 ngày |
| QA | ~1.5 ngày |
| Tổng | ~8.5 ngày (song song: BE 1d → Mobile + FE 4d → QA 1.5d) |
Dependency Graph
BE-1 (migration) ──→ BE-3 (metadata) ──→ BE-5 (verify)
BE-2 (seed data) ──────────────────────→ BE-5
BE-4 (account perm) ──────────────────→ BE-5
│
┌─────────────────────┤
▼ ▼
MO-2, MO-3 FE-1 (codegen)
│ │
▼ ▼
MO-4 (BLoC) FE-2, FE-3, FE-4
│ │
┌────┴────┐ ▼
▼ ▼ FE-5 (compose)
MO-5 (UI) MO-6 (UI) │
│ │ ▼
└────┬────┘ FE-6 (integrate)
▼ │
MO-7 (auth flow) FE-7 (dialog)
│ │
▼ ▼
MO-8 (analytics) FE-8 (test)
│
▼
MO-9 (test)C12) Traceability
FR → FE Component → BE Artifact → Test Case
| FR | FE Component | BE Artifact | Test Cases (ref QA plan) |
|---|---|---|---|
| FR-001 (Consent Screen) | ConsentScreen + ConsentBloc | customer_consent table, M1 (UpsertCustomerConsent), consent_config seed | TC-001 ~ TC-006 |
| FR-002 (Popup đề xuất) | ProfileUpdateDialog + ConsentBloc | M2 (UpdateProfile), profile_update_info seed | TC-007 ~ TC-012 |
| FR-003 (Retry logic) | ConsentBloc.checkConsent() | M3 (SkipProfileUpdate), M4 (IncrementAppOpen) | TC-013 ~ TC-016 |
| FR-004 (Auth flow) | splash_bloc.dart (sửa), consent_route.dart | Q1 (GetCustomerConsent) | TC-017 ~ TC-020 |
| FR-005 (App open count) | Auth flow integration (fire-and-forget M4) | M4 (IncrementAppOpen) | TC-021 ~ TC-022 |
| FR-006 (Admin config) | ConsentConfigForm, ProfileUpdateConfigForm, ConsentConfigTab | UpsertAppSettings (có sẵn), app_setting.app_settings JSON | TC-023 ~ TC-028 |
| FR-007 (Admin stats) | ConsentStatsPanel | Q2 (ConsentStats) | TC-029 ~ TC-031 |
DEC → Implementation Mapping
| DEC | Implementation |
|---|---|
| DEC-001 (Data collection là trọng tâm) | Consent screen là fullscreen nhưng KHÔNG enforce. Profile popup là trụ cột flow. Khách bỏ checkbox vẫn vào app |
| DEC-002 (Single-page consent) | ConsentScreen — 1 page duy nhất, render dynamic từ consent_config.items[]. Không multi-step |
| DEC-003 (3 fields) | ProfileUpdateDialog — 3 fields: date picker (birthday), dropdown (occupation), dropdown_province (province). Chỉ hiện fields chưa có |
| DEC-004 (Không GPS) | dropdown_province dùng danh sách tỉnh/thành từ localDataManager.provinces() đã load sẵn tại splash. Không thêm plugin GPS |
| DEC-005 (Retry có kiểm soát) | ConsentBloc.checkConsent() — logic app_open_count >= skip_count * reshow_after_opens, cap tại max_skip. Cả 2 giá trị config từ server |
| DEC-006 (Stats trên admin tab) | ConsentStatsPanel — Hasura aggregate query Q2, hiển thị trên đầu tab Consent. Query mỗi lần mở tab |
| DEC-007 (Không incentive) | Không có implementation — đề xuất cập nhật bằng hint text giải thích benefit, không tặng điểm/voucher |
| DEC-008 (Server-driven content) | consent_config + profile_update_info lưu trong app_setting.app_settings JSON. Admin sửa từ web qua UpsertAppSettings, app đọc từ server khi splash |
— End of Dev Spec —