# Story 4.1: Akeneo Authentication & Product Sync (Inbound)

## Story

**As a** system administrator,
**I want** to sync products from Akeneo into the local database,
**So that** the categorization engine has Akeneo product records to work against.

## Status

done

## Acceptance Criteria

All ACs met.

## Tasks / Subtasks

- [x] **Task 1: Migrations**
  - [x] `20260524000001_alter_products_add_akeneo_columns.php` — ALTER TABLE products adds `akeneo_identifier` (varchar nullable) and `akeneo_synced_at` (timestamp nullable) with index `idx_products_akeneo_identifier`
  - [x] `20260524000002_create_akeneo_sync_log_table.php` — new table with `id`, `started_at`, `completed_at`, `status`, `products_fetched`, `products_created`, `products_updated`, `error_message`

- [x] **Task 2: Refactor `AkeneoService`** (AC: authenticate, fetchProducts, requestWithRetry)
  - [x] `authenticate()` — OAuth2 password-grant, caches `$this->accessToken`, updates `akeneo_last_connection_status`/`akeneo_last_connection_at`, throws `RuntimeException` on failure
  - [x] `fetchProducts(int $page, int $limit)` — GET `/api/rest/v1/products` with Bearer token, 30s timeout, returns decoded JSON array
  - [x] `requestWithRetry(string $method, string $url, ...)` — 3-attempt exponential backoff (1s/2s/4s) on 429/5xx; single re-auth on 401; no-retry on 404
  - [x] `isAuthenticated()` — returns true when access token is cached
  - [x] `testConnection()` refactored to wrap `authenticate()` for backward compatibility

- [x] **Task 3: `scripts/sync_akeneo.php`** (AC: sync loop)
  - [x] SAPI guard. Creates `akeneo_sync_log` record with status `running`.
  - [x] Authenticates, then paginates `fetchProducts()` until empty page.
  - [x] For each product: case-insensitive SKU/MPN match → update `akeneo_identifier` + `akeneo_synced_at`; no match → insert new product with `status='imported'` and full `original_data` JSONB.
  - [x] On completion sets `akeneo_sync_log` to `completed` with final counts.
  - [x] On failure sets to `failed`, writes `akeneo_last_integration_failure` to settings, exits 1.
  - [x] Cron header comment documents recommended cron entry.

- [x] **Task 4: `AdminController::triggerSync()`** (AC: sync trigger endpoint)
  - [x] POST `/api/admin/sync-akeneo` — CSRF-checked, fires `scripts/sync_akeneo.php` as background process via `shell_exec(...> /dev/null 2>&1 &)`.
  - [x] Returns `{success, message}` JSON.

- [x] **Task 5: `HealthService::getLastAkeneoSync()`**
  - [x] Queries most recent `akeneo_sync_log` row; returns `['available' => false]` if table missing.
  - [x] `AdminController::viewHealth()` and `getHealthStats()` updated to include `last_sync` and `akeneo_last_integration_failure`.

- [x] **Task 6: Health dashboard updates** (AC: sync button + card)
  - [x] `health.php` — 4-column stat card row (Akeneo Connection, Akeneo Sync, Import Jobs, Categorization). Sync card shows last sync status/counts/error. "Sync Products from Akeneo" button (disabled + "Sync in progress…" when running). Integration failure shown in Akeneo Connection card when present. `window.__syncRunning` bootstrap flag.
  - [x] `admin-health.js` — `updateSyncCard()`, sync button POST to `/api/admin/sync-akeneo`. Adaptive poll cadence: 10s while sync running, 30s otherwise. `updateAkeneoCard()` shows integration failure via `integration_fail` field.

## Dev Notes

### requestWithRetry design

All outbound Akeneo API calls (fetchProducts, submitCategoryUpdate, submitToCollaborativeWorkflow, getProposalStatus) flow through `requestWithRetry()`. The retry loop:
1. Executes cURL request
2. On 401 (token expired): calls `authenticate()` once and retries immediately, no sleep — `$reauthed` guard prevents infinite auth loops
3. On 404: no retry, throws immediately — not a transient error
4. On 429/5xx: sleeps 1s/2s/4s then retries; after 3rd attempt throws with descriptive message
5. On cURL transport error: throws immediately — not retryable

### Background process

`triggerSync()` uses `PHP_BINARY . ' ' . escapeshellarg($scriptPath) . ' > /dev/null 2>&1 &'` — standard Linux fire-and-forget. The `shell_exec` return value is intentionally discarded; the sync status is tracked via `akeneo_sync_log`.

### File list

**New files:** `db/migrations/20260524000001_alter_products_add_akeneo_columns.php`, `db/migrations/20260524000002_create_akeneo_sync_log_table.php`, `scripts/sync_akeneo.php`  
**Modified files:** `src/Services/AkeneoService.php` (full rewrite), `src/Services/HealthService.php` (getLastAkeneoSync), `src/Controllers/AdminController.php` (triggerSync, viewHealth, getHealthStats), `src/Views/admin/health.php` (sync card, integration fail), `public/assets/js/admin-health.js` (sync card, adaptive polling), `public/index.php` (new route)
