# Epic 2 — Technical Specification

## Architecture & Execution Flow

The capture mechanism must be completely asynchronous and decoupled from the publishing infrastructure to avoid disrupting the legacy sync or blocking the user's HTTP request.

**Execution path:**

1. A user saves a Project via the existing SuiteX controller.
2. `ProjectObserver` (at `src/Domain/Projects/Observers/ProjectObserver.php`) detects the Eloquent `created`, `updated`, or `deleted` event.
3. The observer checks the `shadow_sync` feature flag via `ShadowSyncFeatureFlag::enabledForCurrentTenant()`.
4. If `true`, the observer dispatches a queued job (`FormatAndPublishSyncEvent`).
5. The background job uses `CanonicalEventMapper` to transform the model into the Canonical JSON Envelope.
6. The job passes the payload to the injected `SyncEventPublisherInterface` (currently bound to the local log driver).

---

## Data Interception — Observers

Both observers already exist in the codebase. They must be **extended**, not replaced.

| Observer | Path | Already registered? |
|---|---|---|
| `ProjectObserver` | `src/Domain/Projects/Observers/ProjectObserver.php` | Yes — `AppServiceProvider::boot()` |
| `ProjectTaskObserver` | `src/Domain/ProjectTasks/Observers/ProjectTaskObserver.php` | No — must be added |

Observer registration in SuiteX lives in `AppServiceProvider::boot()`, **not** in an `EventServiceProvider`.

> **Note on coexistence with `RecordObserver`:** Both `Project` and `ProjectTask` are also registered against the legacy `RecordObserver` and `RecordCacheInvalidator` in `EventServiceProvider::$observedModels`. Laravel fires multiple observers on the same model sequentially — this is intentional and there is no conflict. The domain observers fire post-commit (`$afterCommit = true`), while `RecordObserver` fires inline during the transaction. Shadow dispatch must not be added to `RecordObserver` for two reasons: (1) it fires before the record is fully committed, meaning `external_id`/`refid`/`synced` fields may not yet be set; (2) it couples the new event pipeline to legacy NetSuite sync logic that will eventually be removed.

### Namespace Convention

```
Domain\{DomainName}\Observers\{ModelName}Observer
```

### Registration (AppServiceProvider)

```php
// src/App/Providers/AppServiceProvider.php — boot() method

// Already present — do not change:
\Domain\Projects\Models\Project::observe(\Domain\Projects\Observers\ProjectObserver::class);

// Add if not present:
\Domain\ProjectTasks\Models\ProjectTask::observe(\Domain\ProjectTasks\Observers\ProjectTaskObserver::class);
```

### ProjectObserver

Extends the existing cache-invalidation logic with shadow dispatch.

```php
// src/Domain/Projects/Observers/ProjectObserver.php

namespace Domain\Projects\Observers;

use App\Jobs\Sync\FormatAndPublishSyncEvent;
use App\Services\Sync\ShadowSyncFeatureFlag;
use Domain\Projects\Models\Project;
use Domain\Projects\Services\ProjectLookupService;
use Illuminate\Support\Str;

class ProjectObserver
{
    public $afterCommit = true;

    public function created(Project $project): void
    {
        app(ProjectLookupService::class)->forget($project->refid);
        $this->triggerShadowEvent($project, 'create');
    }

    public function updated(Project $project): void
    {
        app(ProjectLookupService::class)->forget($project->refid);
        $this->triggerShadowEvent($project, 'update');
    }

    public function deleted(Project $project): void
    {
        app(ProjectLookupService::class)->forget($project->refid);
        $this->triggerShadowEvent($project, 'delete');
    }

    public function restored(Project $project): void
    {
        app(ProjectLookupService::class)->forget($project->refid);
    }

    private function triggerShadowEvent(Project $project, string $eventType): void
    {
        if (app(ShadowSyncFeatureFlag::class)->enabledForCurrentTenant()) {
            $writeId = (string) Str::uuid();
            FormatAndPublishSyncEvent::dispatch($project, 'project', $eventType, $writeId);
        }
    }
}
```

### ProjectTaskObserver

The `isImport()` guard (from the `HasImportFlag` trait) prevents shadow events from firing during bulk import jobs — consistent with the existing observer convention.

```php
// src/Domain/ProjectTasks/Observers/ProjectTaskObserver.php

namespace Domain\ProjectTasks\Observers;

use App\Jobs\Sync\FormatAndPublishSyncEvent;
use App\Services\Sync\ShadowSyncFeatureFlag;
use Domain\ProjectTasks\Models\ProjectTask;
use Illuminate\Support\Str;

class ProjectTaskObserver
{
    public function created(ProjectTask $projectTask): void
    {
        $this->triggerShadowEvent($projectTask, 'create');
    }

    public function updated(ProjectTask $projectTask): void
    {
        $this->triggerShadowEvent($projectTask, 'update');
    }

    public function deleted(ProjectTask $projectTask): void
    {
        $this->triggerShadowEvent($projectTask, 'delete');
    }

    public function restored(ProjectTask $projectTask): void
    {
        //
    }

    public function forceDeleted(ProjectTask $projectTask): void
    {
        //
    }

    private function triggerShadowEvent(ProjectTask $projectTask, string $eventType): void
    {
        if (app(ShadowSyncFeatureFlag::class)->enabledForCurrentTenant() && !$projectTask->isImport()) {
            $writeId = (string) Str::uuid();
            FormatAndPublishSyncEvent::dispatch($projectTask, 'projecttask', $eventType, $writeId);
        }
    }
}
```

---

## Payload Transformation — CanonicalEventMapper

Per the Service Placement Matrix, this service is cross-domain infrastructure and belongs in `src/App/Services/Sync/`.

> **Note:** SuiteX has no `account_id` column on `projects` or `projecttasks`. Tenant identity is resolved at runtime via `TenantService::getCurrentTenantId()`. Field names follow NetSuite convention (e.g., `isinactive`, `startdate`) — verify exact column names against the migration files before finalising.

```php
// src/App/Services/Sync/CanonicalEventMapper.php

namespace App\Services\Sync;

use App\Services\TenantService;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;

class CanonicalEventMapper
{
    public function __construct(private TenantService $tenantService) {}

    public function buildEnvelope(Model $model, string $recordType, string $eventType, string $writeId): array
    {
        return [
            'schemaVersion' => 'v1',
            'eventId'       => (string) Str::uuid(),
            'accountId'     => $this->tenantService->getCurrentTenantId(),
            'recordType'    => $recordType,
            'recordId'      => (string) $model->id,
            'eventType'     => $eventType,
            'source'        => 'suitex',
            'timestamp'     => now()->timezone('UTC')->toIso8601String(),
            'sourceSystem'  => 'suitex',
            'writeId'       => $writeId,
            'changes'       => $this->mapChanges($model, $recordType),
        ];
    }

    private function mapChanges(Model $model, string $recordType): array
    {
        if ($recordType === 'project') {
            return [
                'title'      => $model->title,
                'isInactive' => (bool) ($model->isinactive ?? false),
                'startDate'  => $model->startdate ? Carbon::parse($model->startdate)->toDateString() : null,
            ];
        }

        if ($recordType === 'projecttask') {
            return [
                'title'     => $model->title,
                'status'    => $model->status ?? null,
                'startDate' => $model->startdate ? Carbon::parse($model->startdate)->toDateString() : null,
                'endDate'   => $model->enddate ? Carbon::parse($model->enddate)->toDateString() : null,
            ];
        }

        return [];
    }
}
```

---

## Publisher — Dependency Inversion

Because Epic 2 (GCP Pub/Sub) infrastructure is not yet provisioned, we define an interface so this Epic can proceed independently. When Epic 2 is complete, a single binding change in `AppServiceProvider` switches the driver.

### Interface

```php
// src/App/Contracts/Sync/SyncEventPublisherInterface.php

namespace App\Contracts\Sync;

interface SyncEventPublisherInterface
{
    public function publish(string $topic, array $canonicalPayload): void;
}
```

### Local Log Driver (temporary)

```php
// src/App/Services/Sync/Publishers/LocalLogPublisher.php

namespace App\Services\Sync\Publishers;

use App\Contracts\Sync\SyncEventPublisherInterface;
use Illuminate\Support\Facades\Log;

class LocalLogPublisher implements SyncEventPublisherInterface
{
    public function publish(string $topic, array $canonicalPayload): void
    {
        Log::channel('sync_shadow')->info("Mock Publish to {$topic}", $canonicalPayload);
    }
}
```

### Service Provider Binding

Bind in `AppServiceProvider::register()`. To upgrade to GCP Pub/Sub (Epic 2), change the second argument to `GoogleCloudPubSubPublisher::class`.

```php
// src/App/Providers/AppServiceProvider.php — register() method

$this->app->bind(
    \App\Contracts\Sync\SyncEventPublisherInterface::class,
    \App\Services\Sync\Publishers\LocalLogPublisher::class
);
```

---

## Asynchronous Job

Runs on Laravel Horizon/Redis in the background. Follows the existing `src/App/Jobs/` convention with a `Sync` subfolder.

```php
// src/App/Jobs/Sync/FormatAndPublishSyncEvent.php

namespace App\Jobs\Sync;

use App\Contracts\Sync\SyncEventPublisherInterface;
use App\Services\Sync\CanonicalEventMapper;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;

class FormatAndPublishSyncEvent implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public function __construct(
        public Model $model,
        public string $recordType,
        public string $eventType,
        public string $writeId
    ) {}

    public function handle(CanonicalEventMapper $mapper, SyncEventPublisherInterface $publisher): void
    {
        $payload = $mapper->buildEnvelope($this->model, $this->recordType, $this->eventType, $this->writeId);
        $publisher->publish('events.raw', $payload);
    }
}
```

---

## Feature Flag — ShadowSyncFeatureFlag Service

The flag is stored in the core `mysql` database's `feature_flags` table — the same table used by the `chunked_import` flag. No new config file is needed.

The `tenant_ids` column is a JSON array of integer tenant IDs. A tenant is considered enabled if its ID appears in that array, regardless of the top-level `enabled` column (matching the `chunked_import` convention).

A dedicated service class avoids duplicating the DB lookup across both observers and provides a single place to add caching later.

```php
// src/App/Services/Sync/ShadowSyncFeatureFlag.php

namespace App\Services\Sync;

use App\Services\TenantService;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;

class ShadowSyncFeatureFlag
{
    public function __construct(private TenantService $tenantService) {}

    public function enabledForCurrentTenant(): bool
    {
        try {
            $tenantId = $this->tenantService->getCurrentTenantId();

            if (!$tenantId) {
                return false;
            }

            $flag = DB::connection('mysql')
                ->table('feature_flags')
                ->where('name', 'shadow_sync')
                ->first();

            if (!$flag) {
                return false;
            }

            $enabledTenants = array_map('intval', json_decode($flag->tenant_ids, true) ?? []);

            return in_array((int) $tenantId, $enabledTenants, true);
        } catch (\Exception $e) {
            Log::warning('ShadowSyncFeatureFlag: could not read feature flag', ['error' => $e->getMessage()]);
            return false;
        }
    }
}
```

### Database Seeding — Migration

Add a migration to insert the `shadow_sync` row into `feature_flags`. The flag starts with an empty `tenant_ids` array — enable it per tenant via a direct DB update or admin tooling.

```php
// database/migrations/YYYY_MM_DD_000000_add_shadow_sync_feature_flag.php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;

class AddShadowSyncFeatureFlag extends Migration
{
    public function up(): void
    {
        DB::table('feature_flags')->insertOrIgnore([
            'name'        => 'shadow_sync',
            'enabled'     => false,
            'tenant_ids'  => '[]',
            'description' => 'Enables shadow event emission to the new Event-Driven Architecture pipeline (GCP Pub/Sub) while the legacy NetSuite sync continues to run.',
            'created_at'  => now(),
            'updated_at'  => now(),
        ]);
    }

    public function down(): void
    {
        DB::table('feature_flags')->where('name', 'shadow_sync')->delete();
    }
}
```

---

## Directory Layout

```
database/
  migrations/
    YYYY_MM_DD_000000_add_shadow_sync_feature_flag.php

src/
  App/
    Contracts/
      Sync/
        SyncEventPublisherInterface.php
    Jobs/
      Sync/
        FormatAndPublishSyncEvent.php
    Providers/
      AppServiceProvider.php                      ← binding + observer registration
    Services/
      Sync/
        CanonicalEventMapper.php
        ShadowSyncFeatureFlag.php
        Publishers/
          LocalLogPublisher.php
  Domain/
    Projects/
      Observers/
        ProjectObserver.php                       ← extended with shadow dispatch
    ProjectTasks/
      Observers/
        ProjectTaskObserver.php                   ← extended with shadow dispatch
```
