# Epic 8 Technical Spec: Field Mapper & DLQ Dashboard

## 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.

// 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.

// 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.

## 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.

// 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);
        }
    }
}

// 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);
        }
    }
}

## 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.

// 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):

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

Error responses:

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

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

{
  "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.

{
  "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

## 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().

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 behavior: 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

