Skip to content

Đặc tả triển khai (Dev Spec) — Tích hợp Pancake CRM (Pancake CRM Integration)

Phiên bản: 1.0 Ngày: 15/05/2026 Tác giả: PO/BA + AI Tech Lead Profile: L (Large feature, ~37 dev-days, 5.5-6 tuần calendar) Phạm vi: MVP-1 — Inbound Pancake → Diva, 4-layer recovery, Settings UI 4 tabs, DLQ replay, Pilot 4 tuần


Tài liệu đầu vào chuẩn

Tài liệuVai tròTham chiếu canonical
SOURCE_OF_TRUTH.mdSolution Lock 25 DEC + Fact code Phase 3§3 Fact code, §4 Solution Lock
EVIDENCE_PACK.mdFile path + line number Phase 3 ANALYZE§4 Fact DB, §5 Reuse map
prd.mdFR-001..018, LIFECYCLE-001/002, A10 FORMULA-001..006, A9 GlossaryA5, A10, A12
ui-spec.mdSCR-01..06 wireframes, B5 Permission Matrix, B9 Tooltip(đồng cấp)

Quy tắc đọc: Dev Spec là implementation delta từ PRD/SOT. Mọi business rationale → đọc PRD. Mọi UI design → đọc UI Spec. Mọi test case detail → đọc QA Test Plan. Formula Single-Source: PRD A10 = canonical. C3 chỉ SQL implementation + index + precision delta. Trích dẫn EVIDENCE: File path luôn ghi kèm line number nếu có (vd services/crm-api/scheduler/distribute_ticket.go:157).


C1) Phạm vi (Scope)

Modules ảnh hưởng

LayerModule / serviceLoại tác độngFiles chính
Backend webhookservices/webhook🆕 Add handler + 🔧 Extend routehandler/pancake.go (NEW), handler/route.go:35-48 (ADD route), server/main.go
Backend CRMservices/crm-api🆕 Add event handler + 6 cron + 🔧 Refactor distribute helperevent/event_pancake_process_record.go (NEW), event/event.go (ADD map entry), scheduler/pancake_*.go × 6 (NEW), scheduler/distribute_ticket.go:157 (refactor public)
Backend notificationservices/notification-v2-api🔧 Seed 3 template + reuse sendNotifications actionaction/action.go (no change), migration seed
Shared packagepkg/pancake/🆕 New package (REST client + phone normalize + advisory lock helper)client.go, phone_normalize.go, advisory_lock.go (NEW)
Shared storepkg/store/🆕 Build 4 Go struct + 1 customer_consent structpancake_webhook_event.go, pancake_connection.go, pancake_source_routing.go, pancake_outage_state.go, customer_consent.go (NEW)
Frontend admindiva-admin/src/modules/settings/pages/🆕 1 container + 4 child pagePancakeCrmSetting.tsx, pancake/PancakeConnection.tsx, pancake/PancakeSourceRouting.tsx, pancake/PancakeAuditDlq.tsx, pancake/PancakeHealth.tsx
Frontend admindiva-admin/src/modules/settings/graphql/🆕 GraphQL pancake operationspancake.graphql (NEW)
Frontend admindiva-admin/src/modules/crm/🔧 3-file delta cho slot 9 ticket sourcetypes.ts:146-164 (ADD const), i18n/vi.ts:130-137 (ADD key), pages/Tickets.tsx:128-149 (ADD sourceDescriptions)
Frontend routingdiva-admin/src/router/routes.ts🔧 ADD 5 routes + meta.permission(5 entries mới)
Frontend menudiva-admin/src/layouts/...🔧 ADD menu entry Settings → "Tích hợp Pancake"(1 entry)
Frontend permissiondiva-admin/src/shared/permissions.ts🔧 ADD enum value replay_dlq(nếu chưa có)
Databasecontroller/migrations/default/ + controller/migrations/crm/🆕 8 migration mới(xem C7 sequence)
Hasura metadatacontroller/metadata/databases/default/tables/🆕 4 YAML mới cho bảng pancake_* + ADD permission cho accountpublic_pancake_*.yaml × 4
Hasura croncontroller/metadata/cron_triggers.yaml🔧 ADD 6 cron mới(xem C5 Event Trigger / Scheduler)
Hasura actioncontroller/metadata/actions.yaml + actions.graphql🔧 ADD pancake_replay_event action(1 entry)
Go modulediva-backend/go.mod🔧 ADD dependency sony/gobreaker(1 line)

Không thuộc phạm vi Dev Spec

MụcĐọc ở đâu
Business rationale, FR/AC, KPI definition, A9 Glossaryprd.md A2/A3/A5/A8/A9
Color/font/spacing, screen wireframes detailui-spec.md B-Desktop/B-Mobile
Test cases detail, seed data, entry/exit criteriaqa-test-plan.md D3/D4/D5
Go-live gates W1-W4, sign-offgo-live-checklist.md E1-E4
Outbound Diva → Pancake (deferred MVP-2)SOURCE_OF_TRUTH.md §7
Pancake POS / Botcake / Messenger syncSOURCE_OF_TRUTH.md §7 (Out of scope)

C2) Tóm tắt ảnh hưởng + mapping nghiệp vụ → kỹ thuật

Mỗi FR PRD A5 ánh xạ tới 1+ component/file. Ghi rõ Reuse / Extend / Build mới (Codebase-First Rule). File path + line number trích từ EVIDENCE_PACK.

FRMô tả ngắnComponent / file / handlerLoạiDữ liệu chínhĐiều kiện kích hoạtNgoại lệ / EdgeRủi ro kỹ thuật
FR-001Webhook receiver endpoint trả 200<1sservices/webhook/handler/pancake.go (NEW), route POST /api/pancake/record/{token}🆕 Build mới (reuse pattern Stringee handler/route.go:35-48)pancake_webhook_event.status (ingested→received hoặc *_failed)Pancake POST request bất kỳToken sai → status='auth_failed' 200; IP không whitelist → status='ip_blocked' 200; JSON parse fail → status='parse_error' 200DDoS public endpoint (mitigation: rate limit reverse proxy 1000 req/s/IP)
FR-002Persist raw event audit trailpkg/store/pancake_webhook_event.go (NEW) — INSERT raw payload JSONB🆕 Build mớipancake_webhook_event (raw payload jsonb, headers jsonb, source_ip, received_at)Mọi webhook request (kể cả fail)Payload > 1 MB → log warning, vẫn lưuStorage growth (~100k events/tháng × 5 KB avg = 500 MB/tháng → archive sau 6 tháng)
FR-003Idempotency 3-tuple UNIQUEDB constraint UNIQUE (record_id, modified_on, payload_hash) ở bảng pancake_webhook_event🆕 Build mới3-tuple constraintINSERT mọi eventDuplicate → ON CONFLICT DO NOTHING + UPDATE last_received_at, set status='skipped_duplicate'Hash collision (mitigation: SHA-256 payload_hash, 256 bit space)
FR-004Async processor qua Hasura event triggerHasura event trigger pancake_webhook_event_status_update watch column status → fire services/crm-api/event/event_pancake_process_record.go (NEW)🆕 Build mới (reuse pattern services/crm-api/event/event.go:24-50 dispatcher)Hasura trigger payload (op=UPDATE, columns=[status])UPDATE pancake_webhook_event SET status='received'STEP 0 filter: NEW.status != 'received' OR NEW.record_id IS NULL → return nilHasura trigger retry storm (mitigation: retry_conf=3, interval=30s)
FR-005Phone E.164 normalize + match/upsert accountpkg/pancake/phone_normalize.go (NEW) reuse github.com/ttacon/libphonenumber (go.mod L32)🔧 Extend pattern từ services/auth/action/otp.go:20,769account.normalized_phone TEXT + account.pancake_metadata JSONBSTEP 2-5 process record flowPhone không parse được → fallback phone_code=84 + raw phone_number, log warningPancake gửi format khác (vd US +1) — RSK-009
FR-006Advisory lock per phone chống racepkg/pancake/advisory_lock.go (NEW): pg_advisory_xact_lock(hashtext('account_phone_' || normalized_phone)) ở STEP 3🆕 Build mới pattern(lock scope: transaction)Trước STEP 4-5 match/upsert account2 webhook đồng thời cùng phone → 1 lock, 1 wait — serializedLock contention (mitigation: lock scope nhỏ, < 50ms typical)
FR-007Auto-create contact_book entrySTEP 5b event_pancake_process_record.go — query-before-insert pattern🔧 Extend contact_book ALTER ADD primary_phone + FK + UNIQUE (DEC-006)contact_book(account_id, primary_phone, role='customer')Sau STEP 5 nếu account vừa được tạo OR contact chưa cóRace đã được advisory lock (STEP 3) serializeMigration ALTER contact_book lock table — mitigation: NOT VALID ban đầu, VALIDATE sau (xem C7)
FR-008Auto-assign telesale REUSE round-robin 1-tierservices/crm-api/scheduler/distribute_ticket.go:157 GetTicketUpdates refactored public (DEC-012)🔧 Extend refactorticket_distribute(branch_id, target_id, user_id, created_at)STEP 9 sau INSERT ticket nếu có branch_idBranch=NULL (Loose mode) → assignee_id=NULL; branch có nhưng không có telesale active → assignee_id=NULLRefactor breaks existing cron distribute_ticket — mitigation: backward-compat signature
FR-009Smart update Q2.bSTEP 8 logic event_pancake_process_record.go so sánh account old vs Pancake new payload🆕 Build mớiCompare: phone_number, pancake_source_id, pancake_tag_namesSau STEP 6 update pancake_metadataCùng record update khác (vd full_address change) → CHỈ update account, KHÔNG tạo ticketLogic bug bỏ sót case quan trọng — mitigation: TC-009-001..006 coverage
FR-010Tạo ticket source=ticket_source_pancakeSTEP 10 INSERT ticket với source_id='ticket_source_pancake', target_id='telesales', status_id='ticket_status_new', due_date=today 23:59 ICT🔧 Extend (table reuse) + INSERT crm_master_dataticket row + ticket_log auditSau STEP 9 (assignee resolved)Branch=NULL → vẫn INSERT ticket nhưng assignee=NULLSchema FK fail nếu master data chưa seed — mitigation: migration order C7
FR-011Notification telesale + admin (Loose mode)STEP 12 call Hasura action sendNotifications (reuse services/notification-v2-api)🔧 Extend reuse + seed 3 template3 template mới: noti_ticket_assigned_pancake, noti_pancake_unmapped_branch, noti_pancake_outageSau STEP 11 update last_sync_atNotification fail KHÔNG block flow (alert ops nếu > 5%/5 phút — RSK-011)Notification-v2-api downtime — mitigation: STEP 12 non-blocking, log only
FR-012Settings UI 4 tabs Admin onlysrc/modules/settings/pages/PancakeCrmSetting.tsx (NEW container) + 4 child pages🆕 Build mới (reuse pattern AppSettingsSmsTemplate.tsx:14-28 XDetailLayout)GraphQL queries từ pancake.graphqlAdmin login + hasPermission('pancake_crm_integration','access')Non-admin → menu hidden (Diva quy tắc)Permission leak nếu chỉ FE check — mitigation: Hasura permission Admin-only
FR-013REST API consumption Pancake (chỉ /sources, Cron 3 1h cache)pkg/pancake/client.go (NEW) + services/crm-api/scheduler/pancake_source_sync.go (NEW) + sony/gobreaker (DEC-019)🆕 Build mớipancake_source_routing(pancake_source_id, pancake_source_name, last_synced_at)Cron 3 mỗi 1hAPI 401 → status=error, alert admin; 429 → exponential backoff retry; timeout → circuit breaker openPancake API rate limit (mitigation: gobreaker 5 fail/60s → open)
FR-0144-layer outage recovery + DLQ replay UICron 4 detect + Cron 6 polling + Cron 7 reconciliation + DLQ replay Hasura action pancake_replay_event🆕 Build mới (3 cron + 1 action + UI Tab 3)pancake_outage_state 4 states + pancake_webhook_event.status='dead_letter'Cron 4 every 1 min check; Cron 6 adaptive 1-15 min; Cron 7 daily 02:00 AMReconciliation Pancake API down → skip + retry hour sau (FORMULA-005 ngoại lệ)Cron overlapping race — mitigation: advisory lock per cron
FR-015Feature flag 3 mức + pilot rolloutapp_setting.app_settings.pancake_integration + pancake_connection.status + pancake_source_routing.is_active🔧 Extend app_setting + 🆕 build 2 bảngJSON config3 mức toggle (DEC-021)Kill switch off → webhook trả 200 status='skipped_kill_switch'App_setting cache stale — mitigation: invalidate cache khi update
FR-016Test mode event tagged is_test=trueCờ pancake_webhook_event.is_test BOOLEAN + Cron pancake_test_event_cleanup (NEW)🆕 Build mớiis_test=true event không count KPI, soft-delete (disabled=true) sau 24hWebhook payload có cờ test hoặc admin manual markCron soft-delete chạy 04:00 AM dailyTest event leak vào production metric — mitigation: filter is_test=false ở mọi KPI query
FR-017Opt-out qua customer_consent.consent_data->>'marketing'='false'STEP 7 query customer_consent (DEC-014)🆕 Build Go struct pkg/store/customer_consent.go (reuse table EXIST)customer_consent.consent_data->>'marketing'Sau STEP 6 update accountOpt-out → vẫn update account, KHÔNG tạo ticket, status='skipped_opt_out'Key name PD-003 chưa confirm — mitigation: default marketing
FR-018FE delta dropdown ticket source (3 file)diva-admin/src/modules/crm/types.ts + i18n/vi.ts + pages/Tickets.tsx sourceDescriptions🔧 Extend 3-file delta (DEC-017)TICKET_SOURCE_PANCAKE constant slot 9Component dropdown render slot 9 auto sau ADD entriesReport module có thể hardcode slot 1..8 (RSK-005) — grep audit 'ticket_source_' trước deployNone nếu grep clean

C3) Quy tắc và công thức (chỉ phần triển khai)

Canonical: PRD A10 FORMULA-001..006. Section này chỉ ghi SQL + index + precision + performance delta.

FORMULA-001: Latency webhook → ticket assigned (p95)

  • Ref: PRD A10 FORMULA-001.
  • SQL:
sql
SELECT
  percentile_cont(0.95) WITHIN GROUP (
    ORDER BY EXTRACT(EPOCH FROM (processed_at - created_at))
  ) AS latency_p95_seconds
FROM pancake_webhook_event
WHERE status = 'processed'
  AND is_test = false
  AND processed_at IS NOT NULL
  AND created_at >= NOW() - INTERVAL '24 hours'
  AND EXTRACT(EPOCH FROM (processed_at - created_at)) < 300;  -- Outlier > 5 phút loại
  • Source mapping:
    • processed_atpancake_webhook_event.processed_at (set ở STEP 13 trước COMMIT)
    • created_atpancake_webhook_event.created_at (set ở webhook handler INSERT)
  • Index recommendation: idx_pancake_webhook_event_status_created (status, created_at DESC) WHERE status = 'processed' — partial index để giảm size
  • Precision: Lưu trong DB là TIMESTAMPTZ microsecond → output round 1 decimal giây (format Việt Nam dấu phẩy: "18,7s")
  • Performance notes: Window 24h × 100k events = ~3.3k rows. With partial index, query < 50ms p95.

FORMULA-002: SLA telesale phản hồi (p95, giờ làm việc)

  • Ref: PRD A10 FORMULA-002.
  • SQL:
sql
-- Helper function business_hours_diff (chỉ tính giờ làm 08:00-22:00 Asia/Ho_Chi_Minh)
CREATE OR REPLACE FUNCTION business_hours_diff(start_ts TIMESTAMPTZ, end_ts TIMESTAMPTZ)
RETURNS NUMERIC AS $$
DECLARE
  total_seconds NUMERIC := 0;
  cur TIMESTAMPTZ;
  day_start TIMESTAMPTZ;
  day_end TIMESTAMPTZ;
BEGIN
  IF end_ts IS NULL OR start_ts IS NULL OR end_ts <= start_ts THEN
    RETURN NULL;
  END IF;
  cur := start_ts;
  WHILE cur < end_ts LOOP
    day_start := date_trunc('day', cur AT TIME ZONE 'Asia/Ho_Chi_Minh') + INTERVAL '8 hours';
    day_end := date_trunc('day', cur AT TIME ZONE 'Asia/Ho_Chi_Minh') + INTERVAL '22 hours';
    day_start := day_start AT TIME ZONE 'Asia/Ho_Chi_Minh';
    day_end := day_end AT TIME ZONE 'Asia/Ho_Chi_Minh';
    IF cur < day_start THEN cur := day_start; END IF;
    IF cur >= day_end THEN
      cur := day_start + INTERVAL '1 day';
      CONTINUE;
    END IF;
    total_seconds := total_seconds + EXTRACT(EPOCH FROM LEAST(day_end, end_ts) - cur);
    cur := day_start + INTERVAL '1 day';
  END LOOP;
  RETURN total_seconds;
END;
$$ LANGUAGE plpgsql IMMUTABLE;

-- Query SLA p95
SELECT
  percentile_cont(0.95) WITHIN GROUP (
    ORDER BY business_hours_diff(t.created_at, t.first_response_at) / 60.0
  ) AS sla_minutes_p95
FROM ticket t
WHERE t.source_id = 'ticket_source_pancake'
  AND t.first_response_at IS NOT NULL
  AND t.created_at >= NOW() - INTERVAL '7 days';
  • Source mapping: t.created_at, t.first_response_atticket table
  • Index recommendation: idx_ticket_source_first_response (source_id, first_response_at) WHERE source_id = 'ticket_source_pancake'
  • Precision: 2 decimal phút ("25,40 phút")
  • Performance notes: business_hours_diff là PL/pgSQL loop O(days). Window 7 ngày × ~1k ticket Pancake = 7k call. Function IMMUTABLE → query planner cache.

FORMULA-003: Auto-assign correct rate

  • Ref: PRD A10 FORMULA-003.
  • SQL:
sql
WITH pancake_tickets AS (
  SELECT id, assignee_id, created_by
  FROM ticket
  WHERE source_id = 'ticket_source_pancake'
    AND created_at >= NOW() - INTERVAL '7 days'
)
SELECT
  CASE
    WHEN COUNT(*) = 0 THEN NULL
    ELSE ROUND(
      COUNT(*) FILTER (WHERE assignee_id IS NOT NULL AND created_by = 'system_pancake_webhook')::NUMERIC
      / COUNT(*)::NUMERIC * 100,
      2
    )
  END AS correct_rate_pct
FROM pancake_tickets;
  • Source mapping: ticket.source_id, ticket.assignee_id, ticket.created_by
  • Index recommendation: reuse idx_ticket_source_first_response (cover scan)
  • Precision: 2 decimal % ("92,00%")
  • Edge: Mẫu = 0 → return NULL, FE hiển thị

FORMULA-004: Event success rate

  • Ref: PRD A10 FORMULA-004.
  • SQL:
sql
WITH eligible AS (
  SELECT status
  FROM pancake_webhook_event
  WHERE is_test = false
    AND created_at >= NOW() - INTERVAL '24 hours'
    AND status NOT LIKE 'skipped_%'   -- Loại intentional skip
)
SELECT
  CASE
    WHEN COUNT(*) = 0 THEN NULL
    ELSE ROUND(
      COUNT(*) FILTER (WHERE status = 'processed')::NUMERIC
      / COUNT(*)::NUMERIC * 100,
      3
    )
  END AS success_rate_pct
FROM eligible;
  • Source mapping: pancake_webhook_event.status, is_test, created_at
  • Index recommendation: idx_pancake_webhook_event_status_created (đã định nghĩa ở FORMULA-001)
  • Precision: 3 decimal % ("99,950%") — KPI alert threshold < 99% trigger
  • Edge: is_test=true loại khỏi cả tử + mẫu

FORMULA-005: Zero-miss SLO (Cron 7 reconciliation)

  • Ref: PRD A10 FORMULA-005.
  • SQL (Diva side count):
sql
SELECT COUNT(DISTINCT record_id) AS diva_count
FROM pancake_webhook_event
WHERE record_id IS NOT NULL
  AND created_at >= NOW() - INTERVAL '25 hours'
  AND is_test = false;
  • Pancake side count: REST API call GET /workspaces/{ws}/records?modified_since=NOW()-25h (limit/page) — implementation ở services/crm-api/scheduler/pancake_daily_reconciliation.go
  • Miss detection:
go
// Pseudo: detect missing records and inject
divaRecordIDs := store.GetPancakeRecordIDsLast25h(db)
pancakeRecords := client.GetRecordsModifiedSince(time.Now().Add(-25*time.Hour))
for _, rec := range pancakeRecords {
  if _, ok := divaRecordIDs[rec.ID]; !ok {
    store.InsertPancakeWebhookEvent(db, &store.PancakeWebhookEvent{
      RecordID:   &rec.ID,
      ModifiedOn: rec.ModifiedOn,
      Payload:    rec.Raw,
      Status:     "received",   // bypass ingested
      EventType:  "reconciled", // audit tag
      IsTest:     false,
    })
  }
}
  • Window 25h: Overlap 1h chống miss khi clock skew (DEC-025)
  • Alert threshold: > 5 events/ngày miss → alert ops escalate

FORMULA-006: Outage MTTR

  • Ref: PRD A10 FORMULA-006.
  • SQL:
sql
-- Cần audit table history pancake_outage_state (xem C4)
SELECT
  ROUND(
    AVG(EXTRACT(EPOCH FROM (outage_ended_at - outage_started_at)) / 60.0)::NUMERIC,
    2
  ) AS mttr_minutes
FROM pancake_outage_history
WHERE outage_ended_at IS NOT NULL
  AND outage_started_at >= NOW() - INTERVAL '30 days';
  • Source mapping: pancake_outage_history.outage_started_at, outage_ended_at
  • Precision: 2 decimal phút ("4,40 phút")
  • Edge: Outage chưa recover (outage_ended_at IS NULL) → loại khỏi tính

STATE-001 — Vòng đời pancake_webhook_event.status (ref PRD LIFECYCLE-001)

TừSangTriggerGuard / ValidationSide effect
(NULL)ingestedWebhook handler INSERT rowNone
ingestedauth_failedToken URL không matchpancake_connection.webhook_token = $tokenAudit log; nếu rate > 10/min → alert ops
ingestedip_blockedSource IP không trong whitelistapp_setting.pancake_integration.ip_whitelist[]Audit log
ingestedparse_errorJSON schema invalidRequired fields: record_id, phone_number, modified_on, source_idAudit log; alert nếu rate > 5%
ingestedskipped_duplicate3-tuple đã tồn tạiUNIQUE constraint violationUPDATE last_received_at, increment retry_count
ingestedskipped_source_disabledpancake_source_routing.is_active=falseRouting exists nhưng disabledNone
ingestedskipped_kill_switchapp_setting.pancake_integration.enabled=falseGlobal flag offNone
ingestedreceivedTất cả verify passAll above checks negatePROMOTE — fire Hasura trigger pancake_webhook_event_status_update
receivedprocessingHasura trigger fire, handler STEP 0 passNEW.status='received' AND record_id IS NOT NULLNone
processingskipped_opt_outSTEP 7 customer_consent.marketing='false'Account đã update info xongUPDATE account_info OK, KHÔNG INSERT ticket
processingprocessedSTEP 14 COMMIT successTất cả 15 STEP passSet processed_at=NOW(), notify telesale (STEP 12)
processingdead_letterSTEP 1-14 exceptionPanic recovery in handlerUPDATE retry_count++, set error_message, log ERROR
dead_letterreceivedAdmin manual replay qua action pancake_replay_eventPermission pancake_crm_integration.replay_dlq + status='dead_letter'Reset retry_count=0, set error_message=NULL
dead_letterpermanently_failedCron pancake_dlq_retry check retry_count >= 3(terminal)Alert ops manual review

Terminal states: processed, auth_failed, ip_blocked, parse_error, skipped_*, permanently_failed.

STATE-002 — Vòng đời pancake_outage_state.status (ref PRD LIFECYCLE-002)

TừSangTriggerGuardSide effect
healthyoutage_startedCron 4 detect: 0 event nhận trong 5 phút giờ làm việclast_event_received_at < NOW()-5min AND now in 08:00-22:00 ICTINSERT pancake_outage_history, send noti_pancake_outage admin, start Cron 6 adaptive polling
outage_startedoutage_recoveredWebhook nhận event mới (real-time)last_event_received_at >= outage_started_atUPDATE outage_history.outage_ended_at=NOW()
outage_recoveredhealthyCron 4 verify: sustain 5 phút receiving eventslast_event_received_at > NOW()-1min consistent for 5 minStop Cron 6
outage_startedoutage_startedRe-detect (no recovery)Cron 6 escalate polling interval 1→2→5→15 minNone

C4) Mô hình dữ liệu (Data Model)

Bảng hiện có (chỉ đọc / ref)

BảngRef codeCách dùng
accountcontroller/migrations/default/1660041217108_initialize/up.sql, pkg/store/account.go🔧 Extend: ADD 2 cột normalized_phone, pancake_metadata + index
contact_bookcontroller/migrations/ecommerce/1693035151427_create_table_public_contact_book/up.sql🔧 Extend: ADD primary_phone + FK + UNIQUE (DEC-006)
crm_master_data(table EXIST, 8 ticket_source)🔧 Extend: INSERT 1 row ticket_source_pancake
customer_consentcontroller/migrations/default/1775011824496_..._customer_consent_*✅ Reuse table as-is, build Go struct mới
ticketcontroller/migrations/crm/1680065571080_create_table_public_ticket/up.sql✅ Reuse as-is (KHÔNG đổi schema)
ticket_distribute(table EXIST)✅ Reuse as-is — Pancake handler INSERT entry qua refactored helper
app_settingpkg/store/app_setting.go🔧 Extend: ADD key pancake_integration trong JSON
notification_template(table EXIST)🔧 Extend: INSERT 3 row template mới
module_permission_action(table EXIST)🔧 Extend: INSERT module + 6 actions + default seed

Bảng hiện có — ALTER detail

ALTER account:

sql
-- Migration ts 1777870069928_alter_table_public_account_add_pancake_columns/up.sql
ALTER TABLE "public"."account"
  ADD COLUMN IF NOT EXISTS "normalized_phone" TEXT,
  ADD COLUMN IF NOT EXISTS "pancake_metadata" JSONB NOT NULL DEFAULT '{}';

CREATE INDEX IF NOT EXISTS account_normalized_phone_idx
  ON "public"."account" ("normalized_phone")
  WHERE "normalized_phone" IS NOT NULL;

COMMENT ON COLUMN "public"."account"."normalized_phone" IS
  'Phone in E.164 format (libphonenumber). Used for Pancake CRM integration phone matching. Backfilled from phone_code + phone_number.';
COMMENT ON COLUMN "public"."account"."pancake_metadata" IS
  'Latest-state cache from Pancake CRM. Keys: pancake_tag_names (text[]), pancake_source_ids (text[]), last_sync_at (timestamptz), pancake_source_names (text[]).';

Down migration:

sql
DROP INDEX IF EXISTS account_normalized_phone_idx;
ALTER TABLE "public"."account"
  DROP COLUMN IF EXISTS "normalized_phone",
  DROP COLUMN IF EXISTS "pancake_metadata";

ALTER contact_book (DEC-006):

sql
-- Migration ts 1777870069930_alter_table_public_contact_book_add_primary_phone_fk_unique/up.sql

-- Step 1: ADD primary_phone (no constraint, nullable)
ALTER TABLE "public"."contact_book"
  ADD COLUMN IF NOT EXISTS "primary_phone" TEXT;

-- Step 2: Backfill từ account.phone_number cho contact đã có (1-time)
UPDATE "public"."contact_book" cb
SET "primary_phone" = a."normalized_phone"
FROM "public"."account" a
WHERE cb."account_id" = a."id"
  AND cb."primary_phone" IS NULL
  AND a."normalized_phone" IS NOT NULL;

-- Step 3: ADD FK NOT VALID (avoid full table scan)
ALTER TABLE "public"."contact_book"
  ADD CONSTRAINT contact_book_account_id_fkey
  FOREIGN KEY ("account_id") REFERENCES "public"."account"("id")
  ON UPDATE RESTRICT ON DELETE CASCADE
  NOT VALID;

-- Step 4: VALIDATE FK in background
ALTER TABLE "public"."contact_book" VALIDATE CONSTRAINT contact_book_account_id_fkey;

-- Step 5: ADD UNIQUE (account_id) — REQUIRED for query-before-insert pattern STEP 5b
-- Cleanup duplicate trước nếu có
DELETE FROM "public"."contact_book" cb1
USING "public"."contact_book" cb2
WHERE cb1."account_id" = cb2."account_id"
  AND cb1."created_at" > cb2."created_at";  -- giữ row cũ nhất

ALTER TABLE "public"."contact_book"
  ADD CONSTRAINT contact_book_account_id_unique UNIQUE ("account_id");

COMMENT ON COLUMN "public"."contact_book"."primary_phone" IS
  'Primary phone E.164 format. Auto-created from Pancake webhook (FR-007) or manually entered.';

Down migration:

sql
ALTER TABLE "public"."contact_book"
  DROP CONSTRAINT IF EXISTS contact_book_account_id_unique,
  DROP CONSTRAINT IF EXISTS contact_book_account_id_fkey,
  DROP COLUMN IF EXISTS "primary_phone";

INSERT crm_master_data slot 9 (DEC-017):

sql
-- Migration ts 1777870069932_insert_ticket_source_pancake/up.sql
INSERT INTO "public"."crm_master_data" ("id", "name", "type", "disabled", "keywords")
VALUES ('ticket_source_pancake', 'Pancake CRM', 'ticket_source', false,
        'pancake,crm,lead,facebook,zalo,tiktok')
ON CONFLICT ("id") DO NOTHING;

Down migration:

sql
DELETE FROM "public"."crm_master_data" WHERE "id" = 'ticket_source_pancake';

Bảng mới (4 bảng pancake_*)

Diva pattern: disabled BOOLEAN (KHÔNG deleted_at — DEC-007). Audit fields: created_at, updated_at, created_by, updated_by. Timezone: tất cả TIMESTAMPTZ default Asia/Ho_Chi_Minh.

4.1 pancake_webhook_event — Audit raw + status lifecycle

sql
-- Migration ts 1777870069931_create_pancake_tables/up.sql (Part 1/4)
CREATE TABLE IF NOT EXISTS "public"."pancake_webhook_event" (
  "id" UUID PRIMARY KEY DEFAULT gen_random_uuid(),

  -- Idempotency 3-tuple (DEC-004)
  "record_id" TEXT,            -- Pancake record ID (nullable: case test event / parse_error)
  "modified_on" TIMESTAMPTZ,   -- Pancake-side modified_on
  "payload_hash" TEXT NOT NULL, -- SHA-256 hex of canonical payload

  -- Raw audit
  "payload" JSONB NOT NULL,    -- Full raw Pancake webhook payload
  "headers" JSONB DEFAULT '{}',-- HTTP headers for forensic (HMAC, IP, User-Agent)
  "source_ip" INET,
  "event_type" TEXT NOT NULL DEFAULT 'record',  -- record / reconciled / replayed
  "is_test" BOOLEAN NOT NULL DEFAULT false,     -- DEC-009

  -- Lifecycle status (STATE-001 — 12 states)
  "status" TEXT NOT NULL DEFAULT 'ingested',
  "error_message" TEXT,
  "retry_count" SMALLINT NOT NULL DEFAULT 0,
  "processed_at" TIMESTAMPTZ,
  "last_received_at" TIMESTAMPTZ DEFAULT now(),  -- update khi duplicate retry

  -- FK ref (resolved sau process)
  "pancake_source_id" TEXT,    -- raw source ID from Pancake payload
  "resolved_branch_id" UUID,   -- after routing resolution
  "resolved_account_id" TEXT,  -- after match/upsert
  "resolved_ticket_id" UUID,   -- after INSERT ticket

  -- Audit fields (Diva pattern)
  "disabled" BOOLEAN NOT NULL DEFAULT false,
  "created_at" TIMESTAMPTZ NOT NULL DEFAULT now(),
  "updated_at" TIMESTAMPTZ NOT NULL DEFAULT now(),
  "created_by" TEXT DEFAULT 'system_pancake_webhook',
  "updated_by" TEXT,

  -- Constraints
  CONSTRAINT pancake_webhook_event_status_check CHECK (status IN (
    'ingested', 'received', 'processing', 'processed',
    'auth_failed', 'ip_blocked', 'parse_error',
    'skipped_duplicate', 'skipped_source_disabled', 'skipped_kill_switch', 'skipped_opt_out',
    'dead_letter', 'permanently_failed'
  )),
  CONSTRAINT pancake_webhook_event_event_type_check CHECK (event_type IN ('record', 'reconciled', 'replayed')),
  CONSTRAINT pancake_webhook_event_idempotency_unique UNIQUE ("record_id", "modified_on", "payload_hash")
);

-- Indexes
CREATE INDEX IF NOT EXISTS idx_pancake_webhook_event_status_created
  ON "public"."pancake_webhook_event" ("status", "created_at" DESC)
  WHERE status = 'processed';  -- partial cover FORMULA-001

CREATE INDEX IF NOT EXISTS idx_pancake_webhook_event_status_received
  ON "public"."pancake_webhook_event" ("status", "created_at" DESC)
  WHERE status IN ('received', 'processing', 'dead_letter');  -- partial cover DLQ + queue

CREATE INDEX IF NOT EXISTS idx_pancake_webhook_event_record_id
  ON "public"."pancake_webhook_event" ("record_id")
  WHERE record_id IS NOT NULL;

CREATE INDEX IF NOT EXISTS idx_pancake_webhook_event_resolved_account
  ON "public"."pancake_webhook_event" ("resolved_account_id")
  WHERE resolved_account_id IS NOT NULL;

CREATE INDEX IF NOT EXISTS idx_pancake_webhook_event_resolved_ticket
  ON "public"."pancake_webhook_event" ("resolved_ticket_id")
  WHERE resolved_ticket_id IS NOT NULL;

CREATE INDEX IF NOT EXISTS idx_pancake_webhook_event_is_test
  ON "public"."pancake_webhook_event" ("is_test", "created_at" DESC)
  WHERE is_test = true;  -- cron cleanup query

-- Update trigger
CREATE TRIGGER set_pancake_webhook_event_updated_at
  BEFORE UPDATE ON "public"."pancake_webhook_event"
  FOR EACH ROW EXECUTE FUNCTION set_current_timestamp_updated_at();

COMMENT ON TABLE "public"."pancake_webhook_event" IS
  'Pancake CRM webhook audit log + lifecycle state machine. See dev-spec C3 STATE-001 for valid transitions.';

4.2 pancake_connection — Workspace config

sql
-- Part 2/4
CREATE TABLE IF NOT EXISTS "public"."pancake_connection" (
  "id" UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  "workspace_id" TEXT NOT NULL,            -- Pancake workspace identifier
  "workspace_name" TEXT,                   -- Display name
  "api_key_encrypted" TEXT,                -- Encrypted via pgcrypto (RSK-012)
  "webhook_token" TEXT NOT NULL,           -- Random 32-byte hex, rotate quarterly
  "webhook_url" TEXT,                      -- Full URL for display (computed)
  "vip_tag_names" TEXT[] NOT NULL DEFAULT '{}',  -- DEC-008: text array, case-insensitive match
  "ip_whitelist" INET[] NOT NULL DEFAULT '{}',   -- Pancake IP allowlist (PD-002)
  "status" TEXT NOT NULL DEFAULT 'paused',  -- active / paused / suspended_by_pancake / error

  -- Health metrics (cached)
  "last_event_received_at" TIMESTAMPTZ,
  "last_sources_sync_at" TIMESTAMPTZ,      -- Cron 3
  "last_error_at" TIMESTAMPTZ,
  "last_error_message" TEXT,

  -- Audit fields
  "disabled" BOOLEAN NOT NULL DEFAULT false,
  "created_at" TIMESTAMPTZ NOT NULL DEFAULT now(),
  "updated_at" TIMESTAMPTZ NOT NULL DEFAULT now(),
  "created_by" TEXT,
  "updated_by" TEXT,

  CONSTRAINT pancake_connection_status_check CHECK (status IN ('active', 'paused', 'suspended_by_pancake', 'error')),
  CONSTRAINT pancake_connection_workspace_unique UNIQUE ("workspace_id"),
  CONSTRAINT pancake_connection_webhook_token_unique UNIQUE ("webhook_token")
);

CREATE INDEX IF NOT EXISTS idx_pancake_connection_status
  ON "public"."pancake_connection" ("status") WHERE disabled = false;

CREATE TRIGGER set_pancake_connection_updated_at
  BEFORE UPDATE ON "public"."pancake_connection"
  FOR EACH ROW EXECUTE FUNCTION set_current_timestamp_updated_at();

COMMENT ON TABLE "public"."pancake_connection" IS
  'Pancake workspace connection config. MVP-1 single-row (1 workspace). VIP tag matching uses text NAMES case-insensitive (DEC-008).';

4.3 pancake_source_routing — Source → Branch mapping

sql
-- Part 3/4
CREATE TABLE IF NOT EXISTS "public"."pancake_source_routing" (
  "pancake_source_id" TEXT PRIMARY KEY,    -- From Pancake REST /sources
  "pancake_source_name" TEXT NOT NULL,     -- Display label (FB page name, Zalo OA name...)
  "diva_branch_id" UUID,                   -- Nullable for Loose mode (DEC-013)
  "is_active" BOOLEAN NOT NULL DEFAULT true,
  "last_synced_at" TIMESTAMPTZ DEFAULT now(),  -- From Cron 3
  "first_seen_at" TIMESTAMPTZ NOT NULL DEFAULT now(),
  "note" TEXT,                             -- Admin can add note

  -- Audit fields
  "disabled" BOOLEAN NOT NULL DEFAULT false,
  "created_at" TIMESTAMPTZ NOT NULL DEFAULT now(),
  "updated_at" TIMESTAMPTZ NOT NULL DEFAULT now(),
  "created_by" TEXT,
  "updated_by" TEXT,

  CONSTRAINT pancake_source_routing_branch_fkey
    FOREIGN KEY ("diva_branch_id") REFERENCES "public"."branch"("id")
    ON UPDATE RESTRICT ON DELETE SET NULL
);

CREATE INDEX IF NOT EXISTS idx_pancake_source_routing_active
  ON "public"."pancake_source_routing" ("is_active", "diva_branch_id")
  WHERE disabled = false;

CREATE TRIGGER set_pancake_source_routing_updated_at
  BEFORE UPDATE ON "public"."pancake_source_routing"
  FOR EACH ROW EXECUTE FUNCTION set_current_timestamp_updated_at();

COMMENT ON TABLE "public"."pancake_source_routing" IS
  'Pancake source → Diva branch mapping. branch_id NULL = Loose mode (DEC-013, ticket created with assignee=NULL).';

4.4 pancake_outage_state — Single-row outage tracking + history

sql
-- Part 4/4
CREATE TABLE IF NOT EXISTS "public"."pancake_outage_state" (
  "id" SMALLINT PRIMARY KEY DEFAULT 1,      -- Single-row enforced
  "status" TEXT NOT NULL DEFAULT 'healthy', -- STATE-002
  "outage_started_at" TIMESTAMPTZ,
  "outage_ended_at" TIMESTAMPTZ,
  "last_event_received_at" TIMESTAMPTZ,
  "last_check_at" TIMESTAMPTZ DEFAULT now(),
  "current_polling_interval_sec" INT DEFAULT 60,  -- Cron 6 adaptive
  "polling_interval_step" SMALLINT DEFAULT 0,     -- 0=60s, 1=120s, 2=300s, 3=900s
  "notes" TEXT,

  -- Audit fields
  "disabled" BOOLEAN NOT NULL DEFAULT false,
  "created_at" TIMESTAMPTZ NOT NULL DEFAULT now(),
  "updated_at" TIMESTAMPTZ NOT NULL DEFAULT now(),

  CONSTRAINT pancake_outage_state_single_row CHECK (id = 1),
  CONSTRAINT pancake_outage_state_status_check CHECK (status IN ('healthy', 'outage_started', 'outage_recovered'))
);

-- Audit history table (cho FORMULA-006 MTTR query)
CREATE TABLE IF NOT EXISTS "public"."pancake_outage_history" (
  "id" UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  "outage_started_at" TIMESTAMPTZ NOT NULL,
  "outage_ended_at" TIMESTAMPTZ,           -- NULL = still ongoing
  "max_polling_interval_sec" INT,
  "missed_events_count" INT DEFAULT 0,     -- Filled by Cron 7 reconciliation
  "notes" TEXT,
  "created_at" TIMESTAMPTZ NOT NULL DEFAULT now(),
  "updated_at" TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX IF NOT EXISTS idx_pancake_outage_history_started
  ON "public"."pancake_outage_history" ("outage_started_at" DESC);

-- Init single row
INSERT INTO "public"."pancake_outage_state" ("id", "status")
VALUES (1, 'healthy')
ON CONFLICT ("id") DO NOTHING;

CREATE TRIGGER set_pancake_outage_state_updated_at
  BEFORE UPDATE ON "public"."pancake_outage_state"
  FOR EACH ROW EXECUTE FUNCTION set_current_timestamp_updated_at();

CREATE TRIGGER set_pancake_outage_history_updated_at
  BEFORE UPDATE ON "public"."pancake_outage_history"
  FOR EACH ROW EXECUTE FUNCTION set_current_timestamp_updated_at();

COMMENT ON TABLE "public"."pancake_outage_state" IS
  'Single-row state machine for Pancake webhook outage detection. See dev-spec C3 STATE-002.';
COMMENT ON TABLE "public"."pancake_outage_history" IS
  'Outage incident audit log. Used for FORMULA-006 MTTR calculation.';

Hàm SQL helper

sql
-- Migration 1777870069931 (Part 5 — appended in same migration)

-- Generic update trigger function (đã tồn tại trong Diva, ref nếu cần)
-- CREATE OR REPLACE FUNCTION set_current_timestamp_updated_at() RETURNS TRIGGER ...

-- Pancake-specific: advisory lock helper (clarity wrapper)
CREATE OR REPLACE FUNCTION pancake_advisory_lock_account(p_normalized_phone TEXT)
RETURNS VOID AS $$
BEGIN
  IF p_normalized_phone IS NULL OR p_normalized_phone = '' THEN
    RETURN;  -- Skip lock if phone unknown (rare edge)
  END IF;
  PERFORM pg_advisory_xact_lock(hashtext('account_phone_' || p_normalized_phone));
END;
$$ LANGUAGE plpgsql;

COMMENT ON FUNCTION pancake_advisory_lock_account(TEXT) IS
  'Transaction-scoped advisory lock per normalized phone. Releases on COMMIT/ROLLBACK. DEC-020.';

Backfill normalized_phone (batch script)

sql
-- Migration ts 1777870069929_backfill_normalized_phone/up.sql
-- Note: Phone normalization is preferred in Go (libphonenumber) for accuracy.
-- This SQL backfill is approximate: prefix +84 to phone_number if phone_code=84.
-- A follow-up Go batch job will refine using libphonenumber if needed.

DO $$
DECLARE
  batch_size INT := 10000;
  total_updated INT := 0;
  iteration_count INT;
BEGIN
  LOOP
    UPDATE "public"."account"
    SET "normalized_phone" = CASE
      WHEN phone_code IS NOT NULL AND phone_number IS NOT NULL AND phone_number != '' THEN
        '+' || phone_code::TEXT || regexp_replace(phone_number, '^0+', '')
      ELSE NULL
    END
    WHERE "id" IN (
      SELECT "id" FROM "public"."account"
      WHERE "normalized_phone" IS NULL
        AND "phone_number" IS NOT NULL
      LIMIT batch_size
    );

    GET DIAGNOSTICS iteration_count = ROW_COUNT;
    total_updated := total_updated + iteration_count;

    EXIT WHEN iteration_count = 0;

    -- Commit per batch (long-running migration safety)
    -- Note: In Hasura migration this runs as single tx; for production scale use external job.
    RAISE NOTICE 'Backfilled % rows so far (total: %)', iteration_count, total_updated;
  END LOOP;

  RAISE NOTICE 'Backfill complete: % rows updated', total_updated;
END $$;

Note: For 1M+ account dataset, run backfill as external Go job (split transactions). Estimate ~8.3 phút for 1M rows × 0.5ms.


C5) Quy ước tích hợp (Integration Conventions)

Hasura metadata

File: controller/metadata/databases/default/tables/public_pancake_webhook_event.yaml

yaml
table:
  name: pancake_webhook_event
  schema: public
configuration:
  custom_root_fields: {}
  custom_column_names: {}

# Permissions: Admin only
select_permissions:
  - role: admin
    permission:
      columns: '*'
      filter: {}
      allow_aggregations: true
insert_permissions:
  - role: admin
    permission:
      columns: '*'
      check: {}
update_permissions:
  - role: admin
    permission:
      columns: [status, error_message, retry_count, processed_at, updated_by, disabled]
      filter: {}
      check: {}
delete_permissions:
  - role: admin
    permission:
      filter: {}

# Event trigger watch status column → fire crm-api
event_triggers:
  - name: pancake_webhook_event_status_update
    definition:
      enable_manual: true
      update:
        columns: [status]
    webhook_from_env: CRM_API_BASE_URL
    webhook_path: /events
    retry_conf:
      num_retries: 3
      interval_sec: 30
      timeout_sec: 60
    headers:
      - name: X-Hasura-Event-Source
        value: pancake_webhook_event_status_update

Files (4 YAML): public_pancake_webhook_event.yaml, public_pancake_connection.yaml, public_pancake_source_routing.yaml, public_pancake_outage_state.yaml (+ public_pancake_outage_history.yaml).

Permissions tóm tắt: Admin only select/insert/update/delete. Manager/Telesale/POS: NO access.

GraphQL query / mutation

File: diva-admin/src/modules/settings/graphql/pancake.graphql (NEW)

graphql
# Connection
query GetPancakeConnection {
  pancake_connection(limit: 1, where: { disabled: { _eq: false } }) {
    id
    workspace_id
    workspace_name
    webhook_token
    webhook_url
    vip_tag_names
    ip_whitelist
    status
    last_event_received_at
    last_sources_sync_at
    last_error_at
    last_error_message
    updated_at
  }
}

mutation UpdatePancakeConnection($id: uuid!, $set: pancake_connection_set_input!) {
  update_pancake_connection_by_pk(pk_columns: { id: $id }, _set: $set) {
    id
    updated_at
  }
}

# Source routing
query ListPancakeSourceRouting {
  pancake_source_routing(
    where: { disabled: { _eq: false } }
    order_by: { pancake_source_name: asc }
  ) {
    pancake_source_id
    pancake_source_name
    diva_branch_id
    is_active
    last_synced_at
    note
    branch {
      id
      label
    }
  }
}

mutation UpdatePancakeSourceRouting(
  $pancake_source_id: String!
  $set: pancake_source_routing_set_input!
) {
  update_pancake_source_routing_by_pk(
    pk_columns: { pancake_source_id: $pancake_source_id }
    _set: $set
  ) {
    pancake_source_id
    updated_at
  }
}

# Audit / DLQ
query ListPancakeWebhookEvents(
  $where: pancake_webhook_event_bool_exp
  $limit: Int = 50
  $offset: Int = 0
) {
  pancake_webhook_event(
    where: $where
    order_by: { created_at: desc }
    limit: $limit
    offset: $offset
  ) {
    id
    record_id
    status
    event_type
    is_test
    retry_count
    error_message
    pancake_source_id
    resolved_branch_id
    resolved_account_id
    resolved_ticket_id
    created_at
    processed_at
  }
  pancake_webhook_event_aggregate(where: $where) {
    aggregate {
      count
    }
  }
}

query GetPancakeWebhookEventDetail($id: uuid!) {
  pancake_webhook_event_by_pk(id: $id) {
    id
    record_id
    modified_on
    payload
    headers
    source_ip
    status
    error_message
    retry_count
    event_type
    is_test
    pancake_source_id
    resolved_branch_id
    resolved_account_id
    resolved_ticket_id
    created_at
    processed_at
    last_received_at
  }
}

# Hasura action: replay DLQ event
mutation PancakeReplayEvent($event_id: uuid!) {
  pancake_replay_event(event_id: $event_id) {
    success
    message
  }
}

mutation PancakeReplayEventBulk($event_ids: [uuid!]!) {
  pancake_replay_event_bulk(event_ids: $event_ids) {
    success
    replayed_count
    skipped_count
    message
  }
}

# Health
query GetPancakeHealthMetrics($from: timestamptz!, $to: timestamptz!) {
  pancake_outage_state(limit: 1) {
    status
    outage_started_at
    outage_ended_at
    current_polling_interval_sec
    last_event_received_at
    last_check_at
  }
  pancake_outage_history(
    where: { outage_started_at: { _gte: $from, _lte: $to } }
    order_by: { outage_started_at: desc }
  ) {
    id
    outage_started_at
    outage_ended_at
    max_polling_interval_sec
    missed_events_count
  }
}

Hasura Action: pancake_replay_event / pancake_replay_event_bulk

Path: controller/metadata/actions.yaml + actions.graphql

Schema:

graphql
type SuccessOutput {
  success: Boolean!
  message: String!
}

type BulkReplayOutput {
  success: Boolean!
  replayed_count: Int!
  skipped_count: Int!
  message: String!
}

type Mutation {
  pancake_replay_event(event_id: uuid!): SuccessOutput
  pancake_replay_event_bulk(event_ids: [uuid!]!): BulkReplayOutput
}

actions.yaml:

yaml
- name: pancake_replay_event
  definition:
    kind: synchronous
    handler: '{{CRM_API_BASE_URL}}/actions/pancake_replay_event'
    forward_client_headers: true
    headers:
      - name: x-hasura-admin-secret
        value_from_env: HASURA_GRAPHQL_ADMIN_SECRET
  permissions:
    - role: admin

- name: pancake_replay_event_bulk
  definition:
    kind: synchronous
    handler: '{{CRM_API_BASE_URL}}/actions/pancake_replay_event_bulk'
    forward_client_headers: true
  permissions:
    - role: admin

Handler: services/crm-api/action/pancake_replay_event.go (NEW)

go
package action

import (
  "context"
  "fmt"
  "github.com/diva-group/diva-backend/pkg/store"
)

// Logic:
// 1. Check session_variables['x-hasura-role'] == 'admin' (extra defense beyond Hasura permission)
// 2. Load event by ID → must exist + status='dead_letter' or 'permanently_failed'
// 3. UPDATE status='received', retry_count=0, error_message=NULL → fires Hasura trigger
// 4. Return success
func PancakeReplayEvent(ctx context.Context, input PancakeReplayEventInput) (*SuccessOutput, error) {
  // Extract session vars
  if !isAdmin(ctx) {
    return &SuccessOutput{Success: false, Message: "UNAUTHORIZED"}, nil
  }
  event, err := store.GetPancakeWebhookEventByID(db, input.EventID)
  if err != nil {
    return &SuccessOutput{Success: false, Message: "PANCAKE_EVENT_NOT_FOUND"}, nil
  }
  if event.Status != "dead_letter" && event.Status != "permanently_failed" {
    return &SuccessOutput{
      Success: false,
      Message: fmt.Sprintf("PANCAKE_EVENT_NOT_REPLAYABLE: status=%s", event.Status),
    }, nil
  }
  err = store.UpdatePancakeWebhookEventReplay(db, event.ID, ctx.Value("user_id").(string))
  if err != nil {
    return &SuccessOutput{Success: false, Message: err.Error()}, nil
  }
  return &SuccessOutput{Success: true, Message: "Đã đẩy lại sự kiện vào hàng xử lý."}, nil
}

// Bulk: rate-limited 100 events / call, replay sequential
func PancakeReplayEventBulk(ctx context.Context, input PancakeReplayEventBulkInput) (*BulkReplayOutput, error) {
  if !isAdmin(ctx) {
    return &BulkReplayOutput{Success: false, Message: "UNAUTHORIZED"}, nil
  }
  if len(input.EventIDs) > 100 {
    return &BulkReplayOutput{Success: false, Message: "PANCAKE_REPLAY_RATE_LIMIT: max 100/call"}, nil
  }
  // ... iterate, rate-limit 5/giây
}

Endpoint pattern (BẮT BUỘC — không tự sáng tạo route)

Diva services chỉ expose 4 endpoint patterns:

POST /actions      → Hasura action handlers (crm-api/action/*.go)
POST /events       → Hasura event trigger handlers (crm-api/event/*.go)
POST /schedulers   → Cron job handlers (crm-api/scheduler/*.go)
GET  /healthz      → Health check

Ngoại lệ public webhook receiverservices/webhook:

  • POST /api/pancake/record/{token} — chỉ endpoint public (no Hasura action), trả 200 fail-open

Hasura action pancake_replay_event đăng ký 4 nơi:

✅ Handler file: services/crm-api/action/pancake_replay_event.go
✅ Register map: services/crm-api/action/action.go
✅ Action def:   controller/metadata/actions.yaml
✅ GraphQL sig:  controller/metadata/actions.graphql

Flow chi tiết 15 STEP (event_pancake_process_record.go) — pseudocode Go

go
// services/crm-api/event/event_pancake_process_record.go
package event

import (
  "context"
  "fmt"
  "time"

  "github.com/diva-group/diva-backend/pkg/pancake"
  "github.com/diva-group/diva-backend/pkg/store"
)

const (
  PancakeCreatedBy = "system_pancake_webhook"
  TicketSourcePancake = "ticket_source_pancake"
)

// EventPancakeProcessRecord — handler for Hasura trigger `pancake_webhook_event_status_update`.
// Fires when pancake_webhook_event.status is updated. Filter STEP 0 ensures only status='received'.
func EventPancakeProcessRecord(ctx context.Context, payload EventPayload) error {
  newRow := payload.Event.Data.New

  // STEP 0 — Filter: only process status='received' with record_id present.
  if newRow.Status != "received" || newRow.RecordID == nil {
    return nil
  }

  // STEP 1 — BEGIN TX + re-fetch FOR UPDATE (lock row).
  tx, err := db.BeginTx(ctx, nil)
  if err != nil {
    return err
  }
  defer func() {
    if p := recover(); p != nil {
      tx.Rollback()
      _ = store.MarkPancakeEventDeadLetter(db, newRow.ID, fmt.Sprintf("panic: %v", p))
      panic(p)
    }
  }()

  event, err := store.GetPancakeWebhookEventForUpdate(tx, newRow.ID)
  if err != nil {
    tx.Rollback()
    return err
  }
  // Re-check (idempotent guard: maybe another worker already processed)
  if event.Status != "received" {
    tx.Rollback()
    return nil
  }

  // STEP 1.5 — UPDATE status='processing'.
  if err := store.UpdatePancakeEventStatus(tx, event.ID, "processing", nil); err != nil {
    tx.Rollback()
    return err
  }

  // Wrap remaining steps with err handling — on any error, mark dead_letter.
  err = func() error {
    var rawPayload pancake.RecordPayload
    if err := event.UnmarshalPayload(&rawPayload); err != nil {
      return fmt.Errorf("parse payload: %w", err)
    }

    // STEP 2 — Phone normalize (E.164).
    normalizedPhone, err := pancake.NormalizePhone(rawPayload.PhoneNumber, "VN")
    if err != nil {
      // Fallback: store raw, log warning
      normalizedPhone = ""
    }

    // STEP 3 — Advisory lock per phone (DEC-020).
    if _, err := tx.ExecContext(ctx,
      "SELECT pancake_advisory_lock_account($1)", normalizedPhone); err != nil {
      return fmt.Errorf("advisory lock: %w", err)
    }

    // STEP 4 — Resolve branch from source routing.
    routing, err := store.GetPancakeSourceRouting(tx, rawPayload.SourceID)
    if err != nil && err != store.ErrNotFound {
      return fmt.Errorf("source routing: %w", err)
    }
    var branchID *string
    if routing != nil && routing.IsActive && routing.DivaBranchID != nil {
      bID := routing.DivaBranchID.String()
      branchID = &bID
    }
    // branchID = nil → Loose mode (DEC-013)

    // STEP 5 — Match/upsert account.
    var (
      account    *store.Account
      isNewAccount bool
    )
    if normalizedPhone != "" {
      account, _ = store.GetAccountByNormalizedPhone(tx, normalizedPhone)
    }
    if account == nil {
      account, err = store.CreateAccountFromPancake(tx, &rawPayload, normalizedPhone, branchID)
      if err != nil {
        return fmt.Errorf("create account: %w", err)
      }
      isNewAccount = true
    } else {
      // Update basic info (display_name, address text in pancake_metadata) — KHÔNG override existing customer data
      if err := store.UpdateAccountInfoFromPancake(tx, account.ID, &rawPayload); err != nil {
        return fmt.Errorf("update account: %w", err)
      }
    }

    // STEP 5b — Auto-create contact_book (FR-007, DEC-006).
    contact, _ := store.GetContactBookByAccountID(tx, account.ID)
    if contact == nil {
      if err := store.CreateContactBookFromPancake(tx, account.ID, normalizedPhone, &rawPayload); err != nil {
        return fmt.Errorf("create contact: %w", err)
      }
    }

    // STEP 6 — Update pancake_metadata cache.
    oldMeta := account.PancakeMetadata
    if err := store.UpdateAccountPancakeMetadata(tx, account.ID, &rawPayload); err != nil {
      return fmt.Errorf("update pancake_metadata: %w", err)
    }

    // STEP 7 — Check opt-out marketing (FR-017, DEC-014).
    consent, _ := store.GetCustomerConsent(tx, account.ID)
    if consent != nil && consent.ConsentData["marketing"] == "false" {
      _ = store.UpdatePancakeEventStatus(tx, event.ID, "skipped_opt_out", nil)
      return tx.Commit()  // Early return — account updated, no ticket
    }

    // STEP 8 — Smart update Q2.b (FR-009, DEC-011).
    phoneChanged := !isNewAccount && oldMeta.LastPhone != normalizedPhone
    sourceNew := !isNewAccount && !contains(oldMeta.PancakeSourceIDs, rawPayload.SourceID)
    vipTagNew := false
    {
      conn, _ := store.GetActivePancakeConnection(tx)
      if conn != nil {
        newVIPTags := pancake.MatchVIPTags(rawPayload.TagNames, conn.VIPTagNames)
        oldVIPTags := pancake.MatchVIPTags(oldMeta.PancakeTagNames, conn.VIPTagNames)
        for _, t := range newVIPTags {
          if !contains(oldVIPTags, t) {
            vipTagNew = true
            break
          }
        }
      }
    }
    shouldCreateTicket := isNewAccount || phoneChanged || sourceNew || vipTagNew
    if !shouldCreateTicket {
      _ = store.UpdatePancakeEventStatus(tx, event.ID, "processed", timePtr(time.Now()))
      return tx.Commit()
    }

    // STEP 9 — Round-robin assignee (FR-008, DEC-012) — REUSE GetTicketUpdates refactored helper.
    ticket := &store.Ticket{
      ID:           store.NewUUID(),
      CustomerID:   account.ID,
      SourceID:     TicketSourcePancake,
      TargetID:     "telesales",
      BranchID:     branchID,
      DueDate:      todayEndOfDayICT(),
      StatusID:     "ticket_status_new",
      InputNote:    pancake.BuildPancakeInputNote(&rawPayload),
      CreatedBy:    PancakeCreatedBy,
    }

    // STEP 10 — INSERT ticket unassigned first, then call helper.
    if err := store.InsertTicket(tx, ticket); err != nil {
      return fmt.Errorf("insert ticket: %w", err)
    }

    var assigneeID *string
    if branchID != nil {
      branchUsers, _ := store.GetActiveTelesalesByBranch(tx, *branchID)
      if len(branchUsers) > 0 {
        updates, err := scheduler.GetTicketUpdates(tx, []store.Ticket{*ticket}, branchUsers, true /* isTelesales */)
        if err == nil && len(updates) > 0 {
          assigneeID = &updates[0].AssigneeID
          if err := store.UpdateTicketAssignee(tx, ticket.ID, *assigneeID); err != nil {
            return fmt.Errorf("update assignee: %w", err)
          }
          if err := store.InsertTicketDistribute(tx, *branchID, "telesales", *assigneeID); err != nil {
            return fmt.Errorf("insert distribute: %w", err)
          }
        }
      }
    }

    // STEP 11 — Update pancake_metadata.last_sync_at.
    if err := store.SetAccountPancakeMetadataLastSync(tx, account.ID, time.Now()); err != nil {
      return fmt.Errorf("set last_sync_at: %w", err)
    }

    // STEP 12 — Notification dispatch (FR-011, DEC-024). NON-BLOCKING.
    go func(ticketID string, branch *string, assignee *string) {
      if assignee != nil {
        _ = notification.SendTicketAssignedPancake(ticketID, *assignee)
      } else if branch == nil {
        _ = notification.SendPancakeUnmappedBranch(ticketID)
      } else {
        _ = notification.SendPancakeNoTelesaleActive(ticketID, *branch)
      }
    }(ticket.ID, branchID, assigneeID)

    // STEP 13 — UPDATE event.status='processed' + resolved fields.
    if err := store.MarkPancakeEventProcessed(tx, event.ID, &ticket.ID, account.ID, branchID); err != nil {
      return fmt.Errorf("mark processed: %w", err)
    }

    // STEP 14 — COMMIT.
    return tx.Commit()
  }()

  if err != nil {
    _ = tx.Rollback()
    _ = store.MarkPancakeEventDeadLetter(db, newRow.ID, err.Error())
    return err
  }
  return nil
}

func todayEndOfDayICT() time.Time {
  loc, _ := time.LoadLocation("Asia/Ho_Chi_Minh")
  now := time.Now().In(loc)
  return time.Date(now.Year(), now.Month(), now.Day(), 23, 59, 59, 0, loc)
}

Webhook receiver flow (services/webhook/handler/pancake.go) — pseudocode

go
// services/webhook/handler/pancake.go
package handler

import (
  "crypto/sha256"
  "encoding/hex"
  "encoding/json"
  "io"
  "net/http"
  "time"

  "github.com/gin-gonic/gin"
  "github.com/diva-group/diva-backend/pkg/store"
)

// PancakeWebhookHandler — POST /api/pancake/record/{token}
// Fail-open: always return 200 (DEC-003).
func PancakeWebhookHandler(c *gin.Context) {
  startedAt := time.Now()
  defer func() {
    metrics.PancakeWebhookLatency.Observe(time.Since(startedAt).Seconds())
    c.JSON(http.StatusOK, gin.H{"ok": true})  // Always 200
  }()

  token := c.Param("token")
  rawBody, err := io.ReadAll(c.Request.Body)
  if err != nil {
    insertEventFailed(c, "parse_error", rawBody, "read body: "+err.Error())
    return
  }

  // 1. INSERT raw event status='ingested' (audit before verify).
  payloadHash := sha256hex(rawBody)
  event := &store.PancakeWebhookEvent{
    ID:          store.NewUUID(),
    PayloadHash: payloadHash,
    Payload:     rawBody,
    Headers:     captureHeaders(c.Request.Header),
    SourceIP:    c.ClientIP(),
    Status:      "ingested",
    EventType:   "record",
  }
  // Parse to extract record_id / modified_on / source_id (best-effort)
  var pData struct {
    RecordID    string    `json:"record_id"`
    ModifiedOn  time.Time `json:"modified_on"`
    SourceID    string    `json:"source_id"`
    PhoneNumber string    `json:"phone_number"`
    IsTest      bool      `json:"is_test"`
  }
  parseErr := json.Unmarshal(rawBody, &pData)
  if parseErr == nil {
    event.RecordID = &pData.RecordID
    event.ModifiedOn = &pData.ModifiedOn
    event.PancakeSourceID = &pData.SourceID
    event.IsTest = pData.IsTest
  }

  if err := store.InsertPancakeWebhookEvent(db, event); err != nil {
    // Dedup hit? Check ON CONFLICT
    if isDuplicateErr(err) {
      _ = store.MarkDuplicate(db, payloadHash, pData.RecordID, pData.ModifiedOn)
      return
    }
    logger.Error("INSERT pancake_webhook_event failed", err)
    return
  }

  // 2. Verify token URL (lookup pancake_connection)
  conn, err := store.GetPancakeConnectionByToken(db, token)
  if err != nil || conn == nil {
    _ = store.UpdatePancakeEventStatus(db, event.ID, "auth_failed", strPtr("invalid token"))
    return
  }

  // 3. Verify IP whitelist (if configured)
  if len(conn.IPWhitelist) > 0 {
    if !ipInList(c.ClientIP(), conn.IPWhitelist) {
      _ = store.UpdatePancakeEventStatus(db, event.ID, "ip_blocked", strPtr("ip not in whitelist"))
      return
    }
  }

  // 4. Verify HMAC (if PD-001 resolved + conn.hmac_secret set)
  // [TBD-PD-001] — defer to W4

  // 5. Validate JSON schema (parse_err already covered)
  if parseErr != nil || pData.RecordID == "" {
    _ = store.UpdatePancakeEventStatus(db, event.ID, "parse_error", strPtr(parseErr.Error()))
    return
  }

  // 6. Check kill switch
  if !appSetting.PancakeIntegration.Enabled {
    _ = store.UpdatePancakeEventStatus(db, event.ID, "skipped_kill_switch", nil)
    return
  }

  // 7. Check source routing active
  routing, _ := store.GetPancakeSourceRouting(db, pData.SourceID)
  if routing != nil && !routing.IsActive {
    _ = store.UpdatePancakeEventStatus(db, event.ID, "skipped_source_disabled", nil)
    return
  }

  // 8. Update connection.last_event_received_at + outage_state
  _ = store.UpdatePancakeConnectionLastReceived(db, conn.ID, time.Now())
  _ = store.UpdatePancakeOutageStateLastReceived(db, time.Now())

  // 9. PROMOTE: UPDATE status='received' → fires Hasura trigger.
  if err := store.UpdatePancakeEventStatus(db, event.ID, "received", nil); err != nil {
    logger.Error("PROMOTE failed", err)
    return
  }
}

Cron schedulers (services/crm-api/scheduler)

6 cron mới (theo design doc, mapping):

Cron nameScheduleHandler filePurpose
pancake_source_sync_hourly0 * * * *pancake_source_sync.goCron 3 — call Pancake REST /sources, upsert pancake_source_routing
pancake_outage_detect* * * * * (1 min)pancake_outage_detect.goCron 4 — check last_event_received_at, transition state
pancake_adaptive_pollingDynamic (via pancake_outage_state.current_polling_interval_sec)pancake_adaptive_polling.goCron 6 — polling Pancake /records?modified_since when outage
pancake_daily_reconciliation0 2 * * * (02:00 AM ICT)pancake_daily_reconciliation.goCron 7 — 25h reconciliation (DEC-025)
pancake_dlq_retry*/5 * * * * (every 5 min)pancake_dlq_retry.goAuto retry dead_letter events with retry_count < 3
pancake_test_event_cleanup0 4 * * * (04:00 AM ICT)pancake_test_event_cleanup.goSoft-delete (disabled=true) events is_test=true AND created_at < NOW()-24h (DEC-009)

Hasura cron_triggers.yaml config example:

yaml
- name: pancake_source_sync_hourly
  webhook: '{{CRM_API_BASE_URL}}/schedulers'
  schedule: 0 * * * *
  payload:
    name: pancake_source_sync_hourly
  retry_conf:
    num_retries: 2
    timeout_seconds: 600
    tolerance_seconds: 21600
    retry_interval_seconds: 60
  include_in_metadata: true
  comment: 'Pancake CRM: hourly sync /sources REST (DEC-018, Cron 3)'

Bảng mã lỗi (M+)

Tình huốngError codeHTTP / GraphQLFE xử lýCopy hiển thị (vi)
Event không tìm thấy (replay)PANCAKE_EVENT_NOT_FOUND200 (GraphQL error in body)Toast lỗi đỏ"Không tìm thấy sự kiện. Có thể đã bị xóa."
Không quyền replayUNAUTHORIZED401 (Hasura permission)Button hidden(no UI surface)
Replay khi event không phải dead_letter/permanently_failedPANCAKE_EVENT_NOT_REPLAYABLE200 GraphQLToast warning vàng"Sự kiện không thể phát lại. Trạng thái: {status}."
Bulk replay vượt batch limitPANCAKE_REPLAY_RATE_LIMIT200 GraphQLToast warning + progress"Đã phát lại {X}/{Y} sự kiện. Tạm dừng do giới hạn."
Pancake REST timeoutPANCAKE_REST_TIMEOUT200 GraphQLBanner connection panel"Pancake không phản hồi. Đang dùng dữ liệu cache."
Pancake REST 401 (api_key sai)PANCAKE_REST_UNAUTHORIZED200 GraphQLBanner đỏ + alert"API key Pancake không hợp lệ. Liên hệ admin."
Pancake REST 429 (rate limit)PANCAKE_REST_RATE_LIMITED200 GraphQLBanner vàng"Pancake giới hạn truy cập. Sẽ tự thử lại."
HMAC fail (PD-001)PANCAKE_HMAC_INVALID200 (fail-open)(no FE, audit log only)(Log only)
Webhook token saiPANCAKE_TOKEN_INVALID200 (fail-open)(no FE)(Log + alert ops nếu > 10/min)
JSON parse errorPANCAKE_PARSE_ERROR200 (fail-open)(no FE)(Log + alert nếu rate > 5%)
Circuit breaker openPANCAKE_CIRCUIT_OPEN200 GraphQLBanner connection"Tạm dừng gọi Pancake API. Tự thử lại sau {sec}s."
Source routing không tồn tạiPANCAKE_SOURCE_NOT_FOUND200 (continued, branch=NULL)(no FE)(Log INFO — Loose mode)

C6) Component frontend

Cấu trúc file

diva-admin/src/modules/settings/pages/
├── PancakeCrmSetting.tsx                ← NEW container 4 tabs (XDetailLayout)
└── pancake/
    ├── PancakeConnection.tsx            ← NEW (SCR-01) — Tab 1 Connection
    ├── PancakeSourceRouting.tsx         ← NEW (SCR-02) — Tab 2 Source → Branch
    ├── PancakeAuditDlq.tsx              ← NEW (SCR-03) — Tab 3 Audit + DLQ replay
    └── PancakeHealth.tsx                ← NEW (SCR-04) — Tab 4 Health monitor

diva-admin/src/modules/settings/graphql/
└── pancake.graphql                      ← NEW (queries + mutations)

diva-admin/src/modules/crm/
├── types.ts                              ← EDIT (line 146-164): ADD TICKET_SOURCE_PANCAKE
├── i18n/vi.ts                            ← EDIT (line 130-137): ADD key ticket_source_pancake
├── i18n/en.ts                            ← EDIT: ADD key (if file exists)
└── pages/Tickets.tsx                     ← EDIT (line 128-149): ADD sourceDescriptions entry

diva-admin/src/router/routes.ts           ← EDIT: ADD 5 routes (parent + 4 child)
diva-admin/src/layouts/navigation/...     ← EDIT: ADD menu entry Settings → "Tích hợp Pancake"
diva-admin/src/shared/permissions.ts      ← EDIT: ADD `replay_dlq` enum value (if not exists)

Logic lõi (pseudocode TypeScript)

PancakeAuditDlq.tsx — Replay flow:

typescript
import { useMutation, useQuery } from 'villus';
import { useGlobalStore } from '@/stores/useGlobalStore';
import {
  ListPancakeWebhookEventsDocument,
  PancakeReplayEventDocument,
  PancakeReplayEventBulkDocument,
} from '@/modules/settings/graphql/pancake';

export default defineComponent({
  setup() {
    const globalStore = useGlobalStore();
    const canReplay = computed(() =>
      globalStore.hasPermission('pancake_crm_integration', 'replay_dlq')
    );

    const filter = ref({ status: ['dead_letter'], is_test: false });
    const { data, refetch } = useQuery({
      query: ListPancakeWebhookEventsDocument,
      variables: () => ({ where: buildWhere(filter.value), limit: 50, offset: 0 }),
    });

    const { execute: replayOne } = useMutation(PancakeReplayEventDocument);
    const { execute: replayBulk } = useMutation(PancakeReplayEventBulkDocument);

    const replayEvent = async (eventId: string) => {
      const result = await replayOne({ event_id: eventId });
      if (result.error || !result.data?.pancake_replay_event?.success) {
        notify.error(result.data?.pancake_replay_event?.message ?? 'Lỗi không xác định');
        return;
      }
      notify.success('Đã đẩy lại sự kiện vào hàng xử lý.');
      refetch();
    };

    const bulkReplaySelected = async (selectedIds: string[]) => {
      if (selectedIds.length === 0) return;
      if (selectedIds.length > 100) {
        notify.warning('Tối đa 100 sự kiện mỗi lần.');
        return;
      }
      const result = await replayBulk({ event_ids: selectedIds });
      const out = result.data?.pancake_replay_event_bulk;
      if (out?.success) {
        notify.success(`Đã phát lại ${out.replayed_count}/${selectedIds.length} sự kiện.`);
      } else {
        notify.error(out?.message ?? 'Lỗi bulk replay');
      }
      refetch();
    };

    return { canReplay, data, replayEvent, bulkReplaySelected };
  },
});

PancakeConnection.tsx — Test connection action:

typescript
const testConnection = async () => {
  loading.value = true;
  const result = await urqlClient.mutation(PancakeTestConnectionDocument, {});
  loading.value = false;
  if (result.error) {
    notify.error(result.error.message);
    return;
  }
  const out = result.data?.pancake_test_connection;
  if (out?.success) {
    notify.success(`Kết nối thành công. Phát hiện ${out.sources_count} nguồn.`);
    detectedSources.value = out.sources;
  } else {
    banner.value = { type: 'error', message: out?.message };
  }
};

PancakeSourceRouting.tsx — Autosave per row:

typescript
import { debounce } from 'lodash-es';

const saveBranch = debounce(async (sourceId: string, branchId: string | null) => {
  await urqlClient.mutation(UpdatePancakeSourceRoutingDocument, {
    pancake_source_id: sourceId,
    set: { diva_branch_id: branchId },
  });
  notify.success('Đã lưu.', { duration: 1500 });
}, 500);

const toggleActive = async (sourceId: string, isActive: boolean) => {
  await urqlClient.mutation(UpdatePancakeSourceRoutingDocument, {
    pancake_source_id: sourceId,
    set: { is_active: isActive },
  });
};

types.ts delta — slot 9:

typescript
// diva-admin/src/modules/crm/types.ts (line 146-164 edit)
export const TICKET_SOURCE_1 = 'ticket_source_1';
// ... (1-8 existing)
export const TICKET_SOURCE_8 = 'ticket_source_8';
export const TICKET_SOURCE_PANCAKE = 'ticket_source_pancake'; // NEW

export const TicketSources = [
  TICKET_SOURCE_1,
  // ... 2-8
  TICKET_SOURCE_8,
  TICKET_SOURCE_PANCAKE, // append slot 9
] as const;

i18n/vi.ts delta:

typescript
// diva-admin/src/modules/crm/i18n/vi.ts (line 130-137 edit)
ticket_source_1: 'Nguồn 1',
// ... 2-8
ticket_source_8: 'Nguồn 8',
ticket_source_pancake: 'Pancake CRM', // NEW

Tickets.tsx delta — sourceDescriptions:

typescript
// diva-admin/src/modules/crm/pages/Tickets.tsx (line 128-149 edit)
const sourceDescriptions: Record<string, string> = {
  [TICKET_SOURCE_1]: 'Tạo bởi các nhóm sau: ...',
  // ... 2-8
  [TICKET_SOURCE_8]: 'Khách hàng có sinh nhật hôm nay & thực thu tối thiểu 1tr',
  [TICKET_SOURCE_PANCAKE]:
    'Lead từ Pancake CRM (40+ kênh: FB, Zalo, TikTok, Shopee...). Auto-assign telesale theo chi nhánh.',
};

routes.ts delta (5 routes):

typescript
{
  path: 'settings/pancake',
  component: () => import('@/modules/settings/pages/PancakeCrmSetting.tsx'),
  meta: { permission: { module: 'pancake_crm_integration', action: 'access' } },
  children: [
    { path: '', redirect: 'connection' },
    { path: 'connection', component: () => import('@/modules/settings/pages/pancake/PancakeConnection.tsx'),
      meta: { permission: { module: 'pancake_crm_integration', action: 'access' } } },
    { path: 'source-routing', component: () => import('@/modules/settings/pages/pancake/PancakeSourceRouting.tsx'),
      meta: { permission: { module: 'pancake_crm_integration', action: 'access' } } },
    { path: 'audit', component: () => import('@/modules/settings/pages/pancake/PancakeAuditDlq.tsx'),
      meta: { permission: { module: 'pancake_crm_integration', action: 'access' } } },
    { path: 'health', component: () => import('@/modules/settings/pages/pancake/PancakeHealth.tsx'),
      meta: { permission: { module: 'pancake_crm_integration', action: 'access' } } },
  ],
},

C7) Chiến lược migration (Migration Strategy)

Sequence chuẩn: timestamps consecutive từ latest current 1777870069927 (verified). Mỗi migration là cặp up.sql + down.sql. Idempotent guards bắt buộc (IF NOT EXISTS, ON CONFLICT DO NOTHING, anchor pre-check theo CLAUDE.md pitfalls #4).

Migration sequence

#TimestampFolder nameUp SQL nội dungReversible?
11777870069928alter_table_public_account_add_pancake_columnsADD normalized_phone TEXT, pancake_metadata JSONB DEFAULT '{}', index partial✅ Yes
21777870069929backfill_normalized_phoneUPDATE account SET normalized_phone từ phone_code + phone_number (batch 10k)✅ Yes (UPDATE SET NULL)
31777870069930alter_table_public_contact_book_add_primary_phone_fk_uniqueALTER contact_book ADD primary_phone + FK NOT VALID + VALIDATE + UNIQUE (DEC-006)✅ Yes
41777870069931create_pancake_tablesCREATE 4 tables pancake_webhook_event/connection/source_routing/outage_state + history + indexes + triggers + helper function pancake_advisory_lock_account✅ Yes
51777870069932insert_ticket_source_pancakeINSERT crm_master_data slot 9 (DEC-017)✅ Yes (DELETE)
61777870069933insert_pancake_module_permissionINSERT module_permission_action rows × 6 + default seed cho Admin role✅ Yes
71777870069934insert_pancake_notification_templatesINSERT 3 row notification_template (assigned, unmapped_branch, outage)✅ Yes
81777870069935update_app_setting_pancake_integrationUPDATE app_setting JSON ADD key pancake_integration: { enabled: false, kill_switch: false, ip_whitelist: [] }✅ Yes

Migration template — Idempotent + Pre-check anchor (CLAUDE.md pattern)

sql
-- Example for migration #8 — update_app_setting_pancake_integration/up.sql
DO $$
DECLARE
  target_missing INT;
  affected INT;
BEGIN
  -- 1. Count rows needing update (filter chặt theo runtime usage)
  SELECT COUNT(*) INTO target_missing
  FROM "public"."app_setting"
  WHERE id = 1
    AND NOT (app_settings ? 'pancake_integration');

  -- 2. Idempotent guard
  IF target_missing = 0 THEN
    RAISE NOTICE 'pancake_integration key already present, skipping';
    RETURN;
  END IF;

  -- 3. Pre-check that app_setting row exists (anchor)
  IF NOT EXISTS (SELECT 1 FROM "public"."app_setting" WHERE id = 1) THEN
    RAISE EXCEPTION 'app_setting row id=1 not found — base data missing';
  END IF;

  -- 4. UPDATE with anchor in WHERE
  UPDATE "public"."app_setting"
  SET "app_settings" = jsonb_set(
    "app_settings",
    '{pancake_integration}',
    '{"enabled": false, "kill_switch": false, "ip_whitelist": []}'::jsonb,
    true
  )
  WHERE id = 1
    AND NOT (app_settings ? 'pancake_integration');

  GET DIAGNOSTICS affected = ROW_COUNT;

  -- 5. Defense-in-depth assert
  IF affected <> target_missing THEN
    RAISE EXCEPTION 'pancake_integration update: expected %, got %', target_missing, affected;
  END IF;
END $$;

Thứ tự deploy

1. DB migrations (in order ts 1-8) — apply via Hasura migrate up
2. Hasura metadata apply (cron_triggers.yaml + actions.yaml + table YAMLs)
3. Backend release (services/webhook + crm-api + notification-v2-api) — rolling deploy
4. FE codegen (urql generated types from new GraphQL schema)
5. FE release (diva-admin)
6. Enable feature flag: app_setting.pancake_integration.enabled = false  (still off after deploy)
7. Pilot rollout per W1-W4 (see go-live-checklist E1 Gates)

Backfill normalized_phone capacity

  • Dataset: ~1M account rows (PRD ASM-006)
  • Strategy: Migration #2 batch 10k row/iteration với LIMIT subquery
  • Estimate: 1M × 0.5ms = ~8.3 phút total
  • Mitigation lock contention: Run during low-traffic window (02:00-06:00 ICT)
  • Failure recovery: Re-run idempotent (WHERE normalized_phone IS NULL)

Rollback strategy

  • Mỗi migration có down.sql reversible (DROP COLUMN / DROP CONSTRAINT / DELETE)
  • Feature flag kill switch (app_setting.pancake_integration.enabled=false) là rollback soft không cần migration revert — webhook trả 200 skipped_kill_switch
  • Hard rollback (DROP bảng): chỉ áp dụng nếu data corruption, không cần cho rollback feature

C8) Bảo mật (Security)

Ma trận Dynamic Permission

ModuleActionDefault AdminDefault ManagerDefault TelesaleDefault POS-only
pancake_crm_integrationaccess
pancake_crm_integrationcreate
pancake_crm_integrationupdate
pancake_crm_integrationdelete
pancake_crm_integrationview_all
pancake_crm_integrationreplay_dlq

Default Admin only. Có thể Dynamic Permission UI override sau MVP-1.

Migration permission / metadata delta

Migration #6 (insert_pancake_module_permission):

sql
-- INSERT module + 6 actions
INSERT INTO "public"."module_permission_action" ("module_id", "action_id", "portal", "branch_mode")
VALUES
  ('pancake_crm_integration', 'access', 'admin', 'system-wide'),
  ('pancake_crm_integration', 'create', 'admin', 'system-wide'),
  ('pancake_crm_integration', 'update', 'admin', 'system-wide'),
  ('pancake_crm_integration', 'delete', 'admin', 'system-wide'),
  ('pancake_crm_integration', 'view_all', 'admin', 'system-wide'),
  ('pancake_crm_integration', 'replay_dlq', 'admin', 'system-wide')
ON CONFLICT ("module_id", "action_id") DO NOTHING;

-- Seed default role-permission for Admin role (assuming role_permission table)
INSERT INTO "public"."role_permission" ("role_id", "module_id", "action_id")
SELECT 'Admin', 'pancake_crm_integration', a.action_id
FROM (VALUES ('access'), ('create'), ('update'), ('delete'), ('view_all'), ('replay_dlq')) AS a(action_id)
ON CONFLICT DO NOTHING;

Backend enforcement

LayerCơ chế
Webhook public endpointURL token (random 32-byte hex) + IP whitelist + HMAC (PD-001 TBD W4). Rate limit reverse proxy 1000 req/s/IP. NO Hasura role check (public).
Hasura action pancake_replay_eventPermission admin role in actions.yaml + handler double-check session_variables['x-hasura-role']='admin'
Hasura table query/mutationPermission Admin only (xem C5 Hasura metadata) — Manager/Telesale/POS cannot SELECT bảng pancake_*
api_key_encrypted storagepgcrypto encrypt at rest. Decrypt chỉ trong handler khi gọi REST API. Audit log mọi access.
Audit log toggle kill switchMọi UPDATE pancake_connection.status hoặc app_setting.pancake_integration → log vào audit (existing pattern Diva)

Hasura Row-Level Security

  • Bảng pancake_* không cần row-level filter (Admin scope toàn hệ thống)
  • Bảng ticket Pancake-sourced: telesale chỉ thấy ticket được assign (existing ticket_management.access filter, không cần delta)

Sensitive data redaction

  • Tab 3 detail drawer (Audit): Mặc định hiện full payload cho Admin. Non-admin không có quyền access → không vào được Tab 3.
  • api_key_encrypted: Không render trong FE response (Hasura permission exclude column). Mask ••••XXXX khi display ở Tab 1.
  • Phone number ở audit: Show full cho Admin. Future Dynamic Permission có thể redact last-4-digits cho non-admin (defer MVP-2).

Rate limiting

SurfaceLimitAction khi vượt
Webhook receiver public1000 req/s/IP (reverse proxy Nginx)Drop request (NOT trả 200 — let Pancake retry)
Hasura action pancake_replay_event_bulk100 events/call, sequential 5/giây internalReturn PANCAKE_REPLAY_RATE_LIMIT
Pancake REST API call (Cron 3)gobreaker 5 fail / 60s → open, half-open 30sCircuit open, retry hour sau

C9) Phi chức năng (NFR)

Hiệu năng + index

LoạiTargetCách đoAction nếu fail
Latency webhook receiverp95 < 1s, p99 < 2sPrometheus histogram pancake_webhook_latency_secondsScale crm-api horizontally; optimize INSERT (no FK check at ingest)
Throughput peak sustained100 req/sLoad test K6/JMeter pre-W3Add DB read replica; tune Postgres connection pool
End-to-end p95 (webhook → ticket assigned)≤ 30sFORMULA-001 query dailyOptimize STEP 9 (round-robin query), batch DB reads
DB query latency p95< 100mspg_stat_statementsADD index per slow query
FE Settings UI load< 2sLighthouse auditCode-split per tab, lazy load
Cron 7 reconciliation duration< 30 phútCron metrics + log timingParallelize REST page fetch; optimize compare SQL
DLQ bulk replay (100 events)< 5 phútAction timingReduce batch size to 50; async dispatch

Recommended indexes (đã include ở C4 schema):

sql
-- pancake_webhook_event (5 partial indexes)
idx_pancake_webhook_event_status_created (status, created_at DESC) WHERE status = 'processed'
idx_pancake_webhook_event_status_received (status, created_at DESC) WHERE status IN ('received', 'processing', 'dead_letter')
idx_pancake_webhook_event_record_id (record_id) WHERE record_id IS NOT NULL
idx_pancake_webhook_event_resolved_account (resolved_account_id) WHERE resolved_account_id IS NOT NULL
idx_pancake_webhook_event_is_test (is_test, created_at DESC) WHERE is_test = true

-- account (1 partial)
account_normalized_phone_idx (normalized_phone) WHERE normalized_phone IS NOT NULL

-- ticket (cover SLA query)
idx_ticket_source_first_response (source_id, first_response_at) WHERE source_id = 'ticket_source_pancake'

-- pancake_source_routing
idx_pancake_source_routing_active (is_active, diva_branch_id) WHERE disabled = false

-- pancake_outage_history
idx_pancake_outage_history_started (outage_started_at DESC)

Capacity

  • Volume target: 100k events/tháng = ~3.3k/ngày peak (PRD ASM-006)
  • Storage estimate:
    • Raw payload avg 5 KB × 100k = 500 MB/tháng
    • 6 tháng retention = 3 GB
    • Index overhead ~30% = 4 GB total
  • Archive strategy: Cron pancake_archive_old_events (post-MVP-1) — move events created_at < NOW()-180d AND status='processed' to pancake_webhook_event_archive table
  • Memory webhook handler: < 256 MB per pod (Prometheus container_memory_usage_bytes)
  • Connection pool: Tune Postgres max_connections to handle peak 100 req/s × 50ms = 5 concurrent + slack 20 = 25 conn dedicated webhook service

NFR table

LoạiTargetCách đoAction nếu fail
Latency webhook receiverp95 < 1s, p99 < 2sLog timing in handlerScale crm-api horizontally
Throughput peak100 req/s sustainedLoad test K6/JMeterAdd DB read replica
DB capacity100k events/thángStorage calc + index sizeArchive old events sau 6 tháng
Pancake suspension count0 lần/quarterpancake_connection.status historyAlert + ops on-call
Memory webhook< 256 MB per podPrometheusTune Go runtime GC
Cron 7 duration< 30 phútCron metricsOptimize query, parallel
DLQ bulk replay< 5 phút cho 100 eventsAction timingReduce batch size
FE bundle size< 100 KB delta (4 tab + GraphQL)Webpack bundle analyzerCode split per tab
Migration backfill duration< 15 phút for 1M rowsMigration logRun as external Go job

C10) Quan sát vận hành (Observability)

Metrics (Prometheus)

# Counter — total requests
pancake_webhook_requests_total{status, source_ip} counter
pancake_event_processed_total{result, branch_id} counter
pancake_dlq_replay_total{outcome} counter
pancake_rest_api_calls_total{endpoint, status} counter

# Histogram — latency
pancake_webhook_latency_seconds histogram (buckets: 0.1, 0.5, 1, 2, 5)
pancake_event_process_duration_seconds{status} histogram (buckets: 1, 5, 10, 30, 60)
pancake_rest_api_duration_seconds{endpoint} histogram

# Gauge — current state
pancake_circuit_breaker_state{name="pancake_rest"} gauge (0=closed, 1=half-open, 2=open)
pancake_outage_active gauge (1 if outage_started)
pancake_dlq_count gauge (current count of status='dead_letter')
pancake_polling_interval_seconds gauge (current Cron 6 interval)
pancake_reconciliation_missed_events gauge (last Cron 7 result)
pancake_connection_status{workspace} gauge (1=active, 0=others)

Logs (structured, zerolog)

LevelTình huốngFields bắt buộc
ERRORstatus='dead_letter', status='auth_failed' > 10/phút, outage_started, Pancake REST 401pancake_event_id, record_id, error_code, error_message, retry_count
WARNcircuit_breaker_open, rate_limit_429, parse_error rate > 5%, reconciliation_missed > 5/ngàypancake_event_id, source_ip, event_type
INFOprocessed, replay success, sync source completed, cron run start/endpancake_event_id, account_id, branch_id, status, duration_ms
DEBUGSTEP entry/exit, advisory lock acquired, smart update decisionpancake_event_id, step

Common fields cho mọi Pancake log:

  • service: webhook | crm-api
  • pancake_event_id: UUID
  • record_id: Pancake record ID (nullable)
  • account_id: Diva account UUID
  • branch_id: Diva branch UUID (nullable in Loose mode)
  • status: lifecycle state
  • error_code: from C5 error table

Alerts (alertmanager)

SeverityRuleNotification target
Criticalpancake_webhook_error_rate > 10% for 1 phútOps on-call (PagerDuty/Telegram)
Criticalpancake_outage_active = 1 for 5 phútOps on-call
Criticalpancake_connection_status = 0 AND status='suspended_by_pancake'Ops on-call + Tech Lead
Warningpancake_event_success_rate < 99% for 10 phútOps Telegram
Warningpancake_dlq_count > 50Admin email + Telegram
Warningpancake_reconciliation_missed > 5 events/ngàyAdmin email
Warningpancake_circuit_breaker_state = 2 for 5 phútTech Lead
Infopancake_polling_interval_seconds > 60 (degraded fallback active)Dashboard only

Dashboard / Runbook (L+ khuyến nghị)

Grafana board "Pancake CRM Integration" (post-MVP-1):

  • Section 1: KPI 5 metric — Latency p95 / SLA p95 / Auto-assign rate / Event success rate / Zero-miss
  • Section 2: Volume — Events/giờ per source / per branch (heatmap)
  • Section 3: Outage tracking — Outage history timeline + MTTR per incident
  • Section 4: DLQ queue — Current count + replay rate / day
  • Section 5: REST API health — Circuit breaker state, latency, error rate per endpoint
  • Section 6: Audit funnel — ingested → received → processed (drop-off per step)

Runbook:

  • RUNBOOK-001: Pancake suspended webhook recovery (manual re-enable steps)
  • RUNBOOK-002: DLQ event triage (admin replay vs permanently_failed)
  • RUNBOOK-003: Outage detected — verify Pancake side vs Diva side
  • RUNBOOK-004: Reconciliation reported missed events (Cron 7) — investigate root cause

C11) Danh sách việc triển khai (Tasks)

Tổng: ~37 dev-days breakdown. Parallel: 2 BE + 1 FE + 1 QA. Calendar: 5.5-6 tuần (W1 pilot launch + W2-W3 iterate + W4 production).

Giai đoạn 1 — Foundation (W1, ~10 dev-days)

#Hạng mụcEffortOwnerPhụ thuộc
1Schema 4 bảng pancake_* + ALTER account + ALTER contact_book + backfill normalized_phone + INSERT master data1.5dBE-1
2pkg/pancake/ REST client + phone_normalize + advisory_lock helper1.5dBE-1
3services/webhook/handler/pancake.go (handler + verify + dedup + fail-open)2dBE-1#1
4services/crm-api/event/event_pancake_process_record.go (15 STEP)3.5dBE-2#1, #2
5Refactor GetTicketUpdates thành public callable helper1dBE-2
6F18 FE delta (types.ts + vi.ts + sourceDescriptions + all surfaces grep audit)1dFE#1
7Hasura event trigger config + 4 metadata YAML + permissions0.5dBE-1#1
8Confirm consent_data.marketing key (PD-003) + build customer_consent Go struct0.75dBE-1 + PO

Giai đoạn 2 — Recovery + UI (W2-W3, ~16 dev-days)

#Hạng mụcEffortOwnerPhụ thuộc
9Cron 3 pancake_source_sync_hourly + Pancake REST client integration1dBE-1#2
10Cron 4 pancake_outage_detect (state machine)1dBE-1#1
11Cron 6 pancake_adaptive_polling fallback3dBE-1#10
12Cron 7 pancake_daily_reconciliation (25h window)2dBE-2#2
13Notification integration (3 template seed + handler call)1.5dBE-2#1, #4
14Circuit breaker sony/gobreaker REST (DEC-019) + tests1dBE-1#2
15Settings UI Tab 1 Connection (form, test connection action)1.5dFE#7
16Settings UI Tab 2 Source Routing (autosave per-row, table inline-edit)1.5dFE#7
17Settings UI Tab 3 Audit + DLQ (list + drawer + replay action)1.5dFE#18
18Hasura action pancake_replay_event + bulk replay1dBE-2#1
19Backfill custom window endpoint + UI modal (post-outage manual replay range)1.5dBE-2 + FE#4

Giai đoạn 3 — Production hardening (W4, ~11 dev-days)

#Hạng mụcEffortOwnerPhụ thuộc
20Cron pancake_dlq_retry (auto retry retry_count < 3)0.5dBE-2#4
21Cron pancake_test_event_cleanup (soft-delete is_test=true sau 24h)0.5dBE-2#1
22Health monitoring + Prometheus metrics + alertmanager rules2.5dBE-1 + DevOps#4, #11
23Settings UI Tab 4 Health (metrics card + outage history + alert config)0.5dFE#22
24HMAC signature verify (PD-001 resolved) + IP whitelist enforcement1dBE-1PD-001/PD-002
25Test (unit + integration + E2E + load + chaos outage + duplicate stress)6dQA + DevAll
26Pilot W1 launch + bug fix + iterate2dAllAll
27Pilot W2-W4 iterate (chaos test fake outage + duplicate retry)4dAll#26

Sơ đồ phụ thuộc

W1 ─────────────────────────────────────────────
#1 Schema ──┬─→ #3 webhook handler
            ├─→ #4 event handler ──┬─→ #18 replay action
            ├─→ #7 Hasura YAML     ├─→ #13 notification
            └─→ #6 FE delta        └─→ #20 DLQ cron
#2 pkg/pancake ──┬─→ #3, #4
                 ├─→ #9 Cron 3
                 ├─→ #12 Cron 7
                 └─→ #14 circuit breaker
#5 Refactor GetTicketUpdates ──→ #4 STEP 9
#8 PD-003 confirm ──→ #4 STEP 7

W2-W3 ──────────────────────────────────────────
#9 Cron 3 ──→ #15 Tab 1 (test connection)
#10 Cron 4 ──→ #11 Cron 6 ──→ #22 metrics
#12 Cron 7
#17 Tab 3 audit ──→ #19 backfill custom window
#18 Replay action ──→ #17 Tab 3

W4 ──────────────────────────────────────────────
#22 metrics ──→ #23 Tab 4 health
#24 HMAC + IP (PD-001/002 resolved)
#25 Test (concurrent với pilot)
#26 W1 pilot (live!) → #27 W2-W4 iterate

C12) Truy vết (Traceability)

Matrix FR → File/Component → Test Case

FRFile / handler / componentAPI / endpointTest Case (xem qa-test-plan.md)
FR-001 webhook receiverservices/webhook/handler/pancake.goPOST /api/pancake/record/{token}TC-001-001..008 (auth, IP, parse, dup, kill, source disabled, happy, load 100 req/s)
FR-002 persist raw eventpkg/store/pancake_webhook_event.go (INSERT)(Direct DB)TC-002-001..003 (audit fields, raw payload, headers)
FR-003 idempotency 3-tupleUNIQUE constraint pancake_webhook_event_idempotency_uniqueINSERT ON CONFLICTTC-003-001..002 (duplicate same → skipped, retry update last_received_at)
FR-004 async processorservices/crm-api/event/event_pancake_process_record.goHasura trigger pancake_webhook_event_status_updateTC-004-001..010 (15 STEP coverage, panic recovery, dead_letter)
FR-005 phone normalize + matchpkg/pancake/phone_normalize.go + STEP 2, 5(helper)TC-005-001..005 (E.164 +84, fallback raw, edge format, NULL phone)
FR-006 advisory lockpkg/pancake/advisory_lock.go + STEP 3 SQL pancake_advisory_lock_account(Transaction-scoped)TC-006-001 (concurrent 10 webhook cùng phone → 1 account)
FR-007 auto-create contactSTEP 5b event_pancake_process_record.goINSERT contact_bookTC-007-001..003 (new account → contact created, existing account → skip, race serialized)
FR-008 round-robin assignservices/crm-api/scheduler/distribute_ticket.go:157 (refactored public)Helper callTC-008-001..005 (round-robin, Loose mode NULL branch, no-active-telesale → NULL)
FR-009 smart updateSTEP 8 logic(in-handler)TC-009-001..006 (phone change/source new/VIP tag new → ticket; same payload → no ticket)
FR-010 create ticketSTEP 10INSERT ticketTC-010-001..004 (source_id, due_date, target_id telesales, input_note populated)
FR-011 notificationSTEP 12 + 3 templateHasura action sendNotificationsTC-011-001..003 (telesale push, admin unmapped, ops outage)
FR-012 Settings UI 4 tabsdiva-admin/src/modules/settings/pages/pancake/*.tsxGraphQL queriesTC-012-001..020 (Tab 1-4 render, autosave, test connection, replay, health metrics)
FR-013 REST consumptionpkg/pancake/client.go + Cron 3 + sony/gobreakerPancake REST GET /workspaces/{ws}/sourcesTC-013-001..005 (happy, timeout, 401, 429, circuit breaker open)
FR-014 outage recoveryCron 4/6/7 + DLQ replay actionSchedulers + action pancake_replay_eventTC-014-001..015 (chaos test fake outage, polling adaptive, reconciliation inject)
FR-015 feature flagapp_setting + pancake_connection.status + pancake_source_routing.is_active(config)TC-015-001..003 (3 mức toggle independent)
FR-016 test modeis_test=true flag + Cron cleanup(in-handler + cron)TC-016-001..003 (flag set, KPI exclude, cleanup soft-delete 24h)
FR-017 opt-outSTEP 7(in-handler)TC-017-001..002 (consent.marketing=false → skipped_opt_out + account updated)
FR-018 FE deltadiva-admin/src/modules/crm/types.ts + i18n/vi.ts + sourceDescriptions(FE delta)TC-018-001..002 (dropdown render slot 9, all surfaces auto-render, no hardcoded slot 1..8 in report module)

Mapping quyết định (DEC) → triển khai → cách kiểm chứng

DECTriển khaiCách kiểm chứng
DEC-001 Inbound 1 chiều MVP-1KHÔNG có code outbound, KHÔNG endpoint POST recordsCode review + grep client.PostRecord returns zero
DEC-002 Split webhook + crm-apiservices/webhook/handler/pancake.go + services/crm-api/event/event_pancake_process_record.goTC-001 (webhook 200<1s) + TC-004 (handler async qua Hasura trigger)
DEC-003 Fail-open 200Webhook handler always c.JSON(200, ...) mọi caseTC-001-001 (auth fail 200), TC-001-002 (IP block 200), TC-001-003 (parse error 200)
DEC-004 Idempotency 3-tupleDB UNIQUE constraint pancake_webhook_event_idempotency_unique (record_id, modified_on, payload_hash)TC-003-001 (duplicate skipped), TC-003-002 (UPDATE last_received_at on dup)
DEC-005 Phone E.164pkg/pancake/phone_normalize.go reuse libphonenumberTC-005-001..005 (parse VN +84, fallback, edge)
DEC-006 ALTER contact_bookMigration ts 1777870069930 (ADD primary_phone + FK + UNIQUE)TC-007-001..003 (auto-create contact, FK enforce, UNIQUE prevent dup) + migration test up/down
DEC-007 4 bảng disabled BOOLEANMigration ts 1777870069931 (audit fields với disabled, no deleted_at)Schema verify test: \d pancake_webhook_event → has disabled, no deleted_at
DEC-008 VIP tag NAME matchSTEP 8 pancake.MatchVIPTags case-insensitive text compareTC-009-003 (VIP tag new "Hot Lead" → ticket; "hot lead" lowercase → match)
DEC-009 Test mode 24h soft-deleteis_test BOOLEAN flag + Cron pancake_test_event_cleanup 04:00 AMTC-016-001..003 (flag respect, cleanup 24h boundary)
DEC-010 Loose mode admin push onlySTEP 12 logic + template noti_pancake_unmapped_branchTC-011-002 (branch=NULL → push admin, no email/SMS)
DEC-011 Smart update Q2.bSTEP 8 logic compare phone/source/VIP-tagTC-009-001..006 (full coverage)
DEC-012 REUSE GetTicketUpdatesRefactor public + STEP 9 callTC-008-001..005 + cron distribute_ticket regression test
DEC-013 1-tier round-robinSTEP 9 logic single-tierTC-008-002 (no-active-telesale → NULL, no fallback to other branch)
DEC-014 Opt-out via customer_consentSTEP 7 query consent_data->>'marketing'TC-017-001..002 (false → skip, true → process, NULL → process)
DEC-015 4-layer recoveryCron 4/6/7 + DLQ replayTC-014-001..015 (chaos test outage simulation, reconciliation inject, DLQ replay)
DEC-016 Settings UI 4 tabs Admin onlyFE permission pancake_crm_integration.access + Hasura permission AdminTC-012-018 (non-admin no menu, gõ URL → redirect)
DEC-017 Slot 9 ticket_source_pancakeMigration #5 + FE delta 3 fileTC-010-001 (INSERT ticket with source_id) + TC-018-001 (dropdown render)
DEC-018 REST chỉ /sourcesCron 3 only call /workspaces/{ws}/sourcesTC-013-001 + grep client.Get returns only /sources endpoint
DEC-019 Circuit breaker sony/gobreakerpkg/pancake/client.go wrap REST callTC-013-003..005 (open after 5 fail, half-open, retry)
DEC-020 Advisory lock per phoneSTEP 3 pancake_advisory_lock_account(normalized_phone)TC-006-001 (concurrent 10 webhook → 1 account, no duplicate)
DEC-021 Feature flag 3 mứcapp_setting + pancake_connection.status + pancake_source_routing.is_activeTC-015-001..003 (kill switch off → skipped_kill_switch; connection paused → skipped; source disabled → skipped_source_disabled)
DEC-022 Pilot 4 tuầnFeature flag control + monitoring metricsgo-live-checklist E1 W1-W4 gates + Grafana board section 2
DEC-023 KPI 5 metricA8 metrics queries (FORMULA-001..005)Grafana board + daily report cron
DEC-024 3 notification templateMigration #7 INSERT + STEP 12 handler callTC-011-001..003 (each template renders + sends)
DEC-025 25h reconciliation windowCron 7 SQL window NOW()-25hTC-014-008..010 (missed event detected, injected, no duplicate)

Hoàn thành Dev Spec L. Mọi business rationale → PRD; mọi UI detail → ui-spec; mọi test detail → qa-test-plan. Mọi schema delta + flow pseudocode + migration sequence + DEC traceability đã canonical ở file này.


C13) Pass 1 Resolutions (Phase 5.2 Multi-Perspective Review fixes)

Date: 15/05/2026 | Trigger: Tech Lead BLOCK verdict + FE codebase verify. Section này OVERRIDE conflict với C1-C12 gốc. Bao gồm:

  • C13.1 Architecture refactor — Direct pgxpool (DEC-028)
  • C13.2 Sweep Hasura YAML conventions CRM_BASE_URL (Tech Lead P0-3)
  • C13.3 Migration sequence revised (drop primary_phone ADD COLUMN, external backfill job)
  • C13.4 Idempotency UNIQUE NULL handling (NULLS NOT DISTINCT hoặc partial index)
  • C13.5 Cron 6 fixed schedule + handler throttle
  • C13.6 Cron 3 ON CONFLICT DO NOTHING (PO/BA CI-4)
  • C13.7 F18 = 4 file (thêm CustomerTicketManager.tsx)
  • C13.8 Component library audit (8 rename + 2 build-new)
  • C13.9 STATE-001 trigger filter OLD.status check
  • C13.10 DLQ replay permission update reuse (DEC-027)
  • C13.11 New tasks C11 delta (+7.25d effort)

C13.1) Architecture refactor — Direct pgxpool (DEC-028)

Spec cũ C5 giả định tx, err := db.BeginTx(ctx, nil) — không khả thi với Hasura layer hiện tại (grep "database/sql" services/ = 0).

Resolution Option A: Dedicated pgxpool.Pool riêng cho Pancake module.

go
// pkg/pancake/db.go (NEW — ~80 lines)
package pancake

import (
  "context"
  "github.com/jackc/pgx/v5/pgxpool"
)

var pool *pgxpool.Pool

func InitDB(ctx context.Context, dbURL string) error {
  var err error
  pool, err = pgxpool.New(ctx, dbURL) // env: PANCAKE_DB_URL
  if err != nil { return err }
  return pool.Ping(ctx)
}

func Pool() *pgxpool.Pool { return pool }

Update event handler services/crm-api/event/event_pancake_process_record.go:

go
func EventPancakeProcessRecord(ctx context.Context, payload EventPayload) error {
  // STEP 0: filter — OLD.status check (C13.9)
  old := payload.Event.Data.Old
  if old == nil || old.Status != "ingested" { return nil }
  if payload.Event.Data.New.Status != "received" || payload.Event.Data.New.RecordID == nil { return nil }

  tx, err := pancake.Pool().Begin(ctx)
  if err != nil { return err }
  defer tx.Rollback(ctx)

  // STEP 1.5: re-fetch FOR UPDATE defense
  var event PancakeWebhookEvent
  err = tx.QueryRow(ctx, "SELECT id, status, record_id, raw_payload FROM pancake_webhook_event WHERE id=$1 FOR UPDATE", payload.Event.Data.New.ID).Scan(...)
  if err != nil { return err }
  if event.Status != "received" { return nil }

  _, err = tx.Exec(ctx, "UPDATE pancake_webhook_event SET status='processing', processing_started_at=NOW() WHERE id=$1", event.ID)
  // STEP 2-3: Phone normalize + advisory lock
  normalizedPhone, _ := pancake.NormalizePhone(event.Payload.PhoneNumber, "VN")
  _, err = tx.Exec(ctx, "SELECT pg_advisory_xact_lock(hashtext($1))", "account_phone_"+normalizedPhone)
  // STEP 4-13: business logic via tx.Exec / tx.QueryRow
  // ...
  err = tx.Commit(ctx)
  if err != nil { return err }

  // STEP 15 (POST-COMMIT): notification qua Hasura action (non-atomic — accepted trade-off)
  go func() {
    callHasuraAction(ctx, "sendNotifications", notificationPayload)
  }()
  return nil
}

Deploy config: ADD env PANCAKE_DB_URL cho crm-api service.

C13.2) Sweep Hasura YAML conventions

Pattern cũCorrect
/actions/pancake_replay_event/actions (suffix-less, dispatcher routes theo action name)
webhook_from_env: CRM_API_BASE_URLwebhook: "/events" (string template)

Verified: grep "CRM_BASE_URL" actions.yaml = 8 occurrences. Sweep áp dụng cho C5 toàn bộ YAML example + cron config + event trigger.

C13.3) Migration sequence REVISED

#MigrationChange từ Pass 0
21777870069929_backfill_normalized_phoneREMOVE DO $$ block trong migration. Move backfill ra EXTERNAL Go job chạy off-peak window 02:00-06:00 ICT với batch 10k/tx + pg_sleep(0.1) giữa batches. Migration #2 chỉ tạo helper SQL function pancake_normalize_phone_helper(phone_code, phone_number). Owner BE-1, Sprint 1.
31777870069930_alter_table_public_contact_book_*DROP ADD COLUMN primary_phone (đã exist từ migration 1693889973118). CHỈ ADD UNIQUE (account_id) constraint sau cleanup duplicate. KHÔNG ADD FK tới account.id (Hasura manual_configuration hiện tại đang map tới ecommerce_user). Pre-script: detect duplicate SELECT account_id, COUNT(*) FROM contact_book GROUP BY account_id HAVING COUNT(*) > 1 — manual cleanup nếu có
41777870069931_create_pancake_tablesUNIQUE 3-tuple dùng NULLS NOT DISTINCT (PG 15+) hoặc partial index (PG <15). Xem C13.4
51777870069932_insert_ticket_source_pancakeChuẩn bị down-soft.sql: UPDATE crm_master_data SET disabled=true WHERE id='ticket_source_pancake' thay vì DELETE (rollback safety)
61777870069933_insert_pancake_module_permission5 actions (drop replay_dlq per DEC-027). Verify module_permission_action table schema column names trước Sprint 1

C13.4) Idempotency UNIQUE NULL handling

sql
-- Option A (PG 15+):
CREATE UNIQUE INDEX pancake_webhook_event_idem_unique
  ON pancake_webhook_event (record_id, modified_on, payload_hash)
  NULLS NOT DISTINCT;

-- Option B (PG <15):
CREATE UNIQUE INDEX pancake_webhook_event_idem_unique
  ON pancake_webhook_event (record_id, modified_on, payload_hash) WHERE record_id IS NOT NULL;
CREATE UNIQUE INDEX pancake_webhook_event_idem_null_unique
  ON pancake_webhook_event (payload_hash) WHERE record_id IS NULL;

Verify PG version Diva production trước W1.

C13.5) Cron 6 fixed schedule + handler throttle

yaml
# cron_triggers.yaml
- name: pancake_adaptive_polling
  webhook: "{{CRM_BASE_URL}}/schedulers"
  schedule: "* * * * *"   # every 1 min FIXED
  payload: { action: pancake_adaptive_polling }
  retry_conf: { num_retries: 0, timeout_seconds: 60 }
go
// scheduler/pancake_adaptive_polling.go
func PancakeAdaptivePolling(ctx context.Context) error {
  state := getOutageState()
  if state.Status != "outage_started" { return nil }
  if time.Since(state.LastPolledAt) < time.Duration(state.CurrentPollingIntervalSec)*time.Second {
    return nil // not yet due
  }
  records, err := pancakeClient.GetRecordsModifiedSince(state.LastEventReceivedAt)
  for _, rec := range records { insertPancakeWebhookEvent(rec, "polled") }
  updateOutageState(time.Now(), increasedInterval(state.CurrentPollingIntervalSec))
  return nil
}

C13.6) Cron 3 ON CONFLICT DO NOTHING (PO/BA CI-4)

sql
INSERT INTO pancake_source_routing (pancake_source_id, pancake_source_name, diva_branch_id, is_active, last_synced_at)
VALUES ($1, $2, NULL, false, NOW())
ON CONFLICT (pancake_source_id) DO UPDATE
  SET pancake_source_name = EXCLUDED.pancake_source_name,
      last_synced_at = NOW();
-- KHÔNG TOUCH diva_branch_id hoặc is_active

C13.7) F18 = 4 file (FE P0-FE-2)

Verified: CustomerTicketManager.tsx:111-158bản sao sourceDescriptions. F18 delta đầy đủ:

#FileDelta
1crm/types.ts:146-164ADD const + array entry
2crm/i18n/vi.ts:129-138ADD nested key crm.label.ticket.ticket_source.ticket_source_pancake
3crm/pages/Tickets.tsx:128-158ADD entry sourceDescriptions
4user/components/customer/CustomerTicketManager.tsx:111-158NEW — ADD entry sourceDescriptions (file thứ 4 đã miss)

P1 refactor: extract sourceDescriptions thành single-source crm/types.ts (defer M2).

C13.8) Component library audit (FE P0-FE-3)

Spec cũActualAction
XPasswordXInputPasswordRename
XTextarea(no) — QInput type="textarea"Quasar primitive
XBadge(no) — QBadgeQuasar
XDrawer(no) — QDrawer hoặc QDialog seamlessVerify
XSearchInput(no) — XInput + clear iconUse existing
XDateRangePickerXInputDateRangeRename
XJsonViewerBUILD NEW (~1d FE) — <pre> + JSON.stringify(payload, null, 2) + copy + collapseNEW components/core/display/XJsonViewer.tsx
XMetricCardBUILD NEW (~0.5d FE) — card với label + value + icon trend + tooltipNEW components/core/display/XMetricCard.tsx
XChartREUSE modules/dashboard/components/{BarChart,LineChart}Update C6 ref

C13.9) STATE-001 trigger filter OLD.status check

go
old := payload.Event.Data.Old
new := payload.Event.Data.New
if old == nil || old.Status != "ingested" { return nil }
if new.Status != "received" || new.RecordID == nil { return nil }
// proceed STEP 1+

Tránh loop khi Hasura watch columns: [status] fires mọi UPDATE status.

C13.10) DLQ replay permission update reuse (DEC-027)

yaml
- name: pancake_replay_event
  definition: { handler: "{{CRM_BASE_URL}}/actions" }
  permissions: [{ role: admin }]
go
if !ctx.HasPermission("pancake_crm_integration", "update") {
  return errors.New("UNAUTHORIZED")
}

C13.11) New tasks C11 delta (+7.25d)

#Hạng mụcEffortOwnerSprint
NEW-1pkg/pancake/db.go pgxpool + env setup2dBE-1W1
NEW-2External backfill Go job0.5dBE-1W1
NEW-3Build XJsonViewer.tsx1dFEW2
NEW-4Build XMetricCard.tsx0.5dFEW2
NEW-5Cleanup contact_book duplicate audit script0.25dBE-1W1
NEW-6pancake_replay_event permission refactor (use update)0.25dBE-1 + FEW2
NEW-7F18 4th file delta0.25dFEW1
NEW-83 alertmanager rules thêm0.25dDevOpsW3
NEW-9Deploy step 9 split 9a/9b0.25dDevOpsW3
NEW-10down-soft.sql rollback safety0.25dBE-1 + FEW1
NEW-11HMAC verification path spec0.5dTL + OpsW3
NEW-12Permission audit lint script0.5dTLW3
NEW-13Rollback drill rehearsal staging1dBE-1 + DevOpsW3
Tổng+7.25d

New total estimate: ~44.25d (vs 37d cũ). Calendar 5.5-6 tuần OK với 2 BE + 1 FE + 1 QA + 1 Ops.


Hết Pass 1 Resolutions Dev Spec. Sẵn sàng BE Dev implement W1 Sprint 1 với architecture DEC-028 chốt.