From 755a6007a7f0c1c1043d3a75b250eecbb3e253d7 Mon Sep 17 00:00:00 2001 From: Jurn Spijksma Date: Wed, 20 May 2026 14:48:57 +0200 Subject: [PATCH 1/9] v1 --- README.md | 111 +- composer.json | 10 +- ...0_create_mailerlite_activity_log_table.php | 30 + ...eated_index_to_mailerlite_activity_log.php | 40 + ...0_create_mailerlite_activity_log_table.php | 30 + ...eated_index_to_mailerlite_activity_log.php | 40 + documentation.md | 423 +++ package-lock.json | 2507 ++++++++--------- package.json | 27 +- phpstan-baseline.neon | 3 + phpstan.neon.dist | 9 + pint.json | 6 + resources/blueprints/settings.yaml | 12 - resources/dist/build/assets/addon-e4Zwj-7O.js | 1 - resources/dist/build/manifest.json | 8 - resources/js/addon.js | 5 - resources/js/cp/Activity/Show.vue | 224 ++ resources/js/cp/Dashboard/Index.vue | 256 ++ resources/js/cp/Settings/EditPage.vue | 229 ++ resources/js/cp/index.js | 70 + resources/js/pages/FormConfig/Index.vue | 67 - routes/cp.php | 64 +- src/Activity/ActivityLogEntry.php | 72 + src/Activity/ActivityLogger.php | 40 + src/Activity/PayloadRedactor.php | 27 + src/CP/ActivityController.php | 61 + src/CP/Concerns/EnsuresCurrentTenant.php | 28 + src/CP/DashboardController.php | 56 + src/CP/FormHealthSummary.php | 55 + src/CP/RetryController.php | 81 + src/Compat/spatie-tenant-aware-polyfill.php | 22 + .../Commands/PruneActivityLogCommand.php | 36 + src/Fieldtypes/MailerLiteFieldFieldtype.php | 103 + src/Fieldtypes/MailerLiteGroupFieldtype.php | 96 + src/Fieldtypes/MailerLiteGroups.php | 91 - .../Controllers/CP/FormConfigController.php | 256 -- src/Jobs/CreateSubscriberJob.php | 62 - src/Listeners/FormSavedListener.php | 45 + src/Listeners/FormSubmittedListener.php | 24 + src/Listeners/SubmissionCreatedListener.php | 59 +- .../Contracts/MailerLiteServiceInterface.php | 69 + .../Exceptions/MailerLiteHttpException.php | 9 + .../MailerLiteValidationException.php | 9 + src/MailerLite/MailerLiteService.php | 201 ++ src/Settings/SettingsController.php | 106 + src/Stache/FormConfigEntry.php | 81 - src/Stache/FormConfigRepository.php | 58 - src/Stache/FormConfigStore.php | 59 - src/StatamicMailerLiteServiceProvider.php | 234 ++ src/StatamicMailerliteServiceProvider.php | 44 - src/Storage/AddonYamlDriver.php | 136 + .../Exceptions/MailerLiteStorageException.php | 9 + src/Storage/StorageDriverInterface.php | 67 + src/Storage/StorageMode.php | 12 + src/Storage/TenancyDetector.php | 31 + src/Sync/SyncSubscriberJob.php | 301 ++ .../ActivityLogEntryMigrationTest.php | 72 + .../ActivityLogEntryModeAConnectionTest.php | 29 + .../ActivityLogEntryTenantConnectionTest.php | 84 + tests/Activity/ActivityLoggerTest.php | 199 ++ tests/Activity/PayloadRedactorTest.php | 93 + tests/ArchTest.php | 5 - tests/CP/ActivityControllerTest.php | 238 ++ tests/CP/DashboardControllerTest.php | 275 ++ tests/CP/EnsureCurrentTenantGuardTest.php | 208 ++ tests/CP/NavTest.php | 92 + tests/CP/PermissionTest.php | 27 + tests/CP/RateLimiterRegistrationTest.php | 93 + tests/CP/RetryControllerTest.php | 321 +++ tests/CP/RouteRegistrationTest.php | 107 + .../Compat/SpatieTenantAwarePolyfillTest.php | 43 + tests/Console/PruneActivityLogCommandTest.php | 104 + tests/ExampleTest.php | 5 - .../MailerLiteFieldFieldtypeTest.php | 196 ++ .../MailerLiteGroupFieldtypeTest.php | 140 + tests/Forms/AppendConfigFieldsTest.php | 89 + tests/Forms/FormSavedListenerTest.php | 163 ++ tests/Listeners/FormSubmittedListenerTest.php | 59 + .../SubmissionCreatedListenerTest.php | 215 ++ 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/MultitenantTestCase.php | 186 ++ tests/MultitenantTestCaseSmokeTest.php | 71 + tests/Pest.php | 49 +- .../Provider/StorageBindingResolutionTest.php | 83 + tests/Settings/SettingsControllerTest.php | 340 +++ tests/Storage/AddonYamlDriverTest.php | 158 ++ tests/Storage/ContainerBindingTest.php | 76 + tests/Storage/InterfaceContractTest.php | 45 + tests/Storage/PlainStatamicFallbackTest.php | 50 + tests/Storage/TenancyDetectorTest.php | 63 + .../Fixtures/TestableSyncSubscriberJob.php | 29 + tests/Sync/SyncPipelineIntegrationTest.php | 140 + .../SyncSubscriberJobTenantContextTest.php | 148 + tests/Sync/SyncSubscriberJobTest.php | 745 +++++ tests/TestCase.php | 61 +- ...0_000000_create_landlord_tenants_table.php | 33 + .../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 | 87 +- 105 files changed, 10764 insertions(+), 2202 deletions(-) create mode 100644 database/migrations/landlord/2026_04_30_000000_create_mailerlite_activity_log_table.php create mode 100644 database/migrations/landlord/2026_05_04_000001_add_form_status_created_index_to_mailerlite_activity_log.php create mode 100644 database/migrations/tenant/2026_04_30_000000_create_mailerlite_activity_log_table.php create mode 100644 database/migrations/tenant/2026_05_04_000001_add_form_status_created_index_to_mailerlite_activity_log.php create mode 100644 documentation.md create mode 100644 pint.json delete mode 100644 resources/blueprints/settings.yaml 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 create mode 100644 resources/js/cp/Activity/Show.vue create mode 100644 resources/js/cp/Dashboard/Index.vue create mode 100644 resources/js/cp/Settings/EditPage.vue create mode 100644 resources/js/cp/index.js delete mode 100644 resources/js/pages/FormConfig/Index.vue create mode 100644 src/Activity/ActivityLogEntry.php create mode 100644 src/Activity/ActivityLogger.php create mode 100644 src/Activity/PayloadRedactor.php create mode 100644 src/CP/ActivityController.php create mode 100644 src/CP/Concerns/EnsuresCurrentTenant.php create mode 100644 src/CP/DashboardController.php create mode 100644 src/CP/FormHealthSummary.php create mode 100644 src/CP/RetryController.php create mode 100644 src/Compat/spatie-tenant-aware-polyfill.php create mode 100644 src/Console/Commands/PruneActivityLogCommand.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/FormSavedListener.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 create mode 100644 src/Settings/SettingsController.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/StatamicMailerLiteServiceProvider.php delete mode 100644 src/StatamicMailerliteServiceProvider.php create mode 100644 src/Storage/AddonYamlDriver.php create mode 100644 src/Storage/Exceptions/MailerLiteStorageException.php create mode 100644 src/Storage/StorageDriverInterface.php create mode 100644 src/Storage/StorageMode.php create mode 100644 src/Storage/TenancyDetector.php create mode 100644 src/Sync/SyncSubscriberJob.php create mode 100644 tests/Activity/ActivityLogEntryMigrationTest.php create mode 100644 tests/Activity/ActivityLogEntryModeAConnectionTest.php create mode 100644 tests/Activity/ActivityLogEntryTenantConnectionTest.php create mode 100644 tests/Activity/ActivityLoggerTest.php create mode 100644 tests/Activity/PayloadRedactorTest.php delete mode 100644 tests/ArchTest.php create mode 100644 tests/CP/ActivityControllerTest.php create mode 100644 tests/CP/DashboardControllerTest.php create mode 100644 tests/CP/EnsureCurrentTenantGuardTest.php create mode 100644 tests/CP/NavTest.php create mode 100644 tests/CP/PermissionTest.php create mode 100644 tests/CP/RateLimiterRegistrationTest.php create mode 100644 tests/CP/RetryControllerTest.php create mode 100644 tests/CP/RouteRegistrationTest.php create mode 100644 tests/Compat/SpatieTenantAwarePolyfillTest.php create mode 100644 tests/Console/PruneActivityLogCommandTest.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/Forms/FormSavedListenerTest.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/MultitenantTestCase.php create mode 100644 tests/MultitenantTestCaseSmokeTest.php create mode 100644 tests/Provider/StorageBindingResolutionTest.php create mode 100644 tests/Settings/SettingsControllerTest.php create mode 100644 tests/Storage/AddonYamlDriverTest.php create mode 100644 tests/Storage/ContainerBindingTest.php create mode 100644 tests/Storage/InterfaceContractTest.php create mode 100644 tests/Storage/PlainStatamicFallbackTest.php create mode 100644 tests/Storage/TenancyDetectorTest.php create mode 100644 tests/Sync/Fixtures/TestableSyncSubscriberJob.php create mode 100644 tests/Sync/SyncPipelineIntegrationTest.php create mode 100644 tests/Sync/SyncSubscriberJobTenantContextTest.php create mode 100644 tests/Sync/SyncSubscriberJobTest.php create mode 100644 tests/__fixtures__/migrations/0000_00_00_000000_create_landlord_tenants_table.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 diff --git a/README.md b/README.md index 12dad16..c07e30b 100644 --- a/README.md +++ b/README.md @@ -1,84 +1,121 @@ -# A Statamic addon that automatically subscribes form submissions to MailerLite, with per-form configuration for subscriber groups and field mapping manageable from the Control Panel. +# Concept7 Statamic MailerLite -[![Latest Version on Packagist](https://img.shields.io/packagist/v/concept7/statamic-mailerlite.svg?style=flat-square)](https://packagist.org/packages/concept7/statamicmailerlite) -[![GitHub Tests Action Status](https://img.shields.io/github/actions/workflow/status/concept7/statamic-mailerlite/run-tests.yml?branch=main&label=tests&style=flat-square)](https://github.com/concept7/statamicmailerlite/actions?query=workflow%3Arun-tests+branch%3Amain) -[![GitHub Code Style Action Status](https://img.shields.io/github/actions/workflow/status/concept7/statamic-mailerlite/fix-php-code-style-issues.yml?branch=main&label=code%20style&style=flat-square)](https://github.com/concept7/statamicmailerlite/actions?query=workflow%3A"Fix+PHP+code+style+issues"+branch%3Amain) -[![Total Downloads](https://img.shields.io/packagist/dt/concept7/statamic-mailerlite.svg?style=flat-square)](https://packagist.org/packages/concept7/statamicmailerlite) +Automatically subscribe Statamic form submitters to MailerLite, with per-form configuration (enable toggle, field mapping, group selection) editable from the Statamic Control Panel. Supports plain Statamic 6 sites and multi-tenant hosts running `spatie/laravel-multitenancy`. -This is where your description should go. Limit it to a paragraph or two. Consider adding a small example. +[![Latest Version on Packagist](https://img.shields.io/packagist/v/concept7/statamic-mailerlite.svg?style=flat-square)](https://packagist.org/packages/concept7/statamic-mailerlite) +[![Tests](https://img.shields.io/github/actions/workflow/status/concept7/statamic-mailerlite/run-tests.yml?branch=0.x&label=tests&style=flat-square)](https://github.com/concept7/statamic-mailerlite/actions/workflows/run-tests.yml?query=branch%3A0.x) +[![PHPStan](https://img.shields.io/github/actions/workflow/status/concept7/statamic-mailerlite/phpstan.yml?branch=0.x&label=phpstan&style=flat-square)](https://github.com/concept7/statamic-mailerlite/actions/workflows/phpstan.yml?query=branch%3A0.x) +[![Total Downloads](https://img.shields.io/packagist/dt/concept7/statamic-mailerlite.svg?style=flat-square)](https://packagist.org/packages/concept7/statamic-mailerlite) -## Support us +## What it does -[](https://spatie.be/github-ad-click/StatamicMailerLite) +When a visitor submits a Statamic form that has the MailerLite integration enabled, the configured fields and groups are reliably synced to the correct MailerLite account — **without blocking the form response** and **without leaking config or subscribers across tenants**. -We invest a lot of resources into creating [best in class open source packages](https://spatie.be/open-source). You can support us by [buying one of our paid products](https://spatie.be/open-source/support-us). +- **Per-form configuration** — enable the integration, map Statamic fields to MailerLite custom fields, and choose which MailerLite groups the subscriber joins. Edited inline from each form's edit screen in the Statamic Control Panel. +- **Top-level Control Panel section** — a "MailerLite" nav item (nested under a "Concept7" parent for future addons) gives you a dashboard of every form, its enabled status, configured groups, last sync timestamp, and 24-hour failure count. +- **Per-form activity log** — every sync attempt with timestamp, status, redacted payload snapshot, error message, and a Retry action for failed jobs. +- **Settings + test connection** — global page for entering and rotating the MailerLite API key, with an inline "Test connection" button that hits a cheap read-only endpoint and surfaces a clear pass/fail status. +- **Async sync via queued job** — the `SubmissionCreated` listener implements `ShouldQueue`, so the form response is never blocked by MailerLite latency or downtime. Backoff and retry policy honor MailerLite's documented error codes. +- **Encrypted at rest** — the API key is encrypted via Laravel's `Crypt` facade before persistence. Activity log payloads redact obvious secret-shaped fields (`*_token`, `*_key`, `*_secret`, etc.) before storage. +- **Multi-tenant aware** — auto-detects whether `spatie/laravel-multitenancy` is installed and routes storage accordingly: + - **Plain Statamic** — single global API key, per-form config in addon YAML (`Addon::setting('forms')`). + - **Spatie file driver** — per-tenant YAML inside each tenant's storage directory. + - **Spatie DB driver** — per-tenant DB row in the `mailerlite_activity_log` table (migrations included). +- **Reusable fieldtypes** — `MailerLiteField` and `MailerLiteGroups` are Relationship-based fieldtypes that fetch live custom fields and groups directly from the MailerLite API, so you never have to hand-sync available options. -We highly appreciate you sending us a postcard from your hometown, mentioning which of our package(s) you are using. You'll find our address on [our contact page](https://spatie.be/about-us). We publish all received postcards on [our virtual postcard wall](https://spatie.be/open-source/postcards). +## Requirements -## Installation +- PHP **8.4** or higher +- Statamic **6.0** or higher (Laravel **12.x** or **13.x**) +- A MailerLite account with an API key (free tier works) +- *(Optional)* `spatie/laravel-multitenancy` **^4.0** — only required if the host is multi-tenant -You can install the package via composer: +## Installation ```bash composer require concept7/statamic-mailerlite ``` -You can publish and run the migrations with: +If the host site already runs `spatie/laravel-multitenancy`, publish the tenant-scoped activity-log migration: ```bash php artisan vendor:publish --tag="statamic-mailerlite-migrations" -php artisan migrate +php artisan migrate --path=database/migrations/tenant ``` -You can publish the config file with: +On plain Statamic sites (no multi-tenancy) the addon's storage backend is YAML-based and needs no migrations. + +Publish (and re-publish) the compiled CP frontend assets: ```bash -php artisan vendor:publish --tag="statamic-mailerlite-config" +php artisan vendor:publish --provider="Concept7\\StatamicMailerLite\\StatamicMailerLiteServiceProvider" --force ``` -This is the contents of the published config file: +The `--force` flag is required when upgrading — the Vite manifest hash changes on every release. -```php -return [ -]; -``` +## Configuration -Optionally, you can publish the views using +Open the Statamic Control Panel and navigate to **Concept7 → MailerLite → Settings**: -```bash -php artisan vendor:publish --tag="statamic-mailerlite-views" -``` +1. Paste your MailerLite API key. Click **Test connection** — a green pill confirms the key is valid; a red one tells you exactly what went wrong (HTTP 401 unauthorized, network error, etc.). +2. The key is encrypted via `Crypt::encryptString` before persistence; it never appears in any log, dump, or environment variable. -## Usage +Then, for each form you want to wire up: -```php -$statamicMailerLite = new Concept7\StatamicMailerLite(); -echo $statamicMailerLite->echoPhrase('Hello, Concept7!'); -``` +1. Edit the form in Statamic (**Forms → [Your Form] → Configure**). +2. In the **MailerLite** tab: + - Toggle **Enable** on. + - Pick one or more **Groups** the subscriber should join. + - Map each MailerLite field to a Statamic form field. A built-in synthetic "email" row is provided automatically and locked to the form's email field. +3. Submit the form as a visitor. Check **Concept7 → MailerLite → Dashboard** — you'll see the form's sync count tick up. Open the form row to see the per-submission activity log. + +## Multi-tenancy + +The addon auto-detects whether `\Spatie\Multitenancy\Multitenancy` is present in the host application: + +- **Absent** → all form configs and the API key live in addon YAML (a single global key). +- **Present (file driver)** → per-tenant YAML files inside each tenant's storage directory. Tenant isolation is enforced by Spatie's tenant resolver — no tenant can read another's API key, mappings, or activity log. +- **Present (database driver)** → per-tenant DB rows. Migrations for both the landlord and tenant connections ship in `database/migrations/`. + +Sync jobs are tenant-aware: the dispatched `SyncSubscriberJob` re-resolves the current tenant inside `handle()` (never at dispatch time), so re-queued jobs always sync to the right MailerLite account. ## Testing +The addon ships with a Pest test suite (Orchestra Testbench) covering storage adapters, service layer, listeners, the queued sync job, Control Panel routes, and fieldtypes. + ```bash +composer install composer test ``` +PHPStan and Pint also run clean against the source: + +```bash +composer analyse +composer format +``` + +## Documentation + +A compact code-reference document — `documentation.md` — lives at the package root. It cross-references every decision (D-XX), requirement (STOR/SYNC/SET/FT/CP/TEN/SPEC), threat (T-XX), and code-review finding referenced in inline code comments. Use it as a lookup table when reading the source. + ## Changelog -Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently. +See [CHANGELOG.md](CHANGELOG.md). ## Contributing -Please see [CONTRIBUTING](CONTRIBUTING.md) for details. +Issues and pull requests welcome — please target the `0.x` branch. Run `composer test`, `composer analyse`, and `composer format` before opening a PR. -## Security Vulnerabilities +## Security -Please review [our security policy](../../security/policy) on how to report security vulnerabilities. +If you discover a security vulnerability, please email security@concept7.nl rather than opening a public issue. ## Credits -- [Jan Henk Hazelaar](https://github.com/jhhazelaar) -- [All Contributors](../../contributors) +- [Jan Henk Hazelaar](https://github.com/jhhazelaar) — original author +- [Concept7](https://concept7.nl) — sponsor ## License -The MIT License (MIT). Please see [License File](LICENSE.md) for more information. +The MIT License (MIT). See [LICENSE.md](LICENSE.md). diff --git a/composer.json b/composer.json index 1aeb31e..19bb8c8 100644 --- a/composer.json +++ b/composer.json @@ -23,21 +23,25 @@ "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", "pestphp/pest-plugin-laravel": "^4.0", "phpstan/extension-installer": "^1.4", "phpstan/phpstan-deprecation-rules": "^2.0", - "phpstan/phpstan-phpunit": "^2.0" + "phpstan/phpstan-phpunit": "^2.0", + "spatie/laravel-multitenancy": "^4.0" }, "autoload": { "psr-4": { "Concept7\\StatamicMailerLite\\": "src/" - } + }, + "files": [ + "src/Compat/spatie-tenant-aware-polyfill.php" + ] }, "autoload-dev": { "psr-4": { diff --git a/database/migrations/landlord/2026_04_30_000000_create_mailerlite_activity_log_table.php b/database/migrations/landlord/2026_04_30_000000_create_mailerlite_activity_log_table.php new file mode 100644 index 0000000..5126392 --- /dev/null +++ b/database/migrations/landlord/2026_04_30_000000_create_mailerlite_activity_log_table.php @@ -0,0 +1,30 @@ +uuid('id')->primary(); + $table->string('tenant_id')->nullable()->index(); + $table->string('form_handle')->index(); + $table->string('submission_id')->nullable(); + $table->string('status'); // enum-cast at model layer (D-17): success | transient_failure | permanent_failure + $table->json('payload'); + $table->text('error_message')->nullable(); + $table->timestamp('created_at')->index(); + // D-17 — NO updated_at. Append-only log. + }); + } + + public function down(): void + { + Schema::dropIfExists('mailerlite_activity_log'); + } +}; diff --git a/database/migrations/landlord/2026_05_04_000001_add_form_status_created_index_to_mailerlite_activity_log.php b/database/migrations/landlord/2026_05_04_000001_add_form_status_created_index_to_mailerlite_activity_log.php new file mode 100644 index 0000000..86cbaf5 --- /dev/null +++ b/database/migrations/landlord/2026_05_04_000001_add_form_status_created_index_to_mailerlite_activity_log.php @@ -0,0 +1,40 @@ += ?` + * GROUP BY query and the activity log's `WHERE form_handle = ? ORDER BY created_at DESC` + * query both benefit from the leftmost-prefix coverage of (form_handle, status, created_at). + * + * Existing single-column indexes on tenant_id, form_handle, created_at are preserved — + * this migration is purely additive. + */ + public function up(): void + { + Schema::table('mailerlite_activity_log', function (Blueprint $table): void { + $table->index( + ['form_handle', 'status', 'created_at'], + 'mailerlite_log_form_status_created_idx', + ); + }); + } + + public function down(): void + { + Schema::table('mailerlite_activity_log', function (Blueprint $table): void { + $table->dropIndex('mailerlite_log_form_status_created_idx'); + }); + } +}; diff --git a/database/migrations/tenant/2026_04_30_000000_create_mailerlite_activity_log_table.php b/database/migrations/tenant/2026_04_30_000000_create_mailerlite_activity_log_table.php new file mode 100644 index 0000000..5126392 --- /dev/null +++ b/database/migrations/tenant/2026_04_30_000000_create_mailerlite_activity_log_table.php @@ -0,0 +1,30 @@ +uuid('id')->primary(); + $table->string('tenant_id')->nullable()->index(); + $table->string('form_handle')->index(); + $table->string('submission_id')->nullable(); + $table->string('status'); // enum-cast at model layer (D-17): success | transient_failure | permanent_failure + $table->json('payload'); + $table->text('error_message')->nullable(); + $table->timestamp('created_at')->index(); + // D-17 — NO updated_at. Append-only log. + }); + } + + public function down(): void + { + Schema::dropIfExists('mailerlite_activity_log'); + } +}; diff --git a/database/migrations/tenant/2026_05_04_000001_add_form_status_created_index_to_mailerlite_activity_log.php b/database/migrations/tenant/2026_05_04_000001_add_form_status_created_index_to_mailerlite_activity_log.php new file mode 100644 index 0000000..86cbaf5 --- /dev/null +++ b/database/migrations/tenant/2026_05_04_000001_add_form_status_created_index_to_mailerlite_activity_log.php @@ -0,0 +1,40 @@ += ?` + * GROUP BY query and the activity log's `WHERE form_handle = ? ORDER BY created_at DESC` + * query both benefit from the leftmost-prefix coverage of (form_handle, status, created_at). + * + * Existing single-column indexes on tenant_id, form_handle, created_at are preserved — + * this migration is purely additive. + */ + public function up(): void + { + Schema::table('mailerlite_activity_log', function (Blueprint $table): void { + $table->index( + ['form_handle', 'status', 'created_at'], + 'mailerlite_log_form_status_created_idx', + ); + }); + } + + public function down(): void + { + Schema::table('mailerlite_activity_log', function (Blueprint $table): void { + $table->dropIndex('mailerlite_log_form_status_created_idx'); + }); + } +}; diff --git a/documentation.md b/documentation.md new file mode 100644 index 0000000..1c4b02c --- /dev/null +++ b/documentation.md @@ -0,0 +1,423 @@ +# 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) + - [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 | Encryption boundary internal | `saveApiKey()` calls `Crypt::encryptString` before persisting; `getApiKey()` calls `Crypt::decryptString` on read. Callers never see ciphertext. | 01-CONTEXT.md §API key persistence | +| D-06 | Decrypt-fail graceful fallback | When `Crypt::decryptString` throws (rotated APP_KEY, manual edit), driver logs a warning and returns `''`. Never throws. Honors [[FT-04]]. | 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 | API key is encrypted at rest in all three modes — `Crypt::encryptString` for YAML drivers, `'encrypted'` Eloquent cast for the DB driver. | +| 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 | ActivityLogEntry Eloquent + migration | Activity log persists to `mailerlite_activity_log` via `Concept7\StatamicMailerLite\Activity\ActivityLogEntry`. Phase 2 owns migration + model; Phase 4 reads from same table. | 02-CONTEXT.md §Activity log persistence | +| D-17 | Append-only log schema | Schema: `id (uuid)`, `tenant_id`, `form_handle`, `submission_id`, `status (enum)`, `payload (json)`, `error_message`, `created_at`. NO `updated_at` — append-only. | 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 | loadMigrationsFrom in boot() | Migration at `database/migrations/`; provider `boot()` calls `loadMigrationsFrom()` so host's `php artisan migrate` picks it up. NO `vendor:publish` step. | 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 | Inertia/Vue settings page | Settings is a Statamic-registered route rendered as Inertia/Vue page. Integrates with CP shell (sidebar, top nav, breadcrumbs, toasts) without re-implementing chrome. | 03-CONTEXT.md §Settings UI tech | +| D-02 | Route namespacing | Routes registered via `bootRoutes()` under prefix `cp/mailerlite`, middleware `statamic.cp.authenticated`. Names: `mailerlite.settings.{edit, update, test-connection}`. | 03-CONTEXT.md §Settings UI tech | +| D-03 | Sidebar nav under "Tools" | Single CP nav item under "Tools" titled "MailerLite". Visibility gated by [[SET-05]] permission. Phase 4 [[D-13]] promotes this to top-level. | 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 | SettingsController::update | Validates API key as non-empty string of expected length (>=64 chars). Validation messages must NEVER echo `:input` (mitigates [[T-API-KEY-LEAK-IN-VALIDATION-ERROR]]). | 03-CONTEXT.md §Settings persistence | +| D-10 | testConnection() reads unsaved key | Reads submitted (unsaved) API key from request body and calls new lightweight `MailerLiteService::ping(string $apiKey): bool` (hits `$client->fields->get()` per SET-03). Avoids creating a real subscriber on every test click. | 03-CONTEXT.md §Settings persistence | +| D-11 | Test-connection rate limit | 5/min per authenticated CP user via `RateLimiter::for('mailerlite-test-connection', fn ($req) => Limit::perMinute(5)->by($req->user()->id))`. Phase 4 retry limiter mirrors this exactly. | 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. | 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 (Inertia/Vue screen on Statamic-registered route). | +| SET-02 | Settings save action persists the encrypted API key via the current `StorageDriverInterface`. | +| SET-03 | "Test connection" action calls a cheap MailerLite endpoint (e.g. `fields->get()`) and returns inline success or failure feedback. | +| SET-04 | Test connection endpoint is rate-limited per user/IP (5 calls/minute) to prevent MailerLite lockout. | +| SET-05 | Settings page is guarded by `configure mailerlite` permission; Statamic super-users bypass. | +| 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 | +| UAT-02 | Live test-connection error feedback — paste invalid key, expect "Invalid API key — double-check the value and try again." **Triggered the [[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 | +| 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 `