From e4cfd2cc2f989251d2eac9a5e2d46603f85919f6 Mon Sep 17 00:00:00 2001 From: Jurn Spijksma Date: Wed, 20 May 2026 20:37:15 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20minimal=20MailerLite=20addon=20?= =?UTF-8?q?=E2=80=94=20sync=20pipeline=20+=20per-form=20config=20+=20resyn?= =?UTF-8?q?c=20action?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is the v1.0 marketplace release shape, rebuilt per colleague review: > "Basis was prima, alleen die formconfig dingen moesten anders." The basis (sync job + listener + MailerLite SDK wrapper + per-form blueprint section) is preserved. The over-engineering generated during exploration (dashboard, activity log surface, retry controller + rate limiter, central form-config repository, custom CP routes/permissions/nav, Vue/Inertia pages) has been removed. Re-sync is delivered as a Statamic native Submission Action that appears on the form's built-in submissions list — one button, no parallel CP surface. Per-form config is read DIRECTLY from each form's own YAML via `Form::find($handle)->get('enabled' | 'mappings' | 'groups')`. No addon-side mirror, no central form-config repository. Statamic's Form::appendConfigFields already persists the values where Statamic looks for them. Components: src/AddonSettings.php Thin wrapper around Statamic's native addon-settings store. Single responsibility: read the API key. Container-bound singleton; tests replace via app()->instance(). src/MailerLite/MailerLiteService.php + Contracts/, Exceptions/ SDK wrapper. upsertSubscriber(), ping(), listFields(), listGroups(). Translates SDK exceptions to addon-namespace types so the rest of the addon doesn't depend on the SDK's surface. src/Sync/SyncSubscriberJob.php Queued upsert. Reads the API key from AddonSettings and the per-form mappings/groups directly off the form blueprint. tries=3, backoff= [60,300,900]. 4xx -> permanent_failure (no retry); 5xx -> transient (Laravel retries). The finally block emits a Log::info breadcrumb but does not maintain a separate activity store. src/Listeners/FormSubmittedListener.php Captures the visitor IP onto the submission pre-persist. src/Listeners/SubmissionCreatedListener.php Thin dispatch gate — reads `enabled` from the form, dispatches the job. No driver dependency, no form-config repository. src/Actions/ResyncToMailerLite.php Statamic Submission Action (auto-discovered from src/Actions/). Visible on the form's submissions list when the form has MailerLite sync enabled. Dispatches a fresh SyncSubscriberJob per selected submission. src/Fieldtypes/MailerLite{Field,Group}Fieldtype.php Live-fetched dropdowns. Per-request cache keyed by sha256(api_key) keeps the SDK call count to one per CP render. src/StatamicMailerLiteServiceProvider.php Bindings (AddonSettings singleton + MailerLiteService via interface) and the per-form MailerLite section append. Listeners and the Action auto-discover from their respective folders. resources/blueprints/settings.yaml Statamic native addon-settings blueprint — API key field. Rendered under Tools → Addons → MailerLite. Removed (vs the exploratory PR #3 history on f/js/gsd-mailerlite): - StorageDriverInterface + AddonYamlDriver (and the FormConfig methods). Per-form config now lives where Statamic stores it natively, not mirrored into addon storage. - ActivityLogger, ActivityLogEntry, ActivityLogStore (JSONL). - All four CP/* controllers (Dashboard, Activity, Retry, FormHealthSummary). - PruneActivityLogCommand. - FormSavedListener (the mirror writer). - Custom CP routes, top-level nav, configure-mailerlite permission, retry rate limiter. - Vue/Inertia Dashboard + Activity pages and the Vite build pipeline. Quality gates: Pest 124/124 · PHPStan level 5 clean · Pint clean. Co-Authored-By: Claude Opus 4.7 --- composer.json | 2 +- documentation.md | 438 ++++++ package-lock.json | 1344 ----------------- package.json | 15 - phpstan-baseline.neon | 0 phpstan.neon.dist | 9 + pint.json | 6 + resources/blueprints/settings.yaml | 13 +- resources/dist/build/assets/addon-e4Zwj-7O.js | 1 - resources/dist/build/manifest.json | 8 - resources/js/addon.js | 5 - resources/js/pages/FormConfig/Index.vue | 67 - routes/cp.php | 13 - src/Actions/ResyncToMailerLite.php | 66 + src/Activity/PayloadRedactor.php | 23 + src/AddonSettings.php | 33 + src/Fieldtypes/MailerLiteFieldFieldtype.php | 104 ++ src/Fieldtypes/MailerLiteGroupFieldtype.php | 96 ++ src/Fieldtypes/MailerLiteGroups.php | 91 -- .../Controllers/CP/FormConfigController.php | 256 ---- src/Jobs/CreateSubscriberJob.php | 62 - src/Listeners/FormSubmittedListener.php | 22 + src/Listeners/SubmissionCreatedListener.php | 39 +- .../Contracts/MailerLiteServiceInterface.php | 60 + .../Exceptions/MailerLiteHttpException.php | 9 + .../MailerLiteValidationException.php | 9 + src/MailerLite/MailerLiteService.php | 198 +++ src/Stache/FormConfigEntry.php | 81 - src/Stache/FormConfigRepository.php | 58 - src/Stache/FormConfigStore.php | 59 - src/StatamicMailerliteServiceProvider.php | 105 +- src/Sync/SyncSubscriberJob.php | 264 ++++ tests/Actions/ResyncToMailerLiteTest.php | 78 + tests/Activity/PayloadRedactorTest.php | 93 ++ tests/ArchTest.php | 5 - tests/ExampleTest.php | 5 - .../MailerLiteFieldFieldtypeTest.php | 196 +++ .../MailerLiteGroupFieldtypeTest.php | 140 ++ tests/Forms/AppendConfigFieldsTest.php | 89 ++ tests/Listeners/FormSubmittedListenerTest.php | 59 + .../SubmissionCreatedListenerTest.php | 139 ++ tests/MailerLite/ExceptionAdapterTest.php | 64 + tests/MailerLite/ListFieldsTest.php | 189 +++ tests/MailerLite/ListGroupsTest.php | 161 ++ tests/MailerLite/MailerLiteServiceTest.php | 290 ++++ tests/MailerLite/PingTest.php | 214 +++ tests/Pest.php | 12 +- .../Fixtures/TestableSyncSubscriberJob.php | 29 + tests/Sync/SyncPipelineIntegrationTest.php | 105 ++ tests/Sync/SyncSubscriberJobTest.php | 372 +++++ tests/TestCase.php | 48 +- .../users/admin+guard@example.com.yaml | 4 + .../users/admin+permitted@example.com.yaml | 4 + .../users/regular@example.com.yaml | 4 + .../__fixtures__/users/super@example.com.yaml | 3 + vite.config.js | 13 - 56 files changed, 3713 insertions(+), 2159 deletions(-) create mode 100644 documentation.md delete mode 100644 package-lock.json delete mode 100644 package.json delete mode 100644 phpstan-baseline.neon create mode 100644 pint.json delete mode 100644 resources/dist/build/assets/addon-e4Zwj-7O.js delete mode 100644 resources/dist/build/manifest.json delete mode 100644 resources/js/addon.js delete mode 100644 resources/js/pages/FormConfig/Index.vue delete mode 100644 routes/cp.php create mode 100644 src/Actions/ResyncToMailerLite.php create mode 100644 src/Activity/PayloadRedactor.php create mode 100644 src/AddonSettings.php create mode 100644 src/Fieldtypes/MailerLiteFieldFieldtype.php create mode 100644 src/Fieldtypes/MailerLiteGroupFieldtype.php delete mode 100644 src/Fieldtypes/MailerLiteGroups.php delete mode 100644 src/Http/Controllers/CP/FormConfigController.php delete mode 100644 src/Jobs/CreateSubscriberJob.php create mode 100644 src/Listeners/FormSubmittedListener.php create mode 100644 src/MailerLite/Contracts/MailerLiteServiceInterface.php create mode 100644 src/MailerLite/Exceptions/MailerLiteHttpException.php create mode 100644 src/MailerLite/Exceptions/MailerLiteValidationException.php create mode 100644 src/MailerLite/MailerLiteService.php delete mode 100644 src/Stache/FormConfigEntry.php delete mode 100644 src/Stache/FormConfigRepository.php delete mode 100644 src/Stache/FormConfigStore.php create mode 100644 src/Sync/SyncSubscriberJob.php create mode 100644 tests/Actions/ResyncToMailerLiteTest.php create mode 100644 tests/Activity/PayloadRedactorTest.php delete mode 100644 tests/ArchTest.php delete mode 100644 tests/ExampleTest.php create mode 100644 tests/Fieldtypes/MailerLiteFieldFieldtypeTest.php create mode 100644 tests/Fieldtypes/MailerLiteGroupFieldtypeTest.php create mode 100644 tests/Forms/AppendConfigFieldsTest.php create mode 100644 tests/Listeners/FormSubmittedListenerTest.php create mode 100644 tests/Listeners/SubmissionCreatedListenerTest.php create mode 100644 tests/MailerLite/ExceptionAdapterTest.php create mode 100644 tests/MailerLite/ListFieldsTest.php create mode 100644 tests/MailerLite/ListGroupsTest.php create mode 100644 tests/MailerLite/MailerLiteServiceTest.php create mode 100644 tests/MailerLite/PingTest.php create mode 100644 tests/Sync/Fixtures/TestableSyncSubscriberJob.php create mode 100644 tests/Sync/SyncPipelineIntegrationTest.php create mode 100644 tests/Sync/SyncSubscriberJobTest.php create mode 100644 tests/__fixtures__/users/admin+guard@example.com.yaml create mode 100644 tests/__fixtures__/users/admin+permitted@example.com.yaml create mode 100644 tests/__fixtures__/users/regular@example.com.yaml create mode 100644 tests/__fixtures__/users/super@example.com.yaml delete mode 100644 vite.config.js diff --git a/composer.json b/composer.json index 1aeb31e..29d998e 100644 --- a/composer.json +++ b/composer.json @@ -23,9 +23,9 @@ "statamic/cms": "^6.0" }, "require-dev": { + "larastan/larastan": "^3.0", "laravel/pint": "^1.14", "nunomaduro/collision": "^8.8", - "larastan/larastan": "^3.0", "orchestra/testbench": "^10.0.0||^9.0.0", "pestphp/pest": "^4.0", "pestphp/pest-plugin-arch": "^4.0", diff --git a/documentation.md b/documentation.md new file mode 100644 index 0000000..4e9d94b --- /dev/null +++ b/documentation.md @@ -0,0 +1,438 @@ +# Concept7 Statamic MailerLite — Code Reference + +> Compact lookup for short-codes referenced in code comments and planning docs. +> Code comments should reference these by ID instead of repeating the rationale inline. +> Codes are phase-scoped — e.g. D-11 in Phase 2 is different from D-11 in Phase 4. + +## Index + +- [Phase 1 — Storage Foundation](#phase-1--storage-foundation) + - [Decisions (D-XX, Phase 1)](#decisions-d-xx-phase-1) + - [Requirements (STOR-XX)](#requirements-stor-xx) +- [Phase 2 — Core Sync Pipeline](#phase-2--core-sync-pipeline) + - [Decisions (D-XX, Phase 2)](#decisions-d-xx-phase-2) + - [Requirements (SYNC-XX)](#requirements-sync-xx) + - [Code Review Findings (CR-XX, WR-XX, Phase 2)](#code-review-findings-cr-xx-wr-xx-phase-2) +- [Phase 3 — CP Settings + Fieldtypes](#phase-3--cp-settings--fieldtypes) + - [Decisions (D-XX, Phase 3)](#decisions-d-xx-phase-3) + - [Requirements (SET-XX, FORM-XX, FT-XX)](#requirements-set-xx-form-xx-ft-xx) + - [UAT Items (UAT-XX, Phase 3)](#uat-items-uat-xx-phase-3) +- [Phase 4 — Activity Log + CP Forms UI](#phase-4--activity-log--cp-forms-ui) + - [Decisions (D-XX, Phase 4)](#decisions-d-xx-phase-4) + - [Requirements (CP-XX)](#requirements-cp-xx) + - [Code Review Findings (WR-XX, Phase 4)](#code-review-findings-wr-xx-phase-4) +- [Phase 5 — Multi-Tenant Storage](#phase-5--multi-tenant-storage) + - [Decisions (D-XX, Phase 5)](#decisions-d-xx-phase-5) + - [Requirements (TEN-XX)](#requirements-ten-xx) +- [Phase 5.1 — CP Styling Refresh](#phase-51--cp-styling-refresh) + - [Decisions (D-XX, Phase 5.1)](#decisions-d-xx-phase-51) + - [SPEC Requirements (SPEC-RX)](#spec-requirements-spec-rx) +- [Phase 6 — Test Suite + Distribution](#phase-6--test-suite--distribution) + - [Decisions (D-XX, Phase 6)](#decisions-d-xx-phase-6) + - [Requirements (DIST-XX)](#requirements-dist-xx) +- [Threats (T-XX) — cross-phase](#threats-t-xx--cross-phase) +- [How to use this doc](#how-to-use-this-doc) + +--- + +## Phase 1 — Storage Foundation + +Source: `.planning/phases/01-storage-foundation/01-CONTEXT.md` + +### Decisions (D-XX, Phase 1) + +| ID | Title | Decision | Reference | +|---|---|---|---| +| D-01 | Per-form YAML layout | Per-form config persists via `Addon::setting('forms')`; no dedicated `content/mailerlite/` directory, no Stache store. | 01-CONTEXT.md §Per-form YAML layout | +| D-02 | `forms` keyed by handle | The `forms` array is keyed by form handle (not indexed list): `array, fields: array}>`. O(1) lookup. | 01-CONTEXT.md §Per-form YAML layout | +| D-03 | Auto-flush verification gate | Before AddonYamlDriver implementation, researcher must confirm `Addon::get()->set()` auto-flush semantics. | 01-CONTEXT.md §Per-form YAML layout | +| D-04 | API key persistence handle | API key persists in same Statamic addon-settings store under `Addon::setting('api_key')`. One persistence mechanism for the whole driver. | 01-CONTEXT.md §API key persistence | +| D-05 | SUPERSEDED — Encryption boundary internal | Originally: `saveApiKey()` calls `Crypt::encryptString` before persisting; `getApiKey()` calls `Crypt::decryptString`. SUPERSEDED by Phase 6 [[D-02]] (native-blueprint adoption) — Statamic's native settings UI writes plaintext to `resources/addons/`, so the driver mirrors that for round-trip consistency. Host operators lock down storage permissions; documented in README. | 01-CONTEXT.md §API key persistence | +| D-06 | SUPERSEDED — Decrypt-fail graceful fallback | Originally: lenient decrypt-fail on rotated `APP_KEY` / manual edit. SUPERSEDED by Phase 6 [[D-02]] — no decryption step. `getApiKey()` returns the raw stored string. | 01-CONTEXT.md §API key persistence | +| D-07 | No env-var fallback for API key | STOR-04 requires "encrypted at rest" — env vars can't be encrypted by `Crypt::encryptString`. Single source of truth: CP-entered key. | 01-CONTEXT.md §API key persistence | +| D-08 | Real interface-presence detection in Phase 1 | `TenancyDetector::resolveStorageMode()` returns `StorageMode::Yaml` whenever `class_exists(\Spatie\Multitenancy\Multitenancy::class)` is false. Phase 5 expansion is additive only. | 01-CONTEXT.md §TenancyDetector seed | +| D-09 | StorageMode backed enum | `enum StorageMode: string { Yaml = 'yaml'; SpatieFile = 'spatie-file'; SpatieDatabase = 'spatie-db'; }`. Resolver uses exhaustive `match()`. | 01-CONTEXT.md §TenancyDetector seed | +| D-10 | Container resolver via enum match | `match (TenancyDetector::resolveStorageMode()) { ... }` bound `scoped()` per [[STOR-03]]. Fresh instance per request/job. | 01-CONTEXT.md §TenancyDetector seed | +| D-11 | No config-file override | No `mailerlite.storage_mode` config knob — host environment alone drives the mode. | 01-CONTEXT.md §TenancyDetector seed | +| D-12 | Lenient read semantics | `getApiKey: ''`, `getFormConfig: []`, `allFormConfigs: []` on miss. No nullables, no exceptions on read path. | 01-CONTEXT.md §Method return semantics | +| D-13 | Loud-write semantics | Write methods return `void` and throw `MailerLiteStorageException` on failure. "Fail soft on read, loud on write." | 01-CONTEXT.md §Method return semantics | +| D-14 | No driver-side schema validation on write | Larastan level 5 + typed array shape from [[D-02]] is the contract; runtime validation lives in Phase 3 repository/fieldtype layer. | 01-CONTEXT.md §Method return semantics | + +### Requirements (STOR-XX) + +| ID | Description | +|---|---| +| STOR-01 | Addon resolves a single `StorageDriverInterface` via the container with three concrete drivers (`AddonYamlDriver`, `SpatieFileDriver`, `SpatieDatabaseDriver`). | +| STOR-02 | `TenancyDetector` returns the active storage mode (`yaml | spatie-file | spatie-db`) from host environment without requiring `spatie/laravel-multitenancy` in the addon's composer require. | +| STOR-03 | `StorageDriverInterface` is bound `scoped()` (not `singleton()`) so queue workers re-resolve per request/job and do not leak tenant context across jobs. | +| STOR-04 | SUPERSEDED — Originally: API key is encrypted at rest in all three modes (`Crypt::encryptString` for YAML, `'encrypted'` Eloquent cast for DB). SUPERSEDED by Phase 6 [[D-02]] — v1.0 stores plaintext via Statamic's native settings store; host operators lock down `resources/addons/` file permissions. README documents the requirement. | +| STOR-05 | Per-form config (enabled flag, field mappings, group IDs) reads and writes through the same `StorageDriverInterface` contract regardless of mode. | +| STOR-06 | Driver contract exposes `getApiKey`, `saveApiKey`, `getFormConfig`, `saveFormConfig`, `allFormConfigs` methods. | + +--- + +## Phase 2 — Core Sync Pipeline + +Source: `.planning/phases/02-core-sync-pipeline/02-CONTEXT.md` + +### Decisions (D-XX, Phase 2) + +| ID | Title | Decision | Reference | +|---|---|---|---| +| D-01 | Listener namespace location | Listeners live at `src/Listeners/`. Two files: `FormSubmittedListener.php` (captures IP per SYNC-01) and `SubmissionCreatedListener.php` (dispatches job per SYNC-02). | 02-CONTEXT.md §Listener wiring | +| D-02 | Explicit Event::listen wiring | Listeners wired via `Event::listen(...)` in service provider `boot()`. NOT auto-registered via `$listen` array (AddonServiceProvider doesn't expose that). Later resolved to auto-discovery — see provider source. | 02-CONTEXT.md §Listener wiring | +| D-03 | FormSubmitted mutates pre-persist | `FormSubmittedListener` attaches `ip_address` BEFORE submission persists (FormSubmitted fires pre-persist). | 02-CONTEXT.md §Listener wiring | +| D-04 | Thin SubmissionCreated listener | Listener reads handle, calls `getFormConfig`, early-returns if `enabled !== true`, dispatches `SyncSubscriberJob` with IDs only. No mapping/API logic. | 02-CONTEXT.md §Listener wiring | +| D-05 | Job constructor IDs only | `__construct(public readonly string $submissionId, public readonly ?string $tenantId = null)`. NO API key, NO mappings serialized into payload (SYNC-04). | 02-CONTEXT.md §SyncSubscriberJob shape | +| D-06 | Job ShouldQueue only in Phase 2 | `implements ShouldQueue` in Phase 2; `TenantAware` added additively in Phase 5 (FQCN `Spatie\Multitenancy\Jobs\TenantAware`). | 02-CONTEXT.md §SyncSubscriberJob shape | +| D-07 | Job resolves dependencies inside handle() | Inside `handle()`: `app(StorageDriverInterface::class)`, `getApiKey()`, `getFormConfig()`, `app(MailerLiteService)`. Never reads these from the constructor — Phase 5 tenant-leak prevention. | 02-CONTEXT.md §SyncSubscriberJob shape | +| D-08 | Empty API key → permanent failure | Empty API key short-circuits with `permanent_failure` activity log entry and `$this->fail()` — no SDK call. | 02-CONTEXT.md §SyncSubscriberJob shape | +| D-09 | Retry policy: 3 tries, [60, 300, 900] | `tries = 3`, `backoff = [60, 300, 900]`. **Amended by [[UAT-02]] (2026-05-04):** ALL 4xx (not just 422) → `MailerLiteValidationException` permanent; only 5xx + network → transient. | 02-CONTEXT.md §SyncSubscriberJob shape + STATE.md UAT-02 | +| D-10 | MailerLiteService boundary | `MailerLiteService` lives at `src/MailerLite/MailerLiteService.php`. Single Phase 2 public method: `upsertSubscriber(string $apiKey, string $email, array $fields, array $groupIds, ?string $ipAddress)`. | 02-CONTEXT.md §MailerLiteService boundary | +| D-11 | Service is stateless | Fresh `\MailerLite\MailerLite($apiKey)` SDK client per call. NO singleton, NO caching. Zero tenant-leakage surface in workers handling multiple tenants sequentially. | 02-CONTEXT.md §MailerLiteService boundary | +| D-12 | Two addon-namespace exception types | `MailerLiteValidationException` (4xx — permanent) and `MailerLiteHttpException` (5xx/network — transient). Service catches SDK exceptions, re-throws addon-typed equivalents. | 02-CONTEXT.md §MailerLiteService boundary | +| D-13 | Upsert via subscribers->create() | `$client->subscribers->create([...])` — MailerLite's `create()` is upsert-by-email per SYNC-06. NOT `update()` (404 on new), NOT find-then-create (race). | 02-CONTEXT.md §MailerLiteService boundary | +| D-14 | Empty-string filter at Job level | Empty-string filtering happens in Job `handle()` before calling Service. `array_filter($mappedFields, fn ($v) => $v !== '')`. Service stays thin SDK wrapper. | 02-CONTEXT.md §Field filtering | +| D-15 | Mapping logic in handle() | Reads `$config['fields']` typed shape from Phase 1 [[D-02]] and produces `$mappedFields = [mailerlite_field => $submission->data[$form_field] ?? '']`. | 02-CONTEXT.md §Field filtering | +| D-16 | SUPERSEDED — ActivityLogEntry Eloquent + migration | Originally: activity log persisted to `mailerlite_activity_log` DB table via Eloquent model. SUPERSEDED by Phase 6 [[D-04]] (POPO + JSONL — itself superseded), now Phase 6 [[D-05]] (read-side DTO over Statamic Submission `mailerlite_sync.attempts[]` annotation). v1.0 marketplace ships zero migrations and zero separate stores. | 02-CONTEXT.md §Activity log persistence | +| D-17 | AMENDED — Append-only log schema | Originally DB columns: `id (uuid)`, `tenant_id`, `form_handle`, `submission_id`, `status (enum)`, `payload (json)`, `error_message`, `created_at`. **AMENDED by Phase 6 [[D-05]]:** field shape projected from each attempt entry inside `mailerlite_sync.attempts[]` on the submission YAML. `tenant_id` dropped (Phase 6 [[D-01]]). `created_at` derived from `attempts[].attempted_at`. `form_handle` + `submission_id` derived from the submission's form + id. Append-only semantics preserved — each `log()` call appends a new attempt to the array. | 02-CONTEXT.md §Activity log persistence | +| D-18 | PayloadRedactor minimal Phase 2 | Strip array entries whose key matches `/password|token|secret|card|cvv/i` (case-insensitive) BEFORE JSON-encoding. Phase 4 [[CP-07]] may expand pattern set. | 02-CONTEXT.md §Activity log persistence | +| D-19 | finally-block log invariant | Job wraps work in `try { ... } catch (...) { ... } finally { $this->logger->log(...) }` — entry exists whether success, transient or permanent (SYNC-09). | 02-CONTEXT.md §Activity log persistence | +| D-20 | Singleton bindings for service + logger | `MailerLiteService` and `ActivityLogger` are bound `singleton()` (stateless). Only `StorageDriverInterface` needs `scoped()`. | 02-CONTEXT.md §Container bindings | +| D-21 | SUPERSEDED — loadMigrationsFrom in boot() | Originally: provider `boot()` called `loadMigrationsFrom(__DIR__.'/../database/migrations/landlord')`. SUPERSEDED by Phase 6 [[D-04]] (JSONL — itself superseded), now Phase 6 [[D-05]] — no migration shipped; activity log lives on the submission YAML. Both the `loadMigrationsFrom()` call and the `database/migrations/` directory are removed. | 02-CONTEXT.md §Container bindings | + +### Requirements (SYNC-XX) + +| ID | Description | +|---|---| +| SYNC-01 | `FormSubmittedListener` captures `ip_address` onto the submission before it persists. | +| SYNC-02 | `SubmissionCreatedListener` is thin — validates form has MailerLite enabled, then dispatches `SyncSubscriberJob` with submission ID and tenant ID. | +| SYNC-03 | `SyncSubscriberJob implements ShouldQueue` and additionally `Spatie\Multitenancy\Jobs\TenantAware` when multitenancy is present. | +| SYNC-04 | API key resolution happens INSIDE `SyncSubscriberJob::handle()` after Spatie's `EnsureJobHasCorrectTenant` middleware activates the tenant — never serialized into job payload. | +| SYNC-05 | `MailerLiteService` is stateless and accepts the API key as a method parameter; it does not read from storage. | +| SYNC-06 | Job upserts subscriber via `subscribers->create()` (MailerLite's upsert) with email, mapped custom fields, group IDs, and IP address. | +| SYNC-07 | Job filters out empty-string field values from the payload before calling the SDK. | +| SYNC-08 | Job differentiates `MailerLiteValidationException` (HTTP 4xx — permanent, no retry per [[UAT-02]] amendment) from other `MailerLiteHttpException` (transient — retries per queue config). | +| SYNC-09 | Job writes an activity log entry in a `finally` block regardless of success, transient failure, or permanent failure. | + +### Code Review Findings (CR-XX, WR-XX, Phase 2) + +Source: `.planning/phases/02-core-sync-pipeline/02-REVIEW.md`, `02-REVIEW-FIX.md` + +| ID | Severity | Issue | Fix landed | +|---|---|---|---| +| CR-01 | Blocker | Hardcoded `email` form-field name silently corrupts payload when host form's email handle differs (e.g. `email_address`). Sends `email: ''`, MailerLite 422s. | Resolved via `email_field` config key (default `'email'`); empty email → MailerLiteValidationException + `$this->fail()` + throw. See `src/Sync/SyncSubscriberJob.php`. | +| CR-02 | Blocker | `mapFields()` raw array access throws `ErrorException` on malformed config row (missing `form_field`/`mailerlite_field`). Misclassified as transient + retried 3x. | `mapFields()` validates each row shape; raises `MailerLiteValidationException` on malformed — classified permanent. | +| WR-01 | Warning | `MailerLiteService` success path not test-covered (only reflection tests). | Service now accepts optional `?Closure $clientFactory = null` constructor parameter; behavior tests added. | +| WR-02 | Warning | `Schema::drop('mailerlite_activity_log')` mid-test corrupts isolation. | Test rewritten to mock `ActivityLogEntry::create` failure path. | +| WR-03 | Warning | Table name `mailerlite_activity_log` lacks vendor prefix — marketplace collision risk. | Accepted/documented; revisit in Phase 6 DIST work if collision surfaces. | +| WR-04 | Warning | `$this->fail()` is silent when called outside a queue worker context (e.g., direct `handle()` call from Phase 4 retry). | `throw $e` added after each `$this->fail($e)` so direct callers see failure. | +| WR-05 | Warning | `is_array($groups)` / `is_string(...)` defensive checks unreachable per type hints. | Narrowed upstream assignment lines; service call site passes typed values without defensive checks. | +| WR-06 | Warning | `$config['enabled'] !== true` rejects bare `true` from YAML deserialization edge cases. | Loosened check to truthy comparison so `true`/`'1'`/`1` accepted. | +| WR-07 | Warning | Service-provider docblock claims auto-discovery; integration test only verifies `Event::hasListeners` (not which listener). | Test now asserts specific listener classes are bound. | + +--- + +## Phase 3 — CP Settings + Fieldtypes + +Source: `.planning/phases/03-cp-settings-fieldtypes/03-CONTEXT.md` + +### Decisions (D-XX, Phase 3) + +| ID | Title | Decision | Reference | +|---|---|---|---| +| D-01 | SUPERSEDED — Inertia/Vue settings page | Originally: settings rendered as Inertia/Vue page on a Statamic-registered route. SUPERSEDED by Phase 6 [[D-02]] — `resources/blueprints/settings.yaml` is auto-discovered by Statamic and rendered under Tools → Addons → MailerLite. No custom controller, no Vue page. | 03-CONTEXT.md §Settings UI tech | +| D-02 | SUPERSEDED — Route namespacing | Originally: `cp/mailerlite/{settings, settings/update, settings/test-connection}` routes registered under `statamic.cp.authenticated`. SUPERSEDED by Phase 6 [[D-02]] — all three settings routes removed; route file now carries only the Phase 4 dashboard / activity / retry routes. | 03-CONTEXT.md §Settings UI tech | +| D-03 | SUPERSEDED — Sidebar nav under "Tools" | Originally: dedicated "MailerLite" nav under Tools (Phase 3); Phase 4 [[D-13]] promoted to top-level. SUPERSEDED by Phase 6 [[D-02]] for the settings reachability — settings now lives at Statamic's standard Tools → Addons location; the top-level MailerLite nav (Phase 4) still surfaces the dashboard. Settings link in dashboard header removed. | 03-CONTEXT.md §Settings UI tech | +| D-04 | Form-tab injection research-deferred | Researcher MUST verify `Form::appendConfigFields` tab-vs-section semantics against `vendor/statamic/cms/src/Forms/Form.php` before planner commits. | 03-CONTEXT.md §Form-tab injection | +| D-05 | Provider boot() is wiring point | Form-config injection registered in `boot()` — alongside `loadMigrationsFrom` + listener auto-discovery. NOT a new bootstrapper class. | 03-CONTEXT.md §Form-tab injection | +| D-06 | Three-field MailerLite section | Order: (1) `enabled` toggle, (2) `mappings` replicator (grid: `form_field` + `mailerlite_field`), (3) `groups` multi-select. Toggle first so admin can disable without clearing mappings. | 03-CONTEXT.md §Field-mapping shape | +| D-07 | DEFERRED — form_field dropdown | Originally specified `form_field` dropdown sourced from parent form blueprint. Spike 03-00 proved Statamic 6's `Form::blueprint()` does not call `setParent($form)` → ships as `text` input. See `03-00-SPIKE-RESULT.md`. | 03-CONTEXT.md §Deferred Ideas | +| D-08 | Storage shape unchanged | Phase 3 stores the array shape from Phase 1 [[D-02]] — no migration. Driver contract is source of truth. | 03-CONTEXT.md §Field-mapping shape | +| D-09 | SUPERSEDED — SettingsController::update | Originally: custom controller with `min:32` (later `max:4096`) validation; `:attribute`-only error messages to mitigate [[T-API-KEY-LEAK-IN-VALIDATION-ERROR]]. SUPERSEDED by Phase 6 [[D-02]] — `SettingsController` deleted; Statamic's native settings form owns validation. | 03-CONTEXT.md §Settings persistence | +| D-10 | SUPERSEDED — testConnection() reads unsaved key | Originally: `testConnection()` action POSTed unsaved key to `MailerLiteService::ping(string $apiKey): bool`. SUPERSEDED by Phase 6 [[D-02]] — feature dropped in pre-distribution refactor. `MailerLiteService::ping()` itself stays (still used by other paths); only the CP action is gone. | 03-CONTEXT.md §Settings persistence | +| D-11 | SUPERSEDED — Test-connection rate limit | Originally: 5/min per user via `RateLimiter::for('mailerlite-test-connection', ...)`. SUPERSEDED by Phase 6 [[D-02]] — limiter + its registration removed in `StatamicMailerLiteServiceProvider::boot()`. Phase 4 retry limiter (separate name `mailerlite-retry`) remains. | 03-CONTEXT.md §Settings persistence | +| D-12 | configure mailerlite permission | Permission registered via Statamic's permission system in `boot()`. Super-users bypass automatically. Middleware on route group enforces — controllers stay clean. **Phase 6 [[D-02]] amendment:** permission still gates Phase 4 CP routes (dashboard, activity, retry); the settings-route group was removed, but the addon-settings page under Tools → Addons inherits Statamic's standard `access cp` + super-user gate. | 03-CONTEXT.md §Settings persistence | +| D-13 | Fieldtypes extend Relationship | Both fieldtypes extend Statamic's `Relationship`. Override `toItemArray($id)` and `getIndexItems($request)` to live-fetch from MailerLite. Buys CP UI affordances (search, multi-select) for free. | 03-CONTEXT.md §Fieldtypes | +| D-14 | API key via StorageDriverInterface | `app(StorageDriverInterface::class)->getApiKey()` — NEVER `Addon::setting('api_key')` (foot-gun for multi-tenancy, bypasses encryption boundary). | 03-CONTEXT.md §Fieldtypes | +| D-15 | Per-request fieldtype cache | Wrap SDK call in static-property cache keyed by `sha256(api_key) + endpoint` (mitigates [[T-FT-CACHE-CROSS-TENANT]]). NO cross-request Laravel Cache (admins expect new MailerLite fields/groups to appear without TTL wait). | 03-CONTEXT.md §Fieldtypes | +| D-16 | email synthetic baseline | `MailerLiteFieldFieldtype` prepends `['id' => 'email', 'title' => 'Email (built-in)']` to live-fetched list at position 0. MailerLite reserves `email` keyword → no collision risk. | 03-CONTEXT.md §Fieldtypes | +| D-17 | Graceful-degrade fieldtypes | Wrap SDK call in `try { ... } catch (MailerLiteValidationException|MailerLiteHttpException $e) { Log::warning(...); return []; }`. Empty API key short-circuits without log entry (configuration state, not error). | 03-CONTEXT.md §Fieldtypes | +| D-18 | Fieldtype registration | Both fieldtypes registered via service provider — Statamic auto-discovers fieldtypes from `src/Fieldtypes/` (verified). Explicit `Fieldtype::register(...)` is the safe fallback. | 03-CONTEXT.md §Fieldtypes | +| D-19 | FormSavedListener wires storage | `FormSaved` listener calls `$driver->saveFormConfig($form->handle(), $extracted)`. Auto-discovered per Phase 2 [[D-02]]. | 03-CONTEXT.md §Form-config persistence | +| D-20 | FORM-05 no-config early return | Listener early-returns when MailerLite section absent or `enabled !== true`. Driver's read path returns empty default config so [[SYNC-02]] early-returns and FORM-05 is satisfied. | 03-CONTEXT.md §Form-config persistence | +| D-21 | saveFormConfig called exactly once | Pest test pins `Mockery::times(1)` so saving doesn't re-fire on every CP page render. | 03-CONTEXT.md §Form-config persistence | +| D-22 | No new container bindings | Phase 3 adds NO new container bindings. SettingsController auto-resolved by Laravel; fieldtypes resolved by Statamic. | 03-CONTEXT.md §Container bindings | +| D-23 | Service provider boot() additive | boot() gains: `Form::appendConfigFields(...)`, `RateLimiter::for(...)`, CP nav extender, permission registration. All additive. Phase 4 mirrors this pattern. | 03-CONTEXT.md §Container bindings | + +### Requirements (SET-XX, FORM-XX, FT-XX) + +| ID | Description | +|---|---| +| SET-01 | CP settings screen where an admin enters the MailerLite API key. *(Phase 6 [[D-02]]: delivered via Statamic's native settings blueprint at `resources/blueprints/settings.yaml`, rendered under Tools → Addons → MailerLite. Originally Inertia/Vue per Phase 3 [[D-01]] — superseded.)* | +| SET-02 | SUPERSEDED — Originally: Settings save action persists the encrypted API key via the current `StorageDriverInterface`. SUPERSEDED by Phase 6 [[D-02]] — Statamic's native settings save writes plaintext; `StorageDriverInterface::saveApiKey` remains for programmatic callers but no longer encrypts. | +| SET-03 | SUPERSEDED — Originally: "Test connection" action. SUPERSEDED by Phase 6 [[D-02]] — feature dropped; admins verify the key by submitting a real form. | +| SET-04 | SUPERSEDED — Originally: 5/min rate limit on test-connection endpoint. SUPERSEDED by Phase 6 [[D-02]] — endpoint removed; no limiter required. | +| SET-05 | Settings page is guarded by `configure mailerlite` permission; Statamic super-users bypass. *(Phase 6 [[D-02]] amendment: the native blueprint inherits Statamic's standard CP gate. `configure mailerlite` continues to gate Phase 4 dashboard/activity/retry routes.)* | +| FORM-01 | Statamic's native form edit screen gains a "MailerLite" section via `Form::appendConfigFields('*', 'MailerLite', [...])`. | +| FORM-02 | The MailerLite section contains an enable toggle, a field-mapping repeater, and a groups multi-select. | +| FORM-03 | Each field-mapping row is a pair of (form field handle, MailerLite subscriber field) where both sides are dropdown fieldtypes. *(Note: form_field is `text` input per Phase 3 [[D-07]] deferral.)* | +| FORM-04 | Saving the form persists the per-form MailerLite config via `StorageDriverInterface` — never bypasses by writing direct YAML/Stache. | +| FORM-05 | A form with no MailerLite config renders and submits normally; the addon never breaks unconfigured forms. | +| FT-01 | `MailerLiteFieldFieldtype` (Relationship subclass) lists MailerLite subscriber custom fields by live-fetching from the API using the resolved tenant API key. | +| FT-02 | `MailerLiteGroupFieldtype` (Relationship subclass) lists MailerLite groups by live-fetching. | +| FT-03 | Both fieldtypes resolve the API key via `StorageDriverInterface` injection — never `Addon::setting('api_key')`. | +| FT-04 | Both fieldtypes degrade gracefully when API key missing/invalid/unreachable — empty list, warning logged, no exception leaks to CP. | +| FT-05 | `email` is always available as a baseline mapping target in `MailerLiteFieldFieldtype` alongside live-fetched custom fields. | + +### UAT Items (UAT-XX, Phase 3) + +Source: `.planning/phases/03-cp-settings-fieldtypes/03-HUMAN-UAT.md` + +| ID | Description | Status | +|---|---|---| +| UAT-01 | Live test-connection success path — paste sandbox token, expect inline "Connection successful." | Passed 2026-05-04. **Obsoleted by Phase 6 [[D-02]]** — test-connection feature dropped; UAT no longer reproducible in current build. | +| UAT-02 | Live test-connection error feedback — paste invalid key, expect "Invalid API key — double-check the value and try again." **Triggered the Phase 2 [[D-09]] retry-policy amendment (4xx → permanent, not just 422)** since MailerLite returns 401 for invalid bearer tokens. See `.planning/debug/resolved/uat-02-invalid-key-copy.md`. | Passed 2026-05-04 after service-adapter rewrite. **Obsoleted by Phase 6 [[D-02]]** — test-connection feature dropped. (The retry-policy amendment to Phase 2 [[D-09]] still applies — drove a real service-adapter fix that benefits the form-submit path.) | +| UAT-03 | MailerLite section renders on form edit screen — toggle, mapping repeater (`form_field` as text input per Phase 3 [[D-07]]), groups multi-select with live test group. | Passed 2026-05-04 | +| UAT-04 | Form save persistence round-trip — enable, add mapping, save, reload → config restored; submit form → subscriber lands in sandbox account. | Passed 2026-05-04 | + +--- + +## Phase 4 — Activity Log + CP Forms UI + +Source: `.planning/phases/04-activity-log-cp-forms-ui/04-CONTEXT.md` + +### Decisions (D-XX, Phase 4) + +| ID | Title | Decision | Reference | +|---|---|---|---| +| D-01 | Dashboard default sort | Default sort = failure count DESC (problems first), stable secondary sort by form name ASC. | 04-CONTEXT.md §Dashboard semantics | +| D-02 | Failure count = combined statuses | "Failure" in 24h count = permanent + transient combined. Single number, simple mental model. | 04-CONTEXT.md §Dashboard semantics | +| D-03 | Rolling 24-hour window | `WHERE created_at >= NOW() - INTERVAL 24 HOUR AND status IN ('permanent_failure', 'transient_failure')`. Never misleading-zero just past midnight. | 04-CONTEXT.md §Dashboard semantics | +| D-04 | No pagination v1 | Most Statamic sites have 5–20 forms total. Render all rows server-side, sortable client-side. Defer paginator until >50 forms reported. | 04-CONTEXT.md §Dashboard semantics | +| D-05 | Status badge palette | Green/amber/red for success / transient_failure / permanent_failure. Reuse Statamic's existing Badge component variants. | 04-CONTEXT.md §Activity log UX | +| D-06 | Truncated payload + modal | Truncated inline summary (~80 chars) + click-to-expand modal with full pretty-printed JSON. CP-07 redaction enforced at write-time — no re-redaction at read-time. | 04-CONTEXT.md §Activity log UX | +| D-07 | Inline retry button | Retry button inline at end of every failure row (both transient + permanent). No kebab menu. Success rows show no Retry button. | 04-CONTEXT.md §Activity log UX | +| D-08 | No confirm dialog on Retry | Click dispatches immediately; toast feedback. Retry is idempotent (upsert) and adds at most one new activity log row. | 04-CONTEXT.md §Activity log UX | +| D-09 | Dashboard empty state | Friendly empty state with "No MailerLite-enabled forms yet" + primary CTA linking to `/cp/forms`. | 04-CONTEXT.md §Empty states | +| D-10 | Activity log empty state | "No sync activity yet — submit a form to populate this view." Tells admin the table is empty by design. | 04-CONTEXT.md §Empty states | +| D-11 | Retry rate limit | 5/min per authenticated CP user via `RateLimiter::for('mailerlite-retry', ...)`. Mirrors Phase 3 [[D-11]] test-connection limiter. Mitigates [[T-04-RETRY-STAMPEDE]]. | 04-CONTEXT.md §Empty states | +| D-12 | 90-day auto-prune | Auto-prune activity log rows older than 90 days via scheduled Artisan command (`mailerlite:prune-activity-log`) registered via `Schedule::command(...)->daily()`. | 04-CONTEXT.md §Empty states | +| D-13 | Top-level MailerLite nav | Single top-level "MailerLite" CP nav entry lands on forms dashboard. Settings reachable from dashboard header link. Phase 3 nav under "Tools" is promoted. | 04-CONTEXT.md §CP nav structure | +| D-14 | Single configure mailerlite permission | Permission carries forward Phase 3 [[D-12]] — single permission for dashboard view, activity log view, retry, settings edit. View-only split deferred. | 04-CONTEXT.md §CP nav structure | +| D-15 | Hash-fragment focus pulse | Click-through from dashboard row → `/cp/forms/{form}/edit#mailerlite` + Vue `onMounted` watches `window.location.hash`, runs `scrollIntoView` + 1.5s `ring-2 ring-blue-500 ring-offset-2 rounded-md transition-all` pulse. Phase 5.1 updates the ring classes. | 04-CONTEXT.md §CP nav structure | +| D-16 | Nav icon `mailing-list` | Reuses Phase 3 Settings nav icon for visual continuity. | 04-CONTEXT.md §CP nav structure | + +### Requirements (CP-XX) + +| ID | Description | +|---|---| +| CP-01 | Top-level "MailerLite" CP nav item registered via `Nav::extend()`. | +| CP-02 | Forms list view shows every Statamic form with: name, enabled status, configured groups, last-sync timestamp, 24h failure count. | +| CP-03 | Clicking a form row opens that form's Statamic edit screen, focused on the MailerLite section added by `Form::appendConfigFields()`. | +| CP-04 | Per-form activity log view shows every sync attempt with timestamp, status, redacted payload, error message, and a retry button on failure rows. | +| CP-05 | Retry action dispatches a new `SyncSubscriberJob` using the activity log row's stored payload — does not re-run field mapping against potentially changed submission data. | +| CP-06 | All CP routes guarded by the `configure mailerlite` permission; super-users bypass. | +| CP-07 | Activity log payload writer redacts fields whose handles match `/password|token|secret|card|cvv/i` before persistence. | + +### Code Review Findings (WR-XX, Phase 4) + +Source: `.planning/phases/04-activity-log-cp-forms-ui/04-REVIEW.md`, `04-REVIEW-FIX.md` + +| ID | Severity | Issue | Fix landed | +|---|---|---|---| +| BL-01 | Blocker | `last_sync` rendering treats UTC datetime as local time → wrong "X min ago" for any non-UTC operator. | `FormHealthSummary` normalizes `last_sync` to ISO-8601 UTC via `Carbon::parse(..., 'UTC')->toIso8601String()`. | +| WR-01 | Warning | Dashboard secondary sort direction inverts incorrectly under `desc` primary sort (default "failures DESC" case is the broken one). | Direction applied only to primary axis; stable secondary always-ASC by name. | +| WR-02 | Warning | Sortable table headers have no keyboard handler — keyboard-only users cannot sort. | Added `role="button" tabindex="0"` + `@keydown.enter`/`.space` handlers + `aria-sort`. | +| WR-03 | Warning | Payload truncation `` is click-only with no keyboard/role — modal unreachable via keyboard. | Replaced with `