From e772311a86f3ccbe352b80127c0c1721a3b91d6f Mon Sep 17 00:00:00 2001 From: Christian Date: Sun, 22 Feb 2026 03:27:40 +0100 Subject: [PATCH] Release v2.2.2: sync safety hardening --- .env.prod.example | 2 + DEPLOYMENT_CHECKLIST.md | 3 + PRODUCTION_QUICK_START.md | 1 + RELEASE_NOTES_v2.2.2.md | 38 +++++++++++ VERSION | 2 +- app/settings/frontend/settings.html | 63 ++++++++++++++----- app/system/backend/sync_router.py | 58 ++++++++++++----- ...8_customers_economic_unique_constraint.sql | 40 ++++++++++++ 8 files changed, 176 insertions(+), 31 deletions(-) create mode 100644 RELEASE_NOTES_v2.2.2.md create mode 100644 migrations/138_customers_economic_unique_constraint.sql diff --git a/.env.prod.example b/.env.prod.example index 2e23242..5f8a61f 100644 --- a/.env.prod.example +++ b/.env.prod.example @@ -38,6 +38,8 @@ GITHUB_TOKEN=your_gitea_token_here # ===================================================== # API CONFIGURATION - Production # ===================================================== +# Stack name used by deployment scripts to name containers +STACK_NAME=prod API_HOST=0.0.0.0 API_PORT=8000 API_RELOAD=false diff --git a/DEPLOYMENT_CHECKLIST.md b/DEPLOYMENT_CHECKLIST.md index dc16ff7..948bb09 100644 --- a/DEPLOYMENT_CHECKLIST.md +++ b/DEPLOYMENT_CHECKLIST.md @@ -114,6 +114,9 @@ SECRET_KEY=$(python3 -c "import secrets; print(secrets.token_urlsafe(32))") # 5. CORS Origins (production domain) CORS_ORIGINS=https://hub.bmcnetworks.dk +# 5b. Stack name (used by deployment scripts for container names) +STACK_NAME=prod + # 6. e-conomic Credentials (hvis relevant) ECONOMIC_APP_SECRET_TOKEN=xxxxx ECONOMIC_AGREEMENT_GRANT_TOKEN=xxxxx diff --git a/PRODUCTION_QUICK_START.md b/PRODUCTION_QUICK_START.md index 1f8ff1e..2e2e7e1 100644 --- a/PRODUCTION_QUICK_START.md +++ b/PRODUCTION_QUICK_START.md @@ -50,6 +50,7 @@ DATABASE_URL=postgresql://bmc_hub_prod:din_stærke_password_her@postgres:5432/bm SECRET_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # API +STACK_NAME=prod API_PORT=8000 CORS_ORIGINS=http://172.16.31.183:8001 diff --git a/RELEASE_NOTES_v2.2.2.md b/RELEASE_NOTES_v2.2.2.md new file mode 100644 index 0000000..3e3aa8b --- /dev/null +++ b/RELEASE_NOTES_v2.2.2.md @@ -0,0 +1,38 @@ +# BMC Hub v2.2.2 - Sync Safety Release + +**Release Date:** 22. februar 2026 + +## 🛡️ Critical Fixes + +### e-conomic Customer Sync Mapping +- **Fixed ambiguous matching**: e-conomic sync now matches customers only by `economic_customer_number` +- **Removed unsafe fallback in this flow**: CVR/name fallback is no longer used in `/api/v1/system/sync/economic` +- **Added conflict-safe behavior**: if multiple local rows share the same `economic_customer_number`, the record is skipped and logged as conflict (no overwrite) +- **Improved traceability**: sync logs now include the actual local customer id that was updated/created + +### Settings Sync UX +- **Aligned frontend with backend response fields** for vTiger/e-conomic sync summaries +- **Improved 2FA error feedback** in Settings sync UI when API returns `403: 2FA required` +- **Fixed sync stats request limit** to avoid API validation issues +- **Temporarily disabled CVR→e-conomic action** in Settings UI to prevent misleading behavior +- **Clarified runtime config source**: sync uses environment variables (`.env`) at runtime + +## 🗄️ Database Safety + +### New Migration +- Added migration: `migrations/138_customers_economic_unique_constraint.sql` +- Normalizes empty/whitespace `economic_customer_number` values +- Adds a partial unique index on non-null `economic_customer_number` +- Migration aborts with clear error if duplicates already exist (manual dedupe required before rerun) + +## ⚠️ Deployment Notes + +- Run migration `138_customers_economic_unique_constraint.sql` before enabling broad sync operations in production +- If migration fails due to duplicates, deduplicate `customers.economic_customer_number` first, then rerun migration +- Existing 2FA API protection remains enabled + +## ✅ Expected Outcome + +- Sync payload and DB target row are now consistent in the e-conomic flow +- Incorrect overwrites caused by weak matching strategy are prevented +- Future duplicate `economic_customer_number` values are blocked at database level diff --git a/VERSION b/VERSION index 3e3c2f1..b1b25a5 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.1.1 +2.2.2 diff --git a/app/settings/frontend/settings.html b/app/settings/frontend/settings.html index 09cf42b..46e1922 100644 --- a/app/settings/frontend/settings.html +++ b/app/settings/frontend/settings.html @@ -636,6 +636,10 @@
Data Synkronisering

Synkroniser firmaer og kontakter fra vTiger og e-conomic

+
+ + Sync bruger integration credentials fra miljøvariabler (.env) ved runtime. +
@@ -733,16 +737,16 @@
-
Sync fra e-conomic
-

Hent kundenumre fra e-conomic. Matcher på CVR nummer eller firma navn.

+
Sync fra e-conomic
+

Hent kunder fra e-conomic. Matcher kun på entydigt e-conomic kundenummer.

-
@@ -750,6 +754,10 @@ Sidst synkroniseret: Aldrig
+
+ + CVR-søgning er slået fra midlertidigt for stabil drift. +
@@ -3256,7 +3264,7 @@ let syncLog = []; async function loadSyncStats() { try { - const response = await fetch('/api/v1/customers?limit=10000'); + const response = await fetch('/api/v1/customers?limit=1000'); if (!response.ok) throw new Error('Failed to load customers'); const data = await response.json(); const customers = data.customers || []; @@ -3321,6 +3329,25 @@ function addSyncLogEntry(title, message, status = 'info') { loadSyncLog(); } +async function parseApiError(response, fallbackMessage) { + let detailMessage = fallbackMessage; + + try { + const errorPayload = await response.json(); + if (errorPayload && errorPayload.detail) { + detailMessage = errorPayload.detail; + } + } catch (parseError) { + // Keep fallback message + } + + if (response.status === 403 && detailMessage === '2FA required') { + return '2FA kræves for sync API. Aktivér 2FA på din bruger og log ind igen.'; + } + + return detailMessage; +} + async function syncFromVtiger() { const btn = document.getElementById('btnSyncVtiger'); btn.disabled = true; @@ -3334,16 +3361,16 @@ async function syncFromVtiger() { }); if (!response.ok) { - const error = await response.json(); - throw new Error(error.detail || 'Sync fejlede'); + const errorMessage = await parseApiError(response, 'Sync fejlede'); + throw new Error(errorMessage); } const result = await response.json(); const details = [ `Behandlet: ${result.total_processed || 0}`, - `Oprettet: ${result.created || 0}`, + `Linket: ${result.linked || 0}`, `Opdateret: ${result.updated || 0}`, - `Sprunget over: ${result.skipped || 0}` + `Ikke fundet/sprunget over: ${result.not_found || 0}` ].join(' | '); addSyncLogEntry( 'vTiger Sync Fuldført', @@ -3377,8 +3404,8 @@ async function syncVtigerContacts() { }); if (!response.ok) { - const error = await response.json(); - throw new Error(error.detail || 'Sync fejlede'); + const errorMessage = await parseApiError(response, 'Sync fejlede'); + throw new Error(errorMessage); } const result = await response.json(); @@ -3418,16 +3445,17 @@ async function syncFromEconomic() { }); if (!response.ok) { - const error = await response.json(); - throw new Error(error.detail || 'Sync fejlede'); + const errorMessage = await parseApiError(response, 'Sync fejlede'); + throw new Error(errorMessage); } const result = await response.json(); const details = [ `Behandlet: ${result.total_processed || 0}`, - `Nye matchet: ${result.matched || 0}`, - `Verificeret: ${result.verified || 0}`, - `Ikke matchet: ${result.not_matched || 0}` + `Oprettet: ${result.created || 0}`, + `Opdateret: ${result.updated || 0}`, + `Konflikter: ${result.conflicts || 0}`, + `Sprunget over: ${result.skipped || 0}` ].join(' | '); addSyncLogEntry( 'e-conomic Sync Fuldført', @@ -3449,6 +3477,9 @@ async function syncFromEconomic() { } async function syncCvrToEconomic() { + showNotification('CVR→e-conomic sync er midlertidigt deaktiveret.', 'info'); + return; + const btn = document.getElementById('btnSyncCvrEconomic'); btn.disabled = true; btn.innerHTML = 'Søger...'; diff --git a/app/system/backend/sync_router.py b/app/system/backend/sync_router.py index 9fd1863..b04d532 100644 --- a/app/system/backend/sync_router.py +++ b/app/system/backend/sync_router.py @@ -24,7 +24,7 @@ HUB owns (manual or first-sync only): SYNC RULES: =========== 1. NEVER overwrite source ID if already set (vtiger_id, economic_customer_number) -2. Match order: CVR → Source ID → Name (normalized) +2. Matching is source-specific (e-conomic: strict economic_customer_number only) 3. Re-sync is idempotent - can run multiple times safely 4. Contact relationships are REPLACED on sync (not added) 5. Each sync only updates fields it owns @@ -475,9 +475,11 @@ async def sync_from_economic() -> Dict[str, Any]: created_count = 0 updated_count = 0 skipped_count = 0 + conflict_count = 0 for eco_customer in economic_customers: - customer_number = eco_customer.get('customerNumber') + customer_number_raw = eco_customer.get('customerNumber') + customer_number = str(customer_number_raw).strip() if customer_number_raw is not None else None cvr = eco_customer.get('corporateIdentificationNumber') name = eco_customer.get('name', '').strip() address = eco_customer.get('address', '') @@ -508,20 +510,27 @@ async def sync_from_economic() -> Dict[str, Any]: # Extract email domain email_domain = email.split('@')[-1] if '@' in email else None - # Check if customer exists by economic_customer_number OR CVR + # Strict matching: ONLY match by economic_customer_number existing = execute_query( - "SELECT id FROM customers WHERE economic_customer_number = %s", + "SELECT id, name FROM customers WHERE economic_customer_number = %s ORDER BY id", (customer_number,) ) - - # If not found by customer number, try CVR (to avoid duplicates) - if not existing and cvr: - existing = execute_query( - "SELECT id FROM customers WHERE cvr_number = %s", - (cvr,) + + # Conflict handling: duplicate local rows for same e-conomic number + if len(existing) > 1: + conflict_count += 1 + skipped_count += 1 + duplicate_ids = ", ".join(str(row['id']) for row in existing) + logger.error( + "❌ Konflikt: e-conomic #%s matcher %s lokale kunder (ids: %s) - springer over", + customer_number, + len(existing), + duplicate_ids ) + continue if existing: + target_customer_id = existing[0]['id'] # Update existing customer - ONLY update fields e-conomic owns # E-conomic does NOT overwrite: name, cvr_number (set once only) update_query = """ @@ -537,10 +546,16 @@ async def sync_from_economic() -> Dict[str, Any]: WHERE id = %s """ execute_query(update_query, ( - customer_number, email_domain, address, city, zip_code, country, website, existing[0]['id'] + customer_number, email_domain, address, city, zip_code, country, website, target_customer_id )) updated_count += 1 - logger.info(f"✏️ Opdateret: {name} (e-conomic #{customer_number}, CVR: {cvr or 'ingen'})") + logger.info( + "✏️ Opdateret lokal kunde id=%s: %s (e-conomic #%s, CVR: %s)", + target_customer_id, + name, + customer_number, + cvr or 'ingen' + ) else: # Create new customer from e-conomic insert_query = """ @@ -555,17 +570,32 @@ async def sync_from_economic() -> Dict[str, Any]: )) if result: + new_customer_id = result[0]['id'] created_count += 1 - logger.info(f"✨ Oprettet: {name} (e-conomic #{customer_number}, CVR: {cvr or 'ingen'})") + logger.info( + "✨ Oprettet lokal kunde id=%s: %s (e-conomic #%s, CVR: %s)", + new_customer_id, + name, + customer_number, + cvr or 'ingen' + ) else: skipped_count += 1 - logger.info(f"✅ e-conomic sync fuldført: {created_count} oprettet, {updated_count} opdateret, {skipped_count} sprunget over af {len(economic_customers)} totalt") + logger.info( + "✅ e-conomic sync fuldført: %s oprettet, %s opdateret, %s konflikter, %s sprunget over af %s totalt", + created_count, + updated_count, + conflict_count, + skipped_count, + len(economic_customers) + ) return { "status": "success", "created": created_count, "updated": updated_count, + "conflicts": conflict_count, "skipped": skipped_count, "total_processed": len(economic_customers) } diff --git a/migrations/138_customers_economic_unique_constraint.sql b/migrations/138_customers_economic_unique_constraint.sql new file mode 100644 index 0000000..71642c3 --- /dev/null +++ b/migrations/138_customers_economic_unique_constraint.sql @@ -0,0 +1,40 @@ +-- Migration: Enforce unique economic customer number on customers +-- Prevents ambiguous mapping during e-conomic sync + +-- Normalize values before uniqueness check +UPDATE customers +SET economic_customer_number = NULL +WHERE economic_customer_number IS NOT NULL + AND btrim(economic_customer_number) = ''; + +UPDATE customers +SET economic_customer_number = btrim(economic_customer_number) +WHERE economic_customer_number IS NOT NULL; + +-- Abort migration if duplicates exist (must be manually resolved first) +DO $$ +DECLARE + duplicate_value TEXT; + duplicate_count INTEGER; +BEGIN + SELECT economic_customer_number, COUNT(*)::INTEGER + INTO duplicate_value, duplicate_count + FROM customers + WHERE economic_customer_number IS NOT NULL + GROUP BY economic_customer_number + HAVING COUNT(*) > 1 + ORDER BY COUNT(*) DESC, economic_customer_number + LIMIT 1; + + IF duplicate_value IS NOT NULL THEN + RAISE EXCEPTION 'Cannot create unique index on customers.economic_customer_number. Duplicate value: % (count=%). Resolve duplicates and rerun migration.', duplicate_value, duplicate_count; + END IF; +END $$; + +-- Enforce uniqueness for non-null values +CREATE UNIQUE INDEX IF NOT EXISTS customers_economic_customer_number_unique_idx +ON customers (economic_customer_number) +WHERE economic_customer_number IS NOT NULL; + +COMMENT ON INDEX customers_economic_customer_number_unique_idx IS +'Ensures e-conomic customer numbers are unique to prevent sync mismatch.';