Skip to content

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

VersionDateAuthorThay đổi
1.025/03/2026Dev TeamInitial
1.127/03/2026PO/BADEC-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

ModulePlatformVai trò
controller (Hasura)BackendMigration, metadata, seed data cho customer_consent
customer appFlutter mobileConsent screen, Profile update popup, auth flow integration
settings moduleAdmin web (Vue 3)Tab Consent config + thống kê
core libFlutter sharedExtend ServerToAppSetting model

Exclusions

Hạng mụcLý do
Go microservicesToà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 logicOut-of-scope (DEC-007)
GPS auto-detectOut-of-scope (DEC-004)
i18n / đa ngôn ngữDiva chỉ hỗ trợ tiếng Việt
Staff app / Admin app consentChỉ áp dụng cho customer app

C2) Impact Summary

#ComponentĐã cóCần thêm/sửaRisk
1customer_consent tableChưa cóTạo mới (migration + Hasura tracking + permissions)Low — bảng mới, không ảnh hưởng bảng cũ
2app_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ũ
3account tableĐã có (birthday, occupation)READ ONLY — không sửa schemaNone
4account_address tableĐã có (province_code, province_name)READ ONLY — không sửa schemaNone
5ServerToAppSetting (Dart model)Đã cóThêm 2 fields: consentConfig, profileUpdateInfoLow — backward compatible
6SplashBloc (Flutter customer)Đã cóKhông sửa trực tiếp. Logic consent check nằm ở auth flow mớiLow
7Auth flow navigationĐã cóThêm consent + update_info check sau login/signup successMed — core flow, cần test kỹ
8CustomerAndStaffAppEditor (Admin)Đã cóThêm tab "Consent"Low — additive
9Hasura metadata public_account.yamlĐã cóCần sửa: thêm birthday, occupation vào customer update_permissionsMed — thay đổi permission
10Hasura metadata public_account_address.yamlĐã cóREAD ONLY — customer role đã có insert/update province_codeNone

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)

TableDatabaseColumns sử dụngGhi chú
accountdefaultid (TEXT PK), birthday (DATE), occupation (TEXT), display_name, avatar_urlCustomer role: select 40+ cols, update limited
account_addressdefaultid (UUID PK), account_id (TEXT FK), province_code, province_name, primary_contactCustomer role: RLS account_id = X-Hasura-User-Id
app_settingdefaultid (SMALLINT PK), app_settings (JSON)All roles: select. User role: update
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;

Thêm 2 keys vào JSON field app_settings của record app_setting hiệ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

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: null

Ghi 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 customer hiện tại chỉ được update is_downloaded, customer_source. Cần thêm birthday, 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-id

Lư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
  }
}
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 update app_settings JSON với keys consent_configprofile_update_info.

5.4 Error Contract

Mã lỗiScenarioHTTPResponseXử lý phía client
constraint-violationUpsert conflict lỗi400{"message": "Uniqueness violation..."}Retry với on_conflict
permission-deniedCustomer truy cập record người khác403{"message": "permission denied"}Redirect to login
not-foundaccount_by_pk không tồn tại200{ "account_by_pk": null }Skip consent flow, vào Dashboard
validation-errorconsent_data không phải JSONB400{"message": "invalid input syntax"}Hiện toast lỗi, cho nhập lại
Network errorMất kết nốiN/ATimeoutHiệ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.dart

Files cần sửa (existing)

FileThay đổi
diva-flutter/core/lib/data/models/server_config.dartThêm ConsentConfigValue, ProfileUpdateInfoValue vào ServerToAppSetting
diva-flutter/core/lib/data/models/server_config.g.dartAuto-gen bởi build_runner
diva-flutter/customer/lib/presentation/modules/welcome/splash/bloc/splash_bloc.dartThêm load consent data song song trong Future.wait
diva-flutter/customer/lib/presentation/route/route_list.dartThêm RouteList.consent constant
diva-flutter/customer/lib/presentation/route/route.dartRegister consent route
diva-flutter/customer/lib/di/di.config.dartAuto-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_info

GraphQL 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ự

#MigrationTimestampMô tả
1create_table_customer_consent1774300000000Tạo bảng + indexes + comments
2seed_consent_config_app_setting1774300000001Thêm consent_config + profile_update_info vào app_setting

Lưu ý: Timestamps phải > 1774239369502 (latest hiện tại). Giá trị 17743000000001774300000001 là 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 Web

Rollback plan

BướcHành độngẢnh hưởng
Rollback migration #2Xoá 2 keys khỏi app_settings JSONFlutter app sẽ skip consent flow (null check)
Rollback migration #1DROP TABLE customer_consentMất data consent nhưng không ảnh hưởng hệ thống cũ
Rollback metadataUntrack table customer_consent + revert account permissionsFlutter app sẽ skip consent flow

C8) Security

8.1 Permission Matrix

RoleTableSELECTINSERTUPDATEDELETEFilter
customercustomer_consentKhôngaccount_id = X-Hasura-User-Id
customeraccountKhôngCó (limited + birthday, occupation)Khôngid = X-Hasura-User-Id (cần thêm)
customeraccount_addressaccount_id = X-Hasura-User-Id
customerapp_settingKhôngKhôngKhông{}
user (admin)customer_consentCó (aggregate)Không{} (full access)
user (admin)accountCó (aggregate)Không{}
user (admin)app_settingKhông{}
anonymousapp_settingKhôngKhôngKhông{}

8.2 Hasura RLS Rules

RuleMô tả
customer_consent INSERTcheck: { 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/UPDATEfilter: { 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.yaml role customerupdate_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 columns is_downloaded, customer_source). Khi thêm birthday, occupation cần thêm filter id: { _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

RuleMô tả
JSONB injectionconsent_data lưu JSONB — Hasura tự validate type. Frontend chỉ gửi object { key: boolean }
Rate limitingIncrementAppOpen gọi mỗi lần mở app — volume thấp (1 request/session). Không cần rate limit đặc biệt
Data exposureCustomer role chỉ thấy record của mình. Admin role thấy all nhưng chỉ dùng cho stats (aggregate)
Version tamperingconsent_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

#CategoryMetricTargetCách đo
NFR-001PerformanceGetCustomerConsent query response< 100ms (p95)Hasura console > Monitoring
NFR-002PerformanceUpsertCustomerConsent mutation< 200ms (p95)Hasura console
NFR-003PerformanceConsentStats aggregate (admin)< 500ms (p95)Hasura console. Chấp nhận được vì chỉ admin dùng, load 1 lần khi mở tab
NFR-004PerformanceApp startup thêm consent check< 50ms thêmSo sánh startup time trước/sau
NFR-005Data volumecustomer_consent records~50K records (= số khách dùng app)Bảng nhỏ, 1 row/khách
NFR-006Payloadconsent_config JSON< 2KBKhá nhỏ, load song song với app_settings hiện tại
NFR-007AvailabilityConsent flow không block appLuôn đảm bảoNếu consent_config = null → skip, vào Dashboard
NFR-008Backward compatApp cũ không có consent logicKhông ảnh hưởngconsent_config là key mới trong JSON, app cũ ignore

C10) Observability

Logging

EventLevelDataNơi log
Consent submittedINFO{ account_id, consent_version, consent_data_keys }Flutter analytics + Hasura event log
Profile updatedINFO{ account_id, fields_updated: ["birthday", "occupation", "province"] }Flutter analytics
Profile update skippedINFO{ account_id, skip_count }Flutter analytics
App open countedDEBUG{ 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 loadedDEBUG{ 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 trigger customer_consent_insert_update → webhook ECOMMERCE_BASE_URL/events.

Firebase Analytics Events (Flutter)

Event nameParametersKhi 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

TaskMô tảEstimateDependencyPriority
BE-1Migration: tạo bảng customer_consent (up.sql + down.sql)0.5hP0
BE-2Migration: seed consent_config + profile_update_info vào app_setting0.5hP0
BE-3Hasura metadata: track customer_consent, tạo file public_customer_consent.yaml, thêm vào tables.yaml2hBE-1P0
BE-4Hasura metadata: sửa public_account.yaml — thêm birthday, occupation vào customer update_permissions + thêm RLS filter0.5hP0
BE-5Verify: test tất cả GraphQL queries/mutations (Q1, M1-M4, Q2) qua Hasura console1hBE-1, BE-2, BE-3, BE-4P0
Subtotal BE4.5h (~1 ngày)

Mobile Team (Flutter)

TaskMô tảEstimateDependencyPriority
MO-1Core model: extend ServerToAppSetting + tạo ConsentConfig, ProfileUpdateConfig, các sub-models. Chạy build_runner2hP0
MO-2Repository: tạo ConsentRepository (GraphQL queries Q1, mutations M1, M3, M4)2hBE-5P0
MO-3Repository: tạo ProfileUpdateRepository (GraphQL mutation M2)1hBE-5P0
MO-4BLoC: tạo ConsentBloc (check logic + submit consent + submit/skip profile update)3hMO-1, MO-2P0
MO-5UI: tạo ConsentScreen (fullscreen, dynamic content, checkboxes, webview links)4hMO-4P0
MO-6UI: tạo ProfileUpdateDialog (popup, date picker, dropdowns, conditional fields hiển thị theo data thiếu)4hMO-3, MO-4P0
MO-7Integration: sửa auth flow (splash_bloc / welcome route), thêm consent route, increment app_open3hMO-5, MO-6P0
MO-8Analytics: thêm Firebase events (consent_shown, accepted, profile_update_*)1hMO-5, MO-6P1
MO-9Testing: unit test BLoC logic + integration test flow2hMO-7P1
Subtotal Mobile22h (~4 ngày)

FE Web Team (Admin)

TaskMô tảEstimateDependencyPriority
FE-1GraphQL: thêm consent.graphql (ConsentStats query) + chạy codegen1hBE-3P0
FE-2Component: tạo ConsentStatsPanel (hiển thị 5 metrics + % với 1 decimal)2hFE-1P0
FE-3Component: tạo ConsentConfigForm (title, body, items CRUD, 2 nút Lưu)3hFE-1P0
FE-4Component: 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.5hFE-1P0
FE-5Component: tạo ConsentConfigTab (compose stats + 2 forms)1hFE-2, FE-3, FE-4P0
FE-6Integration: thêm tab "Consent" vào CustomerAndStaffAppEditor.tsx1hFE-5P0
FE-7Dialog: confirm "Lưu & Tăng version" với warning text0.5hFE-3P1
FE-8Testing: manual test toàn bộ flow admin (config + stats)1hFE-6, FE-7P1
Subtotal FE Web11.5h (~2 ngày)

Tổng Effort

TeamEffort
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

FRFE ComponentBE ArtifactTest Cases (ref QA plan)
FR-001 (Consent Screen)ConsentScreen + ConsentBloccustomer_consent table, M1 (UpsertCustomerConsent), consent_config seedTC-001 ~ TC-006
FR-002 (Popup đề xuất)ProfileUpdateDialog + ConsentBlocM2 (UpdateProfile), profile_update_info seedTC-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.dartQ1 (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, ConsentConfigTabUpsertAppSettings (có sẵn), app_setting.app_settings JSONTC-023 ~ TC-028
FR-007 (Admin stats)ConsentStatsPanelQ2 (ConsentStats)TC-029 ~ TC-031

DEC → Implementation Mapping

DECImplementation
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 —