# Technical Specification: Epic 5 (Sync Control Panel & DLQ UI)

**Document Status:** Ready for Development
**Target Frameworks:** Laravel (API Layer), React (all UI components), Blade (page shell)
**Prerequisites:** Epic 1 (`field_metadata` table defined)
**Scope:** Custom Field Mapper UI and Dead Letter Queue (DLQ) Dashboard.

---

## 1. Domain Models (Laravel Backend)

SuiteX models follow the `Domain\{DomainName}\Models\` namespace convention. There is no global `App\Models` directory for domain models.

### A. FieldMetadata Model

Represents a custom field mapping entry per tenant. This model lives in the core (`mysql`) connection because `field_metadata` is a cross-tenant infrastructure table tracking per-tenant configuration — it is **not** on the `tenant_connection`.

> **Design note:** If Epic 1 decides `field_metadata` should live on the tenant connection instead (one table per tenant database), add `UsesTenantConnection` and remove the `$connection` override. This must be resolved before implementation.

```php
// src/Domain/Sync/Models/FieldMetadata.php

namespace Domain\Sync\Models;

use Illuminate\Database\Eloquent\Model;

class FieldMetadata extends Model
{
    protected $connection = 'mysql';
    protected $table = 'field_metadata';

    public $incrementing = false;

    protected $fillable = [
        'tenant_id',
        'record_type',
        'field_id',
        'field_type',
        'is_synced',
        'is_readonly',
        'normalization_rule',
        'conflict_policy',
    ];

    protected $casts = [
        'is_synced'          => 'boolean',
        'is_readonly'        => 'boolean',
        'normalization_rule' => 'array',
    ];
}
```

### B. DeadLetterEvent Model

Stores events that failed the Epic 1 JSON validation. This belongs in the core `mysql` connection — failed events originate from the Orchestrator pipeline, which is not tenant-database-scoped.

```php
// src/Domain/Sync/Models/DeadLetterEvent.php

namespace Domain\Sync\Models;

use Illuminate\Database\Eloquent\Model;

class DeadLetterEvent extends Model
{
    protected $connection = 'mysql';
    protected $table = 'dead_letter_events';

    protected $fillable = [
        'tenant_id',
        'record_type',
        'record_id',
        'payload',
        'error_reason',
        'status',
    ];

    protected $casts = [
        'payload' => 'array',
    ];
}
```

> **Note on `tenant_id`:** Both models use `tenant_id` (an integer matching the `TenantService::getCurrentTenantId()` return value), **not** `account_id`. There is no `account_id` column in SuiteX's tenant isolation pattern.

---

## 2. API Controllers

API controllers live in `src/App/Http/Controllers/Api/v1/` and extend `BaseApiController` (`src/App/Http/Controllers/Api/BaseApiController.php`), which provides `successResponse()`, `errorResponse()`, `paginatedResponse()`, `validationErrorResponse()`, `getCurrentTenantId()`, `logError()`, `forbiddenResponse()`, and `notFoundResponse()`. Do not re-implement these locally.

Tenant context is resolved via the inherited `getCurrentTenantId()` method — never from client-supplied request data.

```php
// src/App/Http/Controllers/Api/v1/SyncMetadataController.php

namespace App\Http\Controllers\Api\v1;

use App\Http\Controllers\Api\BaseApiController;
use Domain\Sync\Models\FieldMetadata;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

class SyncMetadataController extends BaseApiController
{
    public function index(string $recordType): JsonResponse
    {
        try {
            $tenantId = $this->getCurrentTenantId();

            $mappings = FieldMetadata::where('tenant_id', $tenantId)
                ->where('record_type', $recordType)
                ->get();

            return $this->successResponse($mappings->toArray());

        } catch (\Exception $e) {
            $this->logError('Failed to retrieve field metadata', $e, ['record_type' => $recordType]);
            return $this->errorResponse('Failed to retrieve field metadata', 500);
        }
    }

    public function store(Request $request): JsonResponse
    {
        try {
            $tenantId = $this->getCurrentTenantId();

            $validated = $request->validate([
                'record_type'        => 'required|string',
                'field_id'           => 'required|regex:/^cust[a-zA-Z0-9_]+$/',
                'field_type'         => 'required|in:string,integer,boolean,date,list',
                'conflict_policy'    => 'required|in:netsuite-wins,suitex-wins,manual',
                'normalization_rule' => 'nullable|array',
            ]);

            $mapping = FieldMetadata::updateOrCreate(
                [
                    'tenant_id'   => $tenantId,
                    'record_type' => $validated['record_type'],
                    'field_id'    => $validated['field_id'],
                ],
                $validated + ['tenant_id' => $tenantId]
            );

            return $this->successResponse($mapping->toArray(), 201);

        } catch (\Illuminate\Validation\ValidationException $e) {
            return $this->validationErrorResponse($e->errors());
        } catch (\Exception $e) {
            $this->logError('Failed to save field metadata', $e);
            return $this->errorResponse('Failed to save field metadata', 500);
        }
    }
}
```

```php
// src/App/Http/Controllers/Api/v1/SyncDlqController.php

namespace App\Http\Controllers\Api\v1;

use App\Http\Controllers\Api\BaseApiController;
use Domain\Sync\Models\DeadLetterEvent;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

class SyncDlqController extends BaseApiController
{
    public function index(Request $request): JsonResponse
    {
        try {
            $tenantId = $this->getCurrentTenantId();
            $pagination = $this->parsePaginationParams($request);

            $query = DeadLetterEvent::where('tenant_id', $tenantId)
                ->orderByDesc('created_at');

            if ($request->filled('status')) {
                $query->where('status', $request->status);
            }

            if ($request->filled('record_type')) {
                $query->where('record_type', $request->record_type);
            }

            $events = $query->paginate($pagination['limit']);

            return $this->paginatedResponse($events);

        } catch (\Exception $e) {
            $this->logError('Failed to retrieve DLQ events', $e);
            return $this->errorResponse('Failed to retrieve DLQ events', 500);
        }
    }

    public function retry(int $id): JsonResponse
    {
        try {
            $tenantId = $this->getCurrentTenantId();
            $event = DeadLetterEvent::where('tenant_id', $tenantId)->findOrFail($id);
            $event->update(['status' => 'retried']);

            return $this->successResponse(['id' => $id, 'status' => 'retried']);

        } catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
            return $this->notFoundResponse();
        } catch (\Exception $e) {
            $this->logError('Failed to retry DLQ event', $e, ['id' => $id]);
            return $this->errorResponse('Failed to retry event', 500);
        }
    }

    public function dismiss(int $id): JsonResponse
    {
        try {
            $tenantId = $this->getCurrentTenantId();
            $event = DeadLetterEvent::where('tenant_id', $tenantId)->findOrFail($id);
            $event->update(['status' => 'dismissed']);

            return $this->successResponse(['id' => $id, 'status' => 'dismissed']);

        } catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
            return $this->notFoundResponse();
        } catch (\Exception $e) {
            $this->logError('Failed to dismiss DLQ event', $e, ['id' => $id]);
            return $this->errorResponse('Failed to dismiss event', 500);
        }
    }
}
```

---

## 3. API Routes

All v1 routes in SuiteX use the `['web', 'auth', 'throttle:200,1']` middleware group and are defined in `routes/api.php`. Routes are named following the `api.v1.{resource}.{action}` convention.

```php
// routes/api.php

Route::middleware(['web', 'auth', 'throttle:200,1'])->prefix('v1/sync')->group(function () {

    // Custom Field Mapper
    Route::get('metadata/{recordType}', [\App\Http\Controllers\Api\v1\SyncMetadataController::class, 'index'])
        ->name('api.v1.sync.metadata.index');

    Route::post('metadata', [\App\Http\Controllers\Api\v1\SyncMetadataController::class, 'store'])
        ->name('api.v1.sync.metadata.store');

    // Dead Letter Queue
    Route::get('dlq', [\App\Http\Controllers\Api\v1\SyncDlqController::class, 'index'])
        ->name('api.v1.sync.dlq.index');

    Route::post('dlq/{id}/retry', [\App\Http\Controllers\Api\v1\SyncDlqController::class, 'retry'])
        ->name('api.v1.sync.dlq.retry');

    Route::post('dlq/{id}/dismiss', [\App\Http\Controllers\Api\v1\SyncDlqController::class, 'dismiss'])
        ->name('api.v1.sync.dlq.dismiss');
});
```

### API Response Envelope

All responses follow the standard SuiteX envelope (matching `EnhancedImportController`):

```json
{
  "success": true,
  "data": [ ... ],
  "timestamp": "2026-03-09T14:30:00.000000Z"
}
```

Error responses:

```json
{
  "success": false,
  "message": "Validation failed",
  "timestamp": "2026-03-09T14:30:00.000000Z"
}
```

### Mock Response — GET `/api/v1/sync/metadata/{recordType}`

```json
{
  "success": true,
  "data": [
    {
      "field_id": "custentity_supplier",
      "field_type": "string",
      "is_synced": true,
      "conflict_policy": "netsuite-wins",
      "normalization_rule": { "format": "string", "maxLength": 255 }
    }
  ],
  "timestamp": "2026-03-09T14:30:00.000000Z"
}
```

### Mock Response — GET `/api/v1/sync/dlq`

The DLQ event shape is derived from two V10 sources:
- The **Canonical JSON Envelope** (`eventId`, `recordType`, `recordId`, `sourceSystem`, `writeId`, `changes`, etc.)
- The **`sync_error_queue` schema** (`reason`, `details`, `status`)

The frontend developer **must** use these exact field names. Do not invent keys — they will be populated by the real Orchestrator pipeline in a later Epic.

```json
{
  "success": true,
  "data": [
    {
      "id": 9932,
      "tenant_id": 4515181,
      "status": "pending",
      "record_type": "project",
      "record_id": "12345",
      "error_reason": "Schema validation failed: 'isInactive' must be boolean, got string.",
      "error_class": "validation_error",
      "created_at": "2026-03-09T14:30:00.000000Z",
      "updated_at": "2026-03-09T14:30:00.000000Z",
      "payload": {
        "schemaVersion": "v1",
        "eventId": "018e5c2a-1234-7abc-8def-000000000001",
        "accountId": 4515181,
        "recordType": "project",
        "recordId": "12345",
        "eventType": "update",
        "source": "suitex",
        "sourceSystem": "suitex",
        "timestamp": "2026-03-09T14:29:58.000000Z",
        "writeId": "018e5c2a-5678-7abc-8def-000000000002",
        "changes": {
          "title": "Acme Rollout Phase 2",
          "isInactive": "false",
          "startDate": "2026-04-01"
        }
      }
    },
    {
      "id": 9933,
      "tenant_id": 4515181,
      "status": "dismissed",
      "record_type": "projecttask",
      "record_id": "67890",
      "error_reason": "Schema validation failed: 'startDate' must be ISO8601 date string, got null.",
      "error_class": "validation_error",
      "created_at": "2026-03-08T09:10:00.000000Z",
      "updated_at": "2026-03-09T11:00:00.000000Z",
      "payload": {
        "schemaVersion": "v1",
        "eventId": "018e5c2a-9999-7abc-8def-000000000003",
        "accountId": 4515181,
        "recordType": "projecttask",
        "recordId": "67890",
        "eventType": "create",
        "source": "suitex",
        "sourceSystem": "suitex",
        "timestamp": "2026-03-08T09:09:55.000000Z",
        "writeId": "018e5c2a-aaaa-7abc-8def-000000000004",
        "changes": {
          "title": "Install networking gear",
          "status": "not_started",
          "startDate": null,
          "endDate": "2026-04-15"
        }
      }
    }
  ],
  "pagination": {
    "current_page": 1,
    "per_page": 25,
    "total": 2,
    "total_pages": 1,
    "has_next_page": false,
    "has_prev_page": false
  },
  "timestamp": "2026-03-09T14:30:00.000000Z"
}
```

**Field reference:**

| Field | Source | Description |
|---|---|---|
| `id` | `dead_letter_events` table | Auto-increment PK for API operations (retry/dismiss) |
| `tenant_id` | SuiteX tenant | Integer, from `TenantService::getCurrentTenantId()` |
| `status` | `sync_error_queue.status` | `pending`, `retried`, `dismissed` |
| `record_type` | V10 envelope `recordType` | Lowercase: `project`, `projecttask` |
| `record_id` | V10 envelope `recordId` | NetSuite internal ID as string |
| `error_reason` | `sync_error_queue.reason` | Human-readable validation failure message |
| `error_class` | `sync_error_queue` classification | V10 error classes: `validation_error`, `auth_error`, `transient` |
| `created_at` | Timestamp event was routed to DLQ | ISO 8601 UTC |
| `payload` | Full Canonical JSON Envelope | As defined in V10 `## Detailed design — Event envelope` |
| `payload.writeId` | V10 envelope | UUID unique to the originating write |
| `payload.changes` | V10 envelope | Field-level delta that triggered the failure |
```

---

## 4. Frontend Component Architecture

All new UI work uses **Blade + React**. Livewire is not used for new development. The Blade page acts as a shell that mounts React components and passes any server-side bootstrap data as props via `data-*` attributes or inline JSON.

Both the Custom Field Mapper and the DLQ Dashboard follow the same pattern as `EnhancedImport`: a Blade view renders a container `<div>` with a `data-` prop, and a React component is mounted onto that container via `resources/js/app.js`.

### Frontend API calls

All React components use the shared `ApiClient` singleton from `resources/js/utils/api.js`. It handles CSRF tokens, session credentials, payload sanitization, and 409 conflict recovery automatically — do not use raw `fetch()`.

```js
import apiClient from '../utils/api.js';

// GET with query params
const response = await apiClient.get('/sync/dlq', {
  params: { status: 'pending', record_type: 'project' }
});

// POST
const response = await apiClient.post('/sync/metadata', {
  record_type: 'project',
  field_id: 'custentity_supplier',
  field_type: 'string',
  conflict_policy: 'netsuite-wins',
});
```

Note: `ApiClient` has `baseURL` set to `/api/v1`, so component calls omit that prefix.

### A. `SyncFieldMapper` React Component

- **Location:** `resources/js/Sync/SyncFieldMapper.jsx`
- **Blade mount:** `resources/views/tenant/sync/field-mapper.blade.php` renders `<div id="sync-field-mapper"></div>`
- **State:** Fetches existing mappings from `GET /api/v1/sync/metadata/{recordType}` on mount.
- **UI:** A form + table. Table rows show existing mappings; form adds new entries.
- **Entity selector (MVP):** A static dropdown at the top of the component allowing the user to choose the entity being mapped. For MVP, hardcode the options:
  - `project` → label `"Project"`
  - `projecttask` → label `"Project Task"`

  The selected value drives the `{recordType}` path parameter in all API calls. Do **not** fetch the entity list from an API endpoint for this MVP iteration — there is no `/sync/entities` endpoint yet. When the data-sync system expands to additional record types, a `GET /api/v1/sync/entities` endpoint will be introduced (tracked in a future Epic) and the dropdown should be migrated to fetch dynamically at that point.
- **Form Controls:**
  - *Field ID:* Text input — client-side validate against regex `^cust[a-zA-Z0-9_]+$` before submit.
  - *Data Type:* Dropdown (`string`, `integer`, `boolean`, `date`, `list`).
  - *Conflict Policy:* Dropdown (`netsuite-wins`, `suitex-wins`, `manual`).
- **Submit:** `POST /api/v1/sync/metadata` — on `201` response, append new row to local state.

### B. `DlqDashboard` React Component

- **Location:** `resources/js/Sync/DlqDashboard.jsx`
- **Blade mount:** `resources/views/tenant/sync/dlq.blade.php` renders `<div id="dlq-dashboard"></div>`
- **State:** Paginated list of failed events from `GET /api/v1/sync/dlq`.
- **UI:** Data table consistent with the `Table` component pattern (`resources/js/Table/Table.jsx`). Rows highlighted based on age. Filter controls for `status` and `record_type`.
- **Actions per row:** Inspect (opens `DlqInspectorModal`), Retry (`POST .../retry`), Dismiss (`POST .../dismiss`).
- **Dismiss behaviour:** On a successful `dismiss` response, **do not remove the row**. Update the row's local `status` to `"dismissed"` and apply a grayed-out visual style. This preserves the audit trail and matches V10's human review model, where dismissed errors remain visible in the queue. The default filter should show `status=pending` so dismissed rows are hidden by default but reachable via filter.
- **Retry behaviour:** On a successful `retry` response, update the row's local `status` to `"retried"` and disable both action buttons for that row to prevent double-submission.

### C. `DlqInspectorModal` React Component

- **Location:** `resources/js/Sync/DlqInspectorModal.jsx`
- **Props:** Receives a single `DeadLetterEvent` object from the parent `DlqDashboard`.
- **Layout:**
  - **Top bar:** `error_reason` displayed prominently.
  - **Body:** Read-only, syntax-highlighted JSON block of the raw `payload`. Use `highlight.js` (already present in the codebase) — do not add `react-json-view` as a new dependency.

---

## 5. Directory Layout

```
src/
  App/
    Http/
      Controllers/
        Api/
          v1/
            SyncMetadataController.php    ← extends BaseApiController
            SyncDlqController.php         ← extends BaseApiController
  Domain/
    Sync/
      Models/
        FieldMetadata.php
        DeadLetterEvent.php

resources/
  js/
    Sync/
      SyncFieldMapper.jsx
      DlqDashboard.jsx
      DlqInspectorModal.jsx
  views/
    tenant/
      sync/
        field-mapper.blade.php            ← Blade shell for SyncFieldMapper
        dlq.blade.php                     ← Blade shell for DlqDashboard

routes/
  api.php                                 ← v1/sync/* routes added here
```
