From 93a20848e6c6cf40243081b321bc5b370f4a8803 Mon Sep 17 00:00:00 2001 From: OneTwo3D IMS Date: Fri, 26 Jun 2026 22:02:48 +0000 Subject: [PATCH] fix(woocommerce): never let the initial import false-complete and silently disable live order sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A WC initial import that errored on every order and imported nothing still marked itself complete (status 'done' + wc_initial_import_completed='true') — leaving a dead-end "Import completed" with 0 orders, no retry button (only 'error' state had one), and live order sync silently gated off. A separate bug compounded it: saving WC sync settings re-wrote every SYNC_SETTING_KEY including the machine-managed wc_initial_import_completed flag, so a settings save could wipe a genuine completion and turn live sync back off. Three coordinated fixes so this situation cannot happen: - initial-import: new pure decideInitialImportOutcome — a pass that imported/ reconciled nothing AND had errors is FAILED (status 'error', flag NOT set, error notification), so it stays retryable and live sync stays off until a real import succeeds. Any real progress, or no active orders at all, is COMPLETE (partial per-order errors are surfaced but don't block). - wc-sync: exclude MACHINE_MANAGED_SYNC_KEYS (the completed flag + sync cursors + webhook timestamps) from saveSyncSettings so a settings save can't clobber them. - sync UI: the 'done' state now also offers a "Re-import active orders" button, so a previously-stuck "completed" (incl. existing data) is always re-runnable. Tests: decideInitialImportOutcome (0-imported+errors → failed; no-orders → complete; partial/clean → complete). type-check, eslint, next build, full unit suite (1887 pass / 0 fail) all green. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/(dashboard)/sync/sync-client.tsx | 4 ++ app/actions/wc-sync.ts | 19 ++++++- .../woocommerce/sync/initial-import.ts | 50 +++++++++++++++++++ tests/wc-initial-import-outcome.test.ts | 21 ++++++++ 4 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 tests/wc-initial-import-outcome.test.ts diff --git a/app/(dashboard)/sync/sync-client.tsx b/app/(dashboard)/sync/sync-client.tsx index 535853ed..cb45c590 100644 --- a/app/(dashboard)/sync/sync-client.tsx +++ b/app/(dashboard)/sync/sync-client.tsx @@ -997,6 +997,10 @@ export function SyncClient({ settings: init, statusMappings, logs, shoppingCrede Import completed

{importProgress.message}

+ ) : importProgress?.status === 'error' ? (
diff --git a/app/actions/wc-sync.ts b/app/actions/wc-sync.ts index ae481c75..b313340f 100644 --- a/app/actions/wc-sync.ts +++ b/app/actions/wc-sync.ts @@ -75,6 +75,23 @@ const SYNC_SETTING_KEYS = [ 'wc_fx_push_enabled', 'last_wc_fx_push_at', ] +// Machine-managed sync state — written by the import / sync / webhook jobs, never +// by the settings form. Excluded from saveSyncSettings so a settings save can't +// silently wipe the initial-import-completed flag or the sync cursors (which would +// turn live order sync back off). +const MACHINE_MANAGED_SYNC_KEYS = new Set([ + 'wc_initial_import_completed', + 'wc_webhook_last_received_at', + 'wc_order_webhook_last_received_at', + 'wc_product_webhook_last_received_at', + 'last_wc_order_sync_at', + 'last_wc_order_reconcile_at', + 'last_wc_product_sync_at', + 'last_wc_product_reconcile_at', + 'last_wc_stock_sync_at', + 'last_wc_fx_push_at', +]) + const SYNC_DEFAULTS: WcSyncSettings = { wc_sync_enabled: 'false', wc_sync_order_statuses: '["processing"]', @@ -210,7 +227,7 @@ export async function saveWcSyncSettings(data: Partial): Promise }) if (!gate.ok) return { success: false, error: gate.error } const ops = Object.entries(data) - .filter((entry): entry is [string, string] => SYNC_SETTING_KEYS.includes(entry[0]) && typeof entry[1] === 'string') + .filter((entry): entry is [string, string] => SYNC_SETTING_KEYS.includes(entry[0]) && !MACHINE_MANAGED_SYNC_KEYS.has(entry[0]) && typeof entry[1] === 'string') .map(([k, v]) => db.setting.upsert({ where: { key: k }, diff --git a/lib/connectors/woocommerce/sync/initial-import.ts b/lib/connectors/woocommerce/sync/initial-import.ts index 5a318f50..5da6981d 100644 --- a/lib/connectors/woocommerce/sync/initial-import.ts +++ b/lib/connectors/woocommerce/sync/initial-import.ts @@ -32,6 +32,24 @@ export type InitialImportProgress = { const JOB_KEY = 'initial_order_import_job' +/** + * Decide whether an initial-import pass counts as COMPLETE (which unlocks ongoing + * live order sync) or FAILED. A pass that errored on every order and imported + * nothing must NOT count as complete — otherwise the UI shows a dead-end + * "completed" with no orders, live sync stays silently gated off, and there's no + * way to retry. A pass that imported/reconciled at least one order, or had no + * active orders to import, is complete (per-order errors are surfaced but don't + * block, since live sync of new orders can still proceed). + */ +export function decideInitialImportOutcome(input: { + imported: number + skipped: number + errorCount: number +}): 'complete' | 'failed' { + const madeProgress = input.imported > 0 || input.skipped > 0 + return input.errorCount > 0 && !madeProgress ? 'failed' : 'complete' +} + const INITIAL_PROGRESS: InitialImportProgress = { status: 'idle', message: '', @@ -164,6 +182,38 @@ async function runInitialImport(progress: InitialImportProgress) { // ----------------------------------------------------------------------- // Completion // ----------------------------------------------------------------------- + const outcome = decideInitialImportOutcome({ + imported: progress.activeOrdersImported, + skipped: progress.activeOrdersSkipped, + errorCount: progress.errors.length, + }) + + if (outcome === 'failed') { + // Every order errored and nothing was imported \u2014 a systemic failure (e.g. + // no storefront-synced warehouse). Do NOT mark the import complete: that + // would falsely unlock live sync and leave a dead-end "done" state with no + // retry. Surface it as an error so the UI shows Retry and live order sync + // stays gated off until a real import succeeds. + progress.status = 'error' + progress.message = `Import failed \u2014 0 of ${progress.totalOrders} order${progress.totalOrders === 1 ? '' : 's'} imported (${progress.errors.length} error${progress.errors.length === 1 ? '' : 's'}). Resolve the cause and retry; live order sync stays off until the initial import succeeds.` + await saveProgress(progress) + + await logActivity({ + entityType: 'IMPORT', + tag: 'import', + action: 'failed', + description: `Active WC order import failed: ${progress.message}`, + resolveUser: false, + }) + notify({ + type: 'error', + title: 'Active Order Import Failed', + message: progress.message, + actionUrl: '/sync', + }) + return + } + await db.setting.upsert({ where: { key: 'wc_initial_import_completed' }, create: { key: 'wc_initial_import_completed', value: 'true' }, diff --git a/tests/wc-initial-import-outcome.test.ts b/tests/wc-initial-import-outcome.test.ts new file mode 100644 index 00000000..7cefbffc --- /dev/null +++ b/tests/wc-initial-import-outcome.test.ts @@ -0,0 +1,21 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { decideInitialImportOutcome } from '../lib/connectors/woocommerce/sync/initial-import.ts' + +test('a pass that imported nothing and errored on everything is FAILED (no false-complete)', () => { + // The reported bug: 6 orders, all errored ("no storefront-synced warehouse"), + // 0 imported. Must NOT count as complete — otherwise live sync silently stays + // off with a dead-end "completed" and no retry. + assert.equal(decideInitialImportOutcome({ imported: 0, skipped: 0, errorCount: 6 }), 'failed') + assert.equal(decideInitialImportOutcome({ imported: 0, skipped: 0, errorCount: 1 }), 'failed') +}) + +test('no active orders to import is COMPLETE (legitimately ready for live sync)', () => { + assert.equal(decideInitialImportOutcome({ imported: 0, skipped: 0, errorCount: 0 }), 'complete') +}) + +test('any real progress makes it COMPLETE even with some per-order errors', () => { + assert.equal(decideInitialImportOutcome({ imported: 4, skipped: 0, errorCount: 2 }), 'complete') // partial import + assert.equal(decideInitialImportOutcome({ imported: 0, skipped: 3, errorCount: 2 }), 'complete') // all already imported + assert.equal(decideInitialImportOutcome({ imported: 5, skipped: 0, errorCount: 0 }), 'complete') // clean import +})