diff --git a/backend/.env b/backend/.env index 44f99e24..1926fa02 100644 --- a/backend/.env +++ b/backend/.env @@ -31,7 +31,6 @@ URL_ARCHIVE=https://hyvorpost.email # One of: debug, info, notice, warning, error, critical, alert, emergency LOG_LEVEL=info - ### ============ HYVOR CLOUD ============ ### # Deployment type @@ -54,13 +53,11 @@ COMMS_KEY= # @deprecated. Migrate to DEPLOYMENT env IS_CLOUD=true - ### ============ DATABASE ============ ### # PostgreSQL database is the single source of truth DATABASE_URL="" - ### ============ MAIL ============ ### # Hyvor Relay configuration @@ -84,7 +81,6 @@ NOTIFICATION_MAIL_REPLY_TO= # If not set, if will fallback to RELAY_API_KEY NOTIFICATION_RELAY_API_KEY= - ### ============ FILE STORAGE ============ ### # You can use any S3-compatibly storage like @@ -97,6 +93,10 @@ S3_ACCESS_KEY_ID=key-id S3_SECRET_ACCESS_KEY=access-key S3_BUCKET=hyvor-post +### ============ INTEGRATIONS ============ ### + +# Sentry-compatible DSN for error tracking +SENTRY_DSN= ### ============ SCALING ============ ### @@ -104,7 +104,6 @@ S3_BUCKET=hyvor-post # Default is x2 CPUs WORKERS= - ### ============ DOCKER IMAGE ============ ### # Defaults (do not change or add to docs): diff --git a/backend/.gitignore b/backend/.gitignore index 05abde28..67fd15a8 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -2,6 +2,7 @@ /.env.dev.local /.env.local.php /.env.*.local +/.env.local /config/secrets/prod/prod.decrypt.private.php /public/bundles/ /var/ diff --git a/backend/composer.json b/backend/composer.json index 40bf0f2f..f109f740 100644 --- a/backend/composer.json +++ b/backend/composer.json @@ -45,7 +45,8 @@ "twig/cssinliner-extra": "^3.21", "twig/extra-bundle": "^3.21", "symfony/dom-crawler": "7.4.*", - "zenstruck/messenger-monitor-bundle": "^0.6.0" + "zenstruck/messenger-monitor-bundle": "^0.6.0", + "sentry/sentry-symfony": "^5.9" }, "bump-after-update": true, "sort-packages": true, diff --git a/backend/composer.lock b/backend/composer.lock index cec1baf5..19eb728c 100644 --- a/backend/composer.lock +++ b/backend/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "cdd012dd5bf3ebdc6cb6a7c0079c7639", + "content-hash": "84ff26b07990da9b940ce6cb14f5bcb2", "packages": [ { "name": "aws/aws-crt-php", @@ -2511,6 +2511,66 @@ }, "time": "2026-02-04T15:14:59+00:00" }, + { + "name": "jean85/pretty-package-versions", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/Jean85/pretty-package-versions.git", + "reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Jean85/pretty-package-versions/zipball/4d7aa5dab42e2a76d99559706022885de0e18e1a", + "reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2.1.0", + "php": "^7.4|^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.2", + "jean85/composer-provided-replaced-stub-package": "^1.0", + "phpstan/phpstan": "^2.0", + "phpunit/phpunit": "^7.5|^8.5|^9.6", + "rector/rector": "^2.0", + "vimeo/psalm": "^4.3 || ^5.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Jean85\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Alessandro Lai", + "email": "alessandro.lai85@gmail.com" + } + ], + "description": "A library to get pretty versions strings of installed dependencies", + "keywords": [ + "composer", + "package", + "release", + "versions" + ], + "support": { + "issues": "https://github.com/Jean85/pretty-package-versions/issues", + "source": "https://github.com/Jean85/pretty-package-versions/tree/2.1.1" + }, + "time": "2025-03-19T14:43:43+00:00" + }, { "name": "league/flysystem", "version": "3.31.0", @@ -4067,6 +4127,199 @@ ], "time": "2023-12-12T12:06:11+00:00" }, + { + "name": "sentry/sentry", + "version": "4.21.0", + "source": { + "type": "git", + "url": "https://github.com/getsentry/sentry-php.git", + "reference": "2bf405fc4d38f00073a7d023cf321e59f614d54c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/getsentry/sentry-php/zipball/2bf405fc4d38f00073a7d023cf321e59f614d54c", + "reference": "2bf405fc4d38f00073a7d023cf321e59f614d54c", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "ext-json": "*", + "ext-mbstring": "*", + "guzzlehttp/psr7": "^1.8.4|^2.1.1", + "jean85/pretty-package-versions": "^1.5|^2.0.4", + "php": "^7.2|^8.0", + "psr/log": "^1.0|^2.0|^3.0", + "symfony/options-resolver": "^4.4.30|^5.0.11|^6.0|^7.0|^8.0" + }, + "conflict": { + "raven/raven": "*" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.4", + "guzzlehttp/promises": "^2.0.3", + "guzzlehttp/psr7": "^1.8.4|^2.1.1", + "monolog/monolog": "^1.6|^2.0|^3.0", + "nyholm/psr7": "^1.8", + "phpbench/phpbench": "^1.0", + "phpstan/phpstan": "^1.3", + "phpunit/phpunit": "^8.5.52|^9.6.34", + "spiral/roadrunner-http": "^3.6", + "spiral/roadrunner-worker": "^3.6", + "vimeo/psalm": "^4.17" + }, + "suggest": { + "monolog/monolog": "Allow sending log messages to Sentry by using the included Monolog handler." + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Sentry\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Sentry", + "email": "accounts@sentry.io" + } + ], + "description": "PHP SDK for Sentry (http://sentry.io)", + "homepage": "http://sentry.io", + "keywords": [ + "crash-reporting", + "crash-reports", + "error-handler", + "error-monitoring", + "log", + "logging", + "profiling", + "sentry", + "tracing" + ], + "support": { + "issues": "https://github.com/getsentry/sentry-php/issues", + "source": "https://github.com/getsentry/sentry-php/tree/4.21.0" + }, + "funding": [ + { + "url": "https://sentry.io/", + "type": "custom" + }, + { + "url": "https://sentry.io/pricing/", + "type": "custom" + } + ], + "time": "2026-02-24T15:32:51+00:00" + }, + { + "name": "sentry/sentry-symfony", + "version": "5.9.0", + "source": { + "type": "git", + "url": "https://github.com/getsentry/sentry-symfony.git", + "reference": "75a73de23b9af414b3c8b15c26187a4ae6c65732" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/getsentry/sentry-symfony/zipball/75a73de23b9af414b3c8b15c26187a4ae6c65732", + "reference": "75a73de23b9af414b3c8b15c26187a4ae6c65732", + "shasum": "" + }, + "require": { + "guzzlehttp/psr7": "^2.1.1", + "jean85/pretty-package-versions": "^1.5||^2.0", + "php": "^7.2||^8.0", + "sentry/sentry": "^4.20.0", + "symfony/cache-contracts": "^1.1||^2.4||^3.0", + "symfony/config": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0", + "symfony/console": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0", + "symfony/dependency-injection": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0", + "symfony/event-dispatcher": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0", + "symfony/http-kernel": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0", + "symfony/polyfill-php80": "^1.22", + "symfony/psr-http-message-bridge": "^1.2||^2.0||^6.4||^7.0||^8.0", + "symfony/yaml": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0" + }, + "require-dev": { + "doctrine/dbal": "^2.13||^3.3||^4.0", + "doctrine/doctrine-bundle": "^2.6||^3.0", + "friendsofphp/php-cs-fixer": "^2.19||^3.40", + "masterminds/html5": "^2.8", + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "1.12.5", + "phpstan/phpstan-phpunit": "1.4.0", + "phpstan/phpstan-symfony": "1.4.10", + "phpunit/phpunit": "^8.5.40||^9.6.21", + "symfony/browser-kit": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0", + "symfony/cache": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0", + "symfony/dom-crawler": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0", + "symfony/framework-bundle": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0", + "symfony/http-client": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0", + "symfony/messenger": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0", + "symfony/monolog-bundle": "^3.4||^4.0", + "symfony/phpunit-bridge": "^5.2.6||^6.0||^7.0||^8.0", + "symfony/process": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0", + "symfony/security-core": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0", + "symfony/security-http": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0", + "symfony/twig-bundle": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0", + "vimeo/psalm": "^4.3||^5.16.0" + }, + "suggest": { + "doctrine/doctrine-bundle": "Allow distributed tracing of database queries using Sentry.", + "monolog/monolog": "Allow sending log messages to Sentry by using the included Monolog handler.", + "symfony/cache": "Allow distributed tracing of cache pools using Sentry.", + "symfony/twig-bundle": "Allow distributed tracing of Twig template rendering using Sentry." + }, + "type": "symfony-bundle", + "autoload": { + "files": [ + "src/aliases.php" + ], + "psr-4": { + "Sentry\\SentryBundle\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Sentry", + "email": "accounts@sentry.io" + } + ], + "description": "Symfony integration for Sentry (http://getsentry.com)", + "homepage": "http://getsentry.com", + "keywords": [ + "errors", + "logging", + "sentry", + "symfony" + ], + "support": { + "issues": "https://github.com/getsentry/sentry-symfony/issues", + "source": "https://github.com/getsentry/sentry-symfony/tree/5.9.0" + }, + "funding": [ + { + "url": "https://sentry.io/", + "type": "custom" + }, + { + "url": "https://sentry.io/pricing/", + "type": "custom" + } + ], + "time": "2026-02-23T12:32:36+00:00" + }, { "name": "symfony/cache", "version": "v7.4.5", @@ -7590,6 +7843,94 @@ ], "time": "2026-01-27T16:16:02+00:00" }, + { + "name": "symfony/psr-http-message-bridge", + "version": "v7.4.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/psr-http-message-bridge.git", + "reference": "929ffe10bbfbb92e711ac3818d416f9daffee067" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/psr-http-message-bridge/zipball/929ffe10bbfbb92e711ac3818d416f9daffee067", + "reference": "929ffe10bbfbb92e711ac3818d416f9daffee067", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/http-message": "^1.0|^2.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0" + }, + "conflict": { + "php-http/discovery": "<1.15", + "symfony/http-kernel": "<6.4" + }, + "require-dev": { + "nyholm/psr7": "^1.1", + "php-http/discovery": "^1.15", + "psr/log": "^1.1.4|^2|^3", + "symfony/browser-kit": "^6.4|^7.0|^8.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/framework-bundle": "^6.4.13|^7.1.6|^8.0", + "symfony/http-kernel": "^6.4.13|^7.1.6|^8.0", + "symfony/runtime": "^6.4.13|^7.1.6|^8.0" + }, + "type": "symfony-bridge", + "autoload": { + "psr-4": { + "Symfony\\Bridge\\PsrHttpMessage\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "PSR HTTP message bridge", + "homepage": "https://symfony.com", + "keywords": [ + "http", + "http-message", + "psr-17", + "psr-7" + ], + "support": { + "source": "https://github.com/symfony/psr-http-message-bridge/tree/v7.4.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-03T23:30:35+00:00" + }, { "name": "symfony/rate-limiter", "version": "v7.4.5", diff --git a/backend/config/bundles.php b/backend/config/bundles.php index 0e09a636..2db7f272 100644 --- a/backend/config/bundles.php +++ b/backend/config/bundles.php @@ -17,4 +17,5 @@ Symfony\UX\TwigComponent\TwigComponentBundle::class => ['all' => true], Twig\Extra\TwigExtraBundle\TwigExtraBundle::class => ['all' => true], Zenstruck\Messenger\Monitor\ZenstruckMessengerMonitorBundle::class => ['all' => true], + Sentry\SentryBundle\SentryBundle::class => ['prod' => true], ]; diff --git a/backend/config/packages/monolog.php b/backend/config/packages/monolog.php index 1c667be4..fb0a8726 100644 --- a/backend/config/packages/monolog.php +++ b/backend/config/packages/monolog.php @@ -9,7 +9,6 @@ ->type('buffer') ->handler('final') ->level("%env(LOG_LEVEL)%") - ->bubble(false) ->channels()->elements(['app']); $monolog->handler('non_app') ->type('buffer') diff --git a/backend/config/packages/sentry.yaml b/backend/config/packages/sentry.yaml new file mode 100644 index 00000000..23a0dd9e --- /dev/null +++ b/backend/config/packages/sentry.yaml @@ -0,0 +1,39 @@ +when@prod: + sentry: + dsn: '%env(SENTRY_DSN)%' + options: + # Add request headers, cookies, IP address and the authenticated user + # see https://docs.sentry.io/platforms/php/data-management/data-collected/ for more info + # send_default_pii: true + ignore_exceptions: + - 'Symfony\Component\ErrorHandler\Error\FatalError' + - 'Symfony\Component\Debug\Exception\FatalErrorException' +# +# # If you are using Monolog, you also need this additional configuration to log the errors correctly: +# # https://docs.sentry.io/platforms/php/guides/symfony/integrations/monolog/ +# register_error_listener: false +# register_error_handler: false +# + monolog: + handlers: + # Use this only if you don't want to use structured logging and instead receive + # certain log levels as errors. + sentry: + type: service + id: Sentry\Monolog\Handler +# +# # Use this for structured log integration +# sentry_logs: +# type: service +# id: Sentry\SentryBundle\Monolog\LogsHandler +# +# # Enable one of the two services below, depending on your choice above + services: + Sentry\Monolog\Handler: + arguments: + $hub: '@Sentry\State\HubInterface' + $level: !php/const Monolog\Logger::ERROR + $fillExtraContext: true # Enables sending monolog context to Sentry +# Sentry\SentryBundle\Monolog\LogsHandler: +# arguments: +# - !php/const Monolog\Logger::INFO diff --git a/backend/config/reference.php b/backend/config/reference.php index 61e9ec8e..06bd1875 100644 --- a/backend/config/reference.php +++ b/backend/config/reference.php @@ -208,29 +208,29 @@ * initial_marking?: list, * events_to_dispatch?: list|null, * places?: list, + * name?: scalar|Param|null, + * metadata?: array, * }>, - * transitions: list, * to?: list, * weight?: int|Param, // Default: 1 - * metadata?: list, + * metadata?: array, * }>, - * metadata?: list, + * metadata?: array, * }>, * }, * router?: bool|array{ // Router configuration * enabled?: bool|Param, // Default: false - * resource: scalar|Param|null, + * resource?: scalar|Param|null, * type?: scalar|Param|null, * cache_dir?: scalar|Param|null, // Deprecated: Setting the "framework.router.cache_dir.cache_dir" configuration option is deprecated. It will be removed in version 8.0. // Default: "%kernel.build_dir%" * default_uri?: scalar|Param|null, // The default URI used to generate URLs in a non-HTTP context. // Default: null @@ -360,10 +360,10 @@ * mapping?: array{ * paths?: list, * }, - * default_context?: list, + * default_context?: array, * named_serializers?: array, + * default_context?: array, * include_built_in_normalizers?: bool|Param, // Whether to include the built-in normalizers // Default: true * include_built_in_encoders?: bool|Param, // Whether to include the built-in encoders // Default: true * }>, @@ -427,7 +427,7 @@ * }, * messenger?: bool|array{ // Messenger configuration * enabled?: bool|Param, // Default: true - * routing?: array, * }>, * serializer?: array{ @@ -440,7 +440,7 @@ * transports?: array, + * options?: array, * failure_transport?: scalar|Param|null, // Transport name to send failed messages to (after all retries have failed). // Default: null * retry_strategy?: string|array{ * service?: scalar|Param|null, // Service id to override the retry strategy entirely. // Default: null @@ -462,7 +462,7 @@ * allow_no_senders?: bool|Param, // Default: true * }, * middleware?: list, * }>, * }>, @@ -634,7 +634,7 @@ * lock_factory?: scalar|Param|null, // The service ID of the lock factory used by this limiter (or null to disable locking). // Default: "auto" * cache_pool?: scalar|Param|null, // The cache pool to use for storing the current limiter state. // Default: "cache.rate_limiter" * storage_service?: scalar|Param|null, // The service ID of a custom storage implementation, this precedes any configured "cache_pool". // Default: null - * policy: "fixed_window"|"token_bucket"|"sliding_window"|"compound"|"no_limit"|Param, // The algorithm to be used by this limiter. + * policy?: "fixed_window"|"token_bucket"|"sliding_window"|"compound"|"no_limit"|Param, // The algorithm to be used by this limiter. * limiters?: list, * limit?: int|Param, // The maximum allowed hits in a fixed interval or burst. * interval?: scalar|Param|null, // Configures the fixed interval if "policy" is set to "fixed_window" or "sliding_window". The value must be a number followed by "second", "minute", "hour", "day", "week" or "month" (or their plural equivalent). @@ -679,7 +679,7 @@ * enabled?: bool|Param, // Default: false * message_bus?: scalar|Param|null, // The message bus to use. // Default: "messenger.default_bus" * routing?: array, * }, @@ -694,7 +694,7 @@ * dbal?: array{ * default_connection?: scalar|Param|null, * types?: array, * driver_schemes?: array, @@ -910,7 +910,7 @@ * datetime_functions?: array, * }, * filters?: array, * }>, @@ -1045,7 +1045,7 @@ * use_microseconds?: scalar|Param|null, // Default: true * channels?: list, * handlers?: array, * mailer?: scalar|Param|null, // Default: null * email_prototype?: string|array{ - * id: scalar|Param|null, + * id?: scalar|Param|null, * method?: scalar|Param|null, // Default: null * }, * lazy?: bool|Param, // Default: true @@ -1402,7 +1402,10 @@ final class App */ public static function config(array $config): array { - return AppReference::config($config); + /** @var ConfigType $config */ + $config = AppReference::config($config); + + return $config; } } diff --git a/backend/migrations/Version20260225000000.php b/backend/migrations/Version20260225000000.php new file mode 100644 index 00000000..5078457b --- /dev/null +++ b/backend/migrations/Version20260225000000.php @@ -0,0 +1,37 @@ +addSql( + <<addSql('DROP TABLE list_subscriber_unsubscribed'); + } +} diff --git a/backend/src/Api/Console/Controller/ApiKeyController.php b/backend/src/Api/Console/Controller/ApiKeyController.php index ab10addf..5ac8b905 100644 --- a/backend/src/Api/Console/Controller/ApiKeyController.php +++ b/backend/src/Api/Console/Controller/ApiKeyController.php @@ -54,13 +54,13 @@ public function getApiKeys(Newsletter $newsletter): JsonResponse public function updateApiKey(#[MapRequestPayload] UpdateApiKeyInput $input, ApiKey $apiKey): JsonResponse { $updates = new UpdateApiKeyDto(); - if ($input->hasProperty('is_enabled')) { + if ($input->has('is_enabled')) { $updates->enabled = $input->is_enabled; } - if ($input->hasProperty('scopes')) { + if ($input->has('scopes')) { $updates->scopes = $input->scopes; } - if ($input->hasProperty('name')) { + if ($input->has('name')) { $updates->name = $input->name; } diff --git a/backend/src/Api/Console/Controller/ApprovalController.php b/backend/src/Api/Console/Controller/ApprovalController.php index df801584..dab13e07 100644 --- a/backend/src/Api/Console/Controller/ApprovalController.php +++ b/backend/src/Api/Console/Controller/ApprovalController.php @@ -102,39 +102,39 @@ public function updateApproval( $updates = new UpdateApprovalDto(); - if ($input->hasProperty('company_name')) { + if ($input->has('company_name')) { $updates->companyName = $input->company_name; } - if ($input->hasProperty('country')) { + if ($input->has('country')) { $updates->country = $input->country; } - if ($input->hasProperty('website')) { + if ($input->has('website')) { $updates->website = $input->website; } - if ($input->hasProperty('social_links')) { + if ($input->has('social_links')) { $updates->socialLinks = $input->social_links; } - if ($input->hasProperty('type_of_content')) { + if ($input->has('type_of_content')) { $updates->typeOfContent = $input->type_of_content; } - if ($input->hasProperty('frequency')) { + if ($input->has('frequency')) { $updates->frequency = $input->frequency; } - if ($input->hasProperty('existing_list')) { + if ($input->has('existing_list')) { $updates->existingList = $input->existing_list; } - if ($input->hasProperty('sample')) { + if ($input->has('sample')) { $updates->sample = $input->sample; } - if ($input->hasProperty('why_post')) { + if ($input->has('why_post')) { $updates->whyPost = $input->why_post; } diff --git a/backend/src/Api/Console/Controller/IssueController.php b/backend/src/Api/Console/Controller/IssueController.php index f3c7c32d..958db24c 100644 --- a/backend/src/Api/Console/Controller/IssueController.php +++ b/backend/src/Api/Console/Controller/IssueController.php @@ -96,15 +96,15 @@ public function updateIssue( { $updates = new UpdateIssueDto(); - if ($input->hasProperty('subject')) { + if ($input->has('subject')) { $updates->subject = $input->subject; } - if ($input->hasProperty('content')) { + if ($input->has('content')) { $updates->content = $input->content; } - if ($input->hasProperty('sending_profile_id')) { + if ($input->has('sending_profile_id')) { $sendingProfile = $this->sendingProfileService->getSendingProfileOfNewsletterById( $newsletter, $input->sending_profile_id @@ -117,7 +117,7 @@ public function updateIssue( $updates->sendingProfile = $sendingProfile; } - if ($input->hasProperty('lists')) { + if ($input->has('lists')) { $missingListIds = $this->newsletterListService->getMissingListIdsOfNewsletter($newsletter, $input->lists); if ($missingListIds !== null) { diff --git a/backend/src/Api/Console/Controller/NewsletterController.php b/backend/src/Api/Console/Controller/NewsletterController.php index bdb08449..d16eec6e 100644 --- a/backend/src/Api/Console/Controller/NewsletterController.php +++ b/backend/src/Api/Console/Controller/NewsletterController.php @@ -93,19 +93,19 @@ public function updateNewsletter( ): JsonResponse { $updates = new UpdateNewsletterDto(); - if ($input->hasProperty('name')) { + if ($input->has('name')) { $updates->name = $input->name; } - if ($input->hasProperty('subdomain')) { + if ($input->has('subdomain')) { if ($this->newsletterService->isSubdomainTaken($input->subdomain)) { throw new UnprocessableEntityHttpException('Subdomain is already taken.'); } $updates->subdomain = $input->subdomain; } - if ($input->hasProperty('language_code')) { + if ($input->has('language_code')) { $updates->language_code = $input->language_code; } - if ($input->hasProperty('is_rtl')) { + if ($input->has('is_rtl')) { $updates->is_rtl = $input->is_rtl; } $newsletter = $this->newsletterService->updateNewsletter($newsletter, $updates); diff --git a/backend/src/Api/Console/Controller/SendingProfileController.php b/backend/src/Api/Console/Controller/SendingProfileController.php index 793d5485..4fd7cfd5 100644 --- a/backend/src/Api/Console/Controller/SendingProfileController.php +++ b/backend/src/Api/Console/Controller/SendingProfileController.php @@ -83,33 +83,33 @@ public function updateSendingProfile( { $updates = new UpdateSendingProfileDto(); - if ($input->hasProperty('from_email')) { + if ($input->has('from_email')) { $domain = $this->getDomainFromEmail($input->from_email); $updates->customDomain = $domain; $updates->fromEmail = $input->from_email; } - if ($input->hasProperty('from_name')) { + if ($input->has('from_name')) { $updates->fromName = $input->from_name; } - if ($input->hasProperty('reply_to_email')) { + if ($input->has('reply_to_email')) { $updates->replyToEmail = $input->reply_to_email; } - if ($input->hasProperty('brand_name')) { + if ($input->has('brand_name')) { $updates->brandName = $input->brand_name; } - if ($input->hasProperty('brand_logo')) { + if ($input->has('brand_logo')) { $updates->brandLogo = $input->brand_logo; } - if ($input->hasProperty('brand_url')) { + if ($input->has('brand_url')) { $updates->brandUrl = $input->brand_url; } - if ($input->hasProperty('is_default')) { + if ($input->has('is_default')) { $updates->isDefault = $input->is_default; } diff --git a/backend/src/Api/Console/Controller/SubscriberController.php b/backend/src/Api/Console/Controller/SubscriberController.php index 12edad36..0d0e13df 100644 --- a/backend/src/Api/Console/Controller/SubscriberController.php +++ b/backend/src/Api/Console/Controller/SubscriberController.php @@ -6,15 +6,20 @@ use App\Api\Console\Authorization\ScopeRequired; use App\Api\Console\Input\Subscriber\BulkActionSubscriberInput; use App\Api\Console\Input\Subscriber\CreateSubscriberInput; -use App\Api\Console\Input\Subscriber\UpdateSubscriberInput; +use App\Api\Console\Input\Subscriber\ListsStrategy; +use App\Api\Console\Input\Subscriber\MetadataStrategy; use App\Api\Console\Object\SubscriberObject; use App\Entity\Newsletter; +use App\Entity\NewsletterList; use App\Entity\Subscriber; +use App\Entity\Type\ListRemovalReason; use App\Entity\Type\SubscriberSource; use App\Entity\Type\SubscriberStatus; use App\Service\NewsletterList\NewsletterListService; use App\Service\Subscriber\Dto\UpdateSubscriberDto; +use App\Service\Subscriber\ListRemoval\ListRemovalService; use App\Service\Subscriber\SubscriberService; +use App\Service\SubscriberMetadata\Exception\MetadataValidationFailedException; use App\Service\SubscriberMetadata\SubscriberMetadataService; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; @@ -28,12 +33,11 @@ class SubscriberController extends AbstractController { public function __construct( - private SubscriberService $subscriberService, - private NewsletterListService $newsletterListService, - private SubscriberMetadataService $subscriberMetadataService - ) - { - } + private SubscriberService $subscriberService, + private NewsletterListService $newsletterListService, + private SubscriberMetadataService $subscriberMetadataService, + private ListRemovalService $listRemovalService, + ) {} #[Route('/subscribers', methods: 'GET')] #[ScopeRequired(Scope::SUBSCRIBERS_READ)] @@ -65,7 +69,7 @@ public function getSubscribers(Request $request, Newsletter $newsletter): JsonRe $listId, $search, $limit, - $offset + $offset, ) ->map(fn($subscriber) => new SubscriberObject($subscriber)); @@ -76,91 +80,185 @@ public function getSubscribers(Request $request, Newsletter $newsletter): JsonRe #[ScopeRequired(Scope::SUBSCRIBERS_WRITE)] public function createSubscriber( #[MapRequestPayload] CreateSubscriberInput $input, - Newsletter $newsletter - ): JsonResponse - { - $missingListIds = $this - ->newsletterListService - ->getMissingListIdsOfNewsletter($newsletter, $input->list_ids); + Newsletter $newsletter, + ): JsonResponse { + $resolvedLists = $input->lists ? $this->resolveLists($newsletter, $input->lists) : []; + $subscriber = $this->subscriberService->getSubscriberByEmail($newsletter, $input->email); - if ($missingListIds !== null) { - throw new UnprocessableEntityHttpException("List with id {$missingListIds[0]} not found"); + if ($input->metadata) { + try { + $this->subscriberMetadataService->validateMetadata( + $newsletter, + $input->metadata, + ); + } catch (MetadataValidationFailedException $e) { + throw new UnprocessableEntityHttpException($e->getMessage()); + } } - $subscriberDB = $this->subscriberService->getSubscriberByEmail($newsletter, $input->email); - if ($subscriberDB !== null) { - throw new UnprocessableEntityHttpException("Subscriber with email {$input->email} already exists"); - } + if ($subscriber === null) { + $subscriber = $this->subscriberService->createSubscriber( + $newsletter, + $input->email, + $resolvedLists, + status: $input->status ?? SubscriberStatus::SUBSCRIBED, + source: $input->source ?? SubscriberSource::CONSOLE, + subscribeIp: $input->getSubscribeIp(), + subscribedAt: $input->getSubscribedAt(), + metadata: $input->metadata ?? [], + sendConfirmationEmail: $input->send_pending_confirmation_email, + ); + } else { + $updates = new UpdateSubscriberDto(); - $lists = $this->newsletterListService->getListsByIds($input->list_ids); + if ($input->status) { + $updates->status = $input->status; + } - $subscriber = $this->subscriberService->createSubscriber( - $newsletter, - $input->email, - $lists, - SubscriberStatus::PENDING, - $input->source ?? SubscriberSource::CONSOLE, - $input->subscribe_ip, - $input->subscribed_at ? \DateTimeImmutable::createFromTimestamp($input->subscribed_at) : null, - $input->unsubscribed_at ? \DateTimeImmutable::createFromTimestamp($input->unsubscribed_at) : null, - ); + if ($input->source) { + $updates->source = $input->source; + } - return $this->json(new SubscriberObject($subscriber)); - } + if ($input->has('subscribe_ip')) { + $updates->subscribeIp = $input->subscribe_ip; + } - #[Route('/subscribers/{id}', methods: 'PATCH')] - #[ScopeRequired(Scope::SUBSCRIBERS_WRITE)] - public function updateSubscriber( - Subscriber $subscriber, - Newsletter $newsletter, - #[MapRequestPayload] UpdateSubscriberInput $input - ): JsonResponse - { - $updates = new UpdateSubscriberDto(); + if ($input->has('subscribed_at')) { + $updates->subscribedAt = $input->subscribed_at !== null + ? \DateTimeImmutable::createFromTimestamp($input->subscribed_at) + : null; + } - if ($input->hasProperty('email')) { - $subscriberDB = $this->subscriberService->getSubscriberByEmail($newsletter, $input->email); - if ($subscriberDB !== null) { - throw new UnprocessableEntityHttpException("Subscriber with email {$input->email} already exists"); + if ($input->metadata) { + if ($input->metadata_strategy === MetadataStrategy::MERGE) { + $updates->metadata = array_merge( + $subscriber->getMetadata(), + $input->metadata, + ); + } else { + $updates->metadata = $input->metadata; + } } - $updates->email = $input->email; - } + if ($input->lists !== null) { + $newLists = $subscriber->getLists()->toArray(); + + if ($input->lists_strategy === ListsStrategy::MERGE) { + foreach ($resolvedLists as $list) { + if (!array_find($newLists, fn($l) => $l->getId() === $list->getId())) { + $newLists[] = $list; + } + } + } elseif ($input->lists_strategy === ListsStrategy::OVERWRITE) { + $newLists = $resolvedLists; + } else { + // remove + $newLists = array_filter( + $newLists, + fn($l) => !array_find($resolvedLists, fn($rl) => $rl->getId() === $l->getId()), + ); + } - if ($input->hasProperty('list_ids')) { - $missingListIds = $this->newsletterListService->getMissingListIdsOfNewsletter( - $newsletter, - $input->list_ids + $newLists = $this->skipLists( + $subscriber, + $newLists, + $input->getListSkipResubscribeOn(), + ); + + $updates->lists = $newLists; + } + + $subscriber = $this->subscriberService->updateSubscriber( + $subscriber, + $updates, + listRemovalReason: $input->list_removal_reason, + sendConfirmationEmail: $input->send_pending_confirmation_email, ); + } + + return $this->json(new SubscriberObject($subscriber)); + } - if ($missingListIds !== null) { - throw new UnprocessableEntityHttpException("List with id {$missingListIds[0]} not found"); + /** + * @param (string|int)[] $listIdsOrNames + * @return NewsletterList[] + */ + private function resolveLists(Newsletter $newsletter, array $listIdsOrNames): array + { + $listIds = []; + $listNames = []; + + foreach ($listIdsOrNames as $listIdOrName) { + if (is_int($listIdOrName)) { + $listIds[] = $listIdOrName; + } elseif (is_string($listIdOrName)) { + $listNames[] = $listIdOrName; } + } + + $resolvedLists = []; - $updates->lists = $this->newsletterListService->getListsByIds($input->list_ids); + if (count($listIds) > 0) { + $resolvedLists = $this->newsletterListService->getListsByIds($newsletter, $listIds); + + if (count($resolvedLists) !== count($listIds)) { + $resolvedListIds = array_map(fn($l) => $l->getId(), $resolvedLists); + $missingIds = array_diff($listIds, $resolvedListIds); + throw new UnprocessableEntityHttpException( + "Lists with IDs " . implode(', ', $missingIds) . " not found", + ); + } } - if ($input->hasProperty('status')) { - if ($input->status === SubscriberStatus::SUBSCRIBED && $subscriber->getOptInAt() === null) { - throw new UnprocessableEntityHttpException('Subscribers without opt-in can not be updated to SUBSCRIBED status.'); + if (count($listNames) > 0) { + $listsByName = $this->newsletterListService->getListsByNames($newsletter, $listNames); + + foreach ($listsByName as $list) { + if (!in_array($list, $resolvedLists)) { + $resolvedLists[] = $list; + } } - $updates->status = $input->status; + if (count($listsByName) !== count($listNames)) { + $resolvedListNames = array_map(fn($l) => $l->getName(), $listsByName); + $missingNames = array_diff($listNames, $resolvedListNames); + throw new UnprocessableEntityHttpException( + "Lists with names " . implode(', ', $missingNames) . " not found", + ); + } } - $metadataDefinitions = $this->subscriberMetadataService->getMetadataDefinitions($newsletter); + return $resolvedLists; + } - if ($input->hasProperty('metadata')) { - try { - $this->subscriberMetadataService->validateMetadata($newsletter, $input->metadata); - } catch (\Exception $e) { - throw new UnprocessableEntityHttpException($e->getMessage()); + /** + * @param Subscriber $subscriber + * @param NewsletterList[] $lists + * @param ListRemovalReason[] $reasonsToSkip + * @return NewsletterList[] + */ + private function skipLists(Subscriber $subscriber, array $lists, array $reasonsToSkip): array + { + $newlyAddedLists = []; + + foreach ($lists as $list) { + if (!$subscriber->getLists()->contains($list)) { + $newlyAddedLists[] = $list; } - $updates->metadata = $input->metadata; } - $subscriber = $this->subscriberService->updateSubscriber($subscriber, $updates); - return $this->json(new SubscriberObject($subscriber)); + if (count($newlyAddedLists) === 0) { + return $lists; + } + + $newlyAddedListIds = array_map(fn($l) => $l->getId(), $newlyAddedLists); + + $removals = $this->listRemovalService->getRemovals($subscriber, $newlyAddedListIds, $reasonsToSkip); + + return array_filter( + $lists, + fn($list) => !array_find($removals, fn($r) => $r->getList()->getId() === $list->getId()), + ); } #[Route('/subscribers/{id}', methods: 'DELETE')] @@ -173,8 +271,10 @@ public function deleteSubscriber(Subscriber $subscriber): JsonResponse #[Route('/subscribers/bulk', methods: 'POST')] #[ScopeRequired(Scope::SUBSCRIBERS_WRITE)] - public function bulkActions(Newsletter $newsletter, #[MapRequestPayload] BulkActionSubscriberInput $input): JsonResponse - { + public function bulkActions( + Newsletter $newsletter, + #[MapRequestPayload] BulkActionSubscriberInput $input, + ): JsonResponse { if (count($input->subscribers_ids) >= $this->subscriberService::BULK_SUBSCRIBER_LIMIT) { throw new UnprocessableEntityHttpException("Subscribers limit exceeded"); } @@ -186,7 +286,9 @@ public function bulkActions(Newsletter $newsletter, #[MapRequestPayload] BulkAct $subscriber = array_find($currentSubscribers, fn($s) => $s->getId() === $subscriberId); if ($subscriber === null) { - throw new UnprocessableEntityHttpException("Subscriber with ID {$subscriberId} not found in the newsletter"); + throw new UnprocessableEntityHttpException( + "Subscriber with ID {$subscriberId} not found in the newsletter", + ); } $subscribers[] = $subscriber; @@ -197,13 +299,14 @@ public function bulkActions(Newsletter $newsletter, #[MapRequestPayload] BulkAct return $this->json([ 'status' => 'success', 'message' => 'Subscribers deleted successfully', - 'subscribers' => [] + 'subscribers' => [], ]); } if ($input->action == 'status_change') { - if ($input->status == null) + if ($input->status == null) { throw new UnprocessableEntityHttpException("Status must be provided for status change action"); + } $status = SubscriberStatus::tryFrom($input->status); if (!$status) { @@ -225,13 +328,14 @@ public function bulkActions(Newsletter $newsletter, #[MapRequestPayload] BulkAct return $this->json([ 'status' => 'success', 'message' => 'Subscribers status updated successfully', - 'subscribers' => array_map(fn($s) => new SubscriberObject($s), $subscribers) + 'subscribers' => array_map(fn($s) => new SubscriberObject($s), $subscribers), ]); } if ($input->action == 'metadata_update') { - if ($input->metadata == null) + if ($input->metadata == null) { throw new UnprocessableEntityHttpException("Metadata must be provided for metadata update action"); + } foreach ($subscribers as $subscriber) { $updates = new UpdateSubscriberDto(); @@ -249,7 +353,7 @@ public function bulkActions(Newsletter $newsletter, #[MapRequestPayload] BulkAct return $this->json([ 'status' => 'success', 'message' => 'Subscribers metadata updated successfully', - 'subscribers' => array_map(fn($s) => new SubscriberObject($s), $subscribers) + 'subscribers' => array_map(fn($s) => new SubscriberObject($s), $subscribers), ]); } diff --git a/backend/src/Api/Console/Input/Subscriber/CreateSubscriberInput.php b/backend/src/Api/Console/Input/Subscriber/CreateSubscriberInput.php index ee2139e6..b6678eef 100644 --- a/backend/src/Api/Console/Input/Subscriber/CreateSubscriberInput.php +++ b/backend/src/Api/Console/Input/Subscriber/CreateSubscriberInput.php @@ -2,33 +2,87 @@ namespace App\Api\Console\Input\Subscriber; +use App\Entity\Type\ListRemovalReason; use App\Entity\Type\SubscriberSource; +use App\Entity\Type\SubscriberStatus; +use App\Util\OptionalPropertyTrait; +use Symfony\Component\Clock\ClockAwareTrait; use Symfony\Component\Validator\Constraints as Assert; class CreateSubscriberInput { + use OptionalPropertyTrait; + use ClockAwareTrait; + #[Assert\NotBlank] #[Assert\Email] #[Assert\Length(max: 255)] public string $email; /** - * @var int[] $list_ids + * @var ?(int|string)[] */ - #[Assert\NotBlank] - #[Assert\All([ - new Assert\NotBlank(), - new Assert\Type('int'), - ])] - public array $list_ids; + public ?array $lists = null; + + public ?SubscriberStatus $status = null; public ?SubscriberSource $source = null; #[Assert\Ip(version: Assert\Ip::ALL_ONLY_PUBLIC)] - public ?string $subscribe_ip = null; + public ?string $subscribe_ip; + + public ?int $subscribed_at; + + /** + * @var array|null + */ + #[Assert\All(new Assert\Type('scalar'))] + public ?array $metadata = null; + + // settings + + public ListsStrategy $lists_strategy = ListsStrategy::MERGE; + + /** + * @var string[] + */ + #[Assert\All(new Assert\Choice(callback: 'getListResubscribeOnValues'))] + public array $list_skip_resubscribe_on = ['unsubscribe', 'bounce', 'complaint']; + + public ListRemovalReason $list_removal_reason = ListRemovalReason::UNSUBSCRIBE; + + public MetadataStrategy $metadata_strategy = MetadataStrategy::MERGE; + + public bool $send_pending_confirmation_email = false; + + public function getSubscribeIp(): ?string + { + return $this->has('subscribe_ip') ? $this->subscribe_ip : null; + } + + public function getSubscribedAt(): ?\DateTimeImmutable + { + if (!$this->has('subscribed_at')) { + return null; + } + return $this->subscribed_at ? new \DateTimeImmutable()->setTimestamp($this->subscribed_at) : null; + } - public ?int $subscribed_at = null; + /** + * @return ListRemovalReason[] + */ + public function getListSkipResubscribeOn(): array + { + return array_map(fn($item) => ListRemovalReason::from($item), $this->list_skip_resubscribe_on); + } + + /** + * @return string[] + */ + public function getListResubscribeOnValues(): array + { + return array_map(fn($value) => $value->value, ListRemovalReason::cases()); + } - public ?int $unsubscribed_at = null; } diff --git a/backend/src/Api/Console/Input/Subscriber/ListsStrategy.php b/backend/src/Api/Console/Input/Subscriber/ListsStrategy.php new file mode 100644 index 00000000..fdb239f7 --- /dev/null +++ b/backend/src/Api/Console/Input/Subscriber/ListsStrategy.php @@ -0,0 +1,12 @@ + - */ - public array $metadata; -} diff --git a/backend/src/Api/Console/Object/SubscriberObject.php b/backend/src/Api/Console/Object/SubscriberObject.php index daba7352..14b17a9a 100644 --- a/backend/src/Api/Console/Object/SubscriberObject.php +++ b/backend/src/Api/Console/Object/SubscriberObject.php @@ -20,10 +20,9 @@ class SubscriberObject public ?string $subscribe_ip; public bool $is_opted_in = false; public ?int $subscribed_at; - public ?int $unsubscribed_at; /** - * @var array + * @var array */ public array $metadata; @@ -37,7 +36,6 @@ public function __construct(Subscriber $subscriber) $this->subscribe_ip = $subscriber->getSubscribeIp(); $this->is_opted_in = $subscriber->getOptInAt() !== null; $this->subscribed_at = $subscriber->getSubscribedAt()?->getTimestamp(); - $this->unsubscribed_at = $subscriber->getUnsubscribedAt()?->getTimestamp(); $this->metadata = $subscriber->getMetadata(); } diff --git a/backend/src/Api/Public/Controller/Form/FormController.php b/backend/src/Api/Public/Controller/Form/FormController.php index d0e1beee..8350d8b7 100644 --- a/backend/src/Api/Public/Controller/Form/FormController.php +++ b/backend/src/Api/Public/Controller/Form/FormController.php @@ -5,7 +5,6 @@ namespace App\Api\Public\Controller\Form; use App\Api\Public\Input\Form\FormInitInput; -use App\Api\Public\Input\Form\FormRenderInput; use App\Api\Public\Input\Form\FormSubscribeInput; use App\Api\Public\Object\Form\FormListObject; use App\Api\Public\Object\Form\FormSubscriberObject; @@ -32,13 +31,11 @@ class FormController extends AbstractController use ClockAwareTrait; public function __construct( - private NewsletterService $newsletterService, + private NewsletterService $newsletterService, private NewsletterListService $newsletterListService, - private SubscriberService $subscriberService, - private AppConfig $appConfig, - ) - { - } + private SubscriberService $subscriberService, + private AppConfig $appConfig, + ) {} #[Route('/form/init', methods: 'POST')] public function init(#[MapRequestPayload] FormInitInput $input): JsonResponse @@ -54,13 +51,13 @@ public function init(#[MapRequestPayload] FormInitInput $input): JsonResponse if ($listIds !== null) { $missingListIds = $this->newsletterListService->getMissingListIdsOfNewsletter( $newsletter, - $listIds + $listIds, ); if ($missingListIds !== null) { throw new UnprocessableEntityHttpException("List with id {$missingListIds[0]} not found"); } - $lists = $this->newsletterListService->getListsByIds($listIds); + $lists = $this->newsletterListService->getListsByIds($newsletter, $listIds); } else { $lists = $this->newsletterListService->getListsOfNewsletter($newsletter); } @@ -68,16 +65,15 @@ public function init(#[MapRequestPayload] FormInitInput $input): JsonResponse return new JsonResponse([ 'newsletter' => new FormNewsletterObject($newsletter), 'is_subscribed' => false, - 'lists' => $lists->map(fn($list) => new FormListObject($list))->toArray(), + 'lists' => array_map(fn($list) => new FormListObject($list), $lists), ]); } #[Route('/form/subscribe', methods: 'POST')] public function subscribe( #[MapRequestPayload] FormSubscribeInput $input, - Request $request, - ): JsonResponse - { + Request $request, + ): JsonResponse { $ip = $request->getClientIp(); $newsletter = $this->newsletterService->getNewsletterBySubdomain($input->newsletter_subdomain); @@ -88,26 +84,36 @@ public function subscribe( $listIds = $input->list_ids; $missingListIds = $this->newsletterListService->getMissingListIdsOfNewsletter( $newsletter, - $listIds + $listIds, ); if ($missingListIds !== null) { throw new UnprocessableEntityHttpException("List with id {$missingListIds[0]} not found"); } - $lists = $this->newsletterListService->getListsByIds($listIds); + $lists = $this->newsletterListService->getListsByIds($newsletter, $listIds); $email = $input->email; $subscriber = $this->subscriberService->getSubscriberByEmail($newsletter, $email); if ($subscriber) { $update = new UpdateSubscriberDto(); - $update->status = $subscriber->getOptInAt() !== null ? SubscriberStatus::SUBSCRIBED : SubscriberStatus::PENDING; + + // if the user is already subscribed, we do not want to change the status + if ($subscriber->getStatus() !== SubscriberStatus::SUBSCRIBED) { + // if the user has previously opted-in + // we can directly set the status to subscribed + $update->status = + $subscriber->getOptInAt() !== null ? + SubscriberStatus::SUBSCRIBED : + SubscriberStatus::PENDING; + } + $update->lists = $lists; $this->subscriberService->updateSubscriber( $subscriber, - $update + $update, ); } else { $subscriber = $this->subscriberService->createSubscriber( @@ -116,7 +122,7 @@ public function subscribe( $lists, SubscriberStatus::PENDING, SubscriberSource::FORM, - $ip + $ip, ); } @@ -139,7 +145,7 @@ public function renderForm(Request $request): Response getSubdomain()} instance={$instance}> - HTML; + HTML; return new Response($response); } diff --git a/backend/src/Api/Public/Controller/Subscriber/SubscriberController.php b/backend/src/Api/Public/Controller/Subscriber/SubscriberController.php index df6ee6d3..c558f590 100644 --- a/backend/src/Api/Public/Controller/Subscriber/SubscriberController.php +++ b/backend/src/Api/Public/Controller/Subscriber/SubscriberController.php @@ -26,13 +26,11 @@ class SubscriberController extends AbstractController use ClockAwareTrait; public function __construct( - private SubscriberService $subscriberService, - private SendService $sendService, + private SubscriberService $subscriberService, + private SendService $sendService, private NewsletterListService $newsletterListService, - private Encryption $encryption, - ) - { - } + private Encryption $encryption, + ) {} #[Route('/subscriber/confirm', methods: ['GET'])] public function confirm(Request $request): JsonResponse @@ -57,7 +55,7 @@ public function confirm(Request $request): JsonResponse assert(is_string($data['expires_at'])); if (new \DateTimeImmutable($data['expires_at'])->getTimestamp() < $this->now()->getTimestamp()) { throw new BadRequestHttpException( - 'The confirmation link has expired. Please request a new confirmation link.' + 'The confirmation link has expired. Please request a new confirmation link.', ); } @@ -73,9 +71,8 @@ public function confirm(Request $request): JsonResponse #[Route('/subscriber/unsubscribe', methods: ['POST'])] public function unsubscribe( - #[MapRequestPayload] UnsubscribeInput $input - ): JsonResponse - { + #[MapRequestPayload] UnsubscribeInput $input, + ): JsonResponse { try { $sendId = $this->encryption->decrypt($input->token); } catch (DecryptException) { @@ -97,15 +94,14 @@ public function unsubscribe( $lists = $this->newsletterListService->getListsOfNewsletter($send->getNewsletter()); return new JsonResponse([ - 'lists' => $lists->map(fn($list) => new FormListObject($list))->toArray(), + 'lists' => array_map(fn($list) => new FormListObject($list), $lists), ]); } #[Route('/subscriber/resubscribe', methods: ['PATCH'])] public function resubscribe( #[MapRequestPayload] ResubscribeInput $input, - ): JsonResponse - { + ): JsonResponse { try { $sendId = $this->encryption->decrypt($input->token); } catch (DecryptException) { @@ -128,7 +124,7 @@ public function resubscribe( $missingListIds = $this->newsletterListService->getMissingListIdsOfNewsletter( $send->getNewsletter(), - $input->list_ids + $input->list_ids, ); if ($missingListIds !== null) { @@ -139,7 +135,7 @@ public function resubscribe( throw new UnprocessableEntityHttpException('At least one list must be provided.'); } - $lists = $this->newsletterListService->getListsByIds($input->list_ids); + $lists = $this->newsletterListService->getListsByIds($send->getNewsletter(), $input->list_ids); $updates->lists = $lists; $this->subscriberService->updateSubscriber($subscriber, $updates); diff --git a/backend/src/Api/Public/Object/Form/FormSubscriberObject.php b/backend/src/Api/Public/Object/Form/FormSubscriberObject.php index 78e38acc..6fae52e1 100644 --- a/backend/src/Api/Public/Object/Form/FormSubscriberObject.php +++ b/backend/src/Api/Public/Object/Form/FormSubscriberObject.php @@ -15,7 +15,6 @@ class FormSubscriberObject public string $email; public SubscriberStatus $status; public ?int $subscribed_at; - public ?int $unsubscribed_at; public function __construct(Subscriber $subscriber) { @@ -24,7 +23,6 @@ public function __construct(Subscriber $subscriber) $this->email = $subscriber->getEmail(); $this->status = $subscriber->getStatus(); $this->subscribed_at = $subscriber->getSubscribedAt()?->getTimestamp(); - $this->unsubscribed_at = $subscriber->getUnsubscribedAt()?->getTimestamp(); } -} \ No newline at end of file +} diff --git a/backend/src/Command/Dev/DevSeedCommand.php b/backend/src/Command/Dev/DevSeedCommand.php index 52a1ed4b..ff86be9a 100644 --- a/backend/src/Command/Dev/DevSeedCommand.php +++ b/backend/src/Command/Dev/DevSeedCommand.php @@ -28,7 +28,7 @@ * @codeCoverageIgnore */ #[AsCommand( - name: 'app:dev:seed', + name: 'dev:seed', description: 'Seeds the database with test data for development purposes.' )] class DevSeedCommand extends Command diff --git a/backend/src/Entity/Subscriber.php b/backend/src/Entity/Subscriber.php index 9b687d25..58278234 100644 --- a/backend/src/Entity/Subscriber.php +++ b/backend/src/Entity/Subscriber.php @@ -48,9 +48,6 @@ class Subscriber #[ORM\Column(nullable: true)] private ?\DateTimeImmutable $opt_in_at = null; - #[ORM\Column(nullable: true)] - private ?\DateTimeImmutable $unsubscribed_at = null; - #[ORM\Column(enumType: SubscriberSource::class)] private SubscriberSource $source; @@ -64,7 +61,7 @@ class Subscriber private ?string $unsubscribe_reason = null; /** - * @var array + * @var array */ #[ORM\Column(type: 'json', options: ['default' => '{}'])] private array $metadata = []; @@ -170,18 +167,6 @@ public function setOptInAt(?\DateTimeImmutable $opt_in_at): static return $this; } - public function getUnsubscribedAt(): ?\DateTimeImmutable - { - return $this->unsubscribed_at; - } - - public function setUnsubscribedAt(?\DateTimeImmutable $unsubscribed_at): static - { - $this->unsubscribed_at = $unsubscribed_at; - - return $this; - } - public function getSource(): SubscriberSource { return $this->source; @@ -239,6 +224,15 @@ public function getLists(): Collection return $this->lists; } + /** + * @param Collection $lists + */ + public function setLists(Collection $lists): static + { + $this->lists = $lists; + return $this; + } + public function addList(NewsletterList $list): self { if (!$this->lists->contains($list)) { @@ -254,7 +248,7 @@ public function removeList(NewsletterList $list): self } /** - * @return array + * @return array */ public function getMetadata(): array { @@ -262,7 +256,7 @@ public function getMetadata(): array } /** - * @param array $metadata + * @param array $metadata */ public function setMetadata(array $metadata): static { diff --git a/backend/src/Entity/SubscriberListRemoval.php b/backend/src/Entity/SubscriberListRemoval.php new file mode 100644 index 00000000..e00f87ae --- /dev/null +++ b/backend/src/Entity/SubscriberListRemoval.php @@ -0,0 +1,85 @@ +id; + } + + public function setId(int $id): void + { + $this->id = $id; + } + + public function getList(): NewsletterList + { + return $this->list; + } + + public function setList(NewsletterList $list): static + { + $this->list = $list; + return $this; + } + + public function getSubscriber(): Subscriber + { + return $this->subscriber; + } + + public function setSubscriber(Subscriber $subscriber): static + { + $this->subscriber = $subscriber; + return $this; + } + + public function getReason(): ListRemovalReason + { + return $this->reason; + } + + public function setReason(ListRemovalReason $reason): static + { + $this->reason = $reason; + return $this; + } + + public function getCreatedAt(): \DateTimeImmutable + { + return $this->created_at; + } + + public function setCreatedAt(\DateTimeImmutable $created_at): static + { + $this->created_at = $created_at; + return $this; + } +} diff --git a/backend/src/Entity/Type/ListRemovalReason.php b/backend/src/Entity/Type/ListRemovalReason.php new file mode 100644 index 00000000..e4356887 --- /dev/null +++ b/backend/src/Entity/Type/ListRemovalReason.php @@ -0,0 +1,11 @@ + 'string', + }; + } -} \ No newline at end of file +} diff --git a/backend/src/Entity/Type/SubscriberStatus.php b/backend/src/Entity/Type/SubscriberStatus.php index d1237c65..8f5394e9 100644 --- a/backend/src/Entity/Type/SubscriberStatus.php +++ b/backend/src/Entity/Type/SubscriberStatus.php @@ -5,6 +5,5 @@ enum SubscriberStatus: string { case SUBSCRIBED = 'subscribed'; - case UNSUBSCRIBED = 'unsubscribed'; case PENDING = 'pending'; } diff --git a/backend/src/Service/ApiKey/ApiKeyService.php b/backend/src/Service/ApiKey/ApiKeyService.php index 30b07a57..2fbebc71 100644 --- a/backend/src/Service/ApiKey/ApiKeyService.php +++ b/backend/src/Service/ApiKey/ApiKeyService.php @@ -65,19 +65,19 @@ public function regenerateApiKey(ApiKey $apiKey): array public function updateApiKey(ApiKey $apiKey, UpdateApiKeyDto $updates): ApiKey { - if ($updates->hasProperty('enabled')) { + if ($updates->has('enabled')) { $apiKey->setIsEnabled($updates->enabled); } - if ($updates->hasProperty('scopes')) { + if ($updates->has('scopes')) { $apiKey->setScopes($updates->scopes); } - if ($updates->hasProperty('name')) { + if ($updates->has('name')) { $apiKey->setName($updates->name); } - if ($updates->hasProperty('lastAccessedAt')) { + if ($updates->has('lastAccessedAt')) { $apiKey->setLastAccessedAt($updates->lastAccessedAt); } diff --git a/backend/src/Service/App/Messenger/MessageTransport.php b/backend/src/Service/App/Messenger/MessageTransport.php new file mode 100644 index 00000000..506578ee --- /dev/null +++ b/backend/src/Service/App/Messenger/MessageTransport.php @@ -0,0 +1,8 @@ +hasProperty('companyName')) { + if ($updates->has('companyName')) { $approval->setCompanyName($updates->companyName); } - if ($updates->hasProperty('country')) { + if ($updates->has('country')) { $approval->setCountry($updates->country); } - if ($updates->hasProperty('website')) { + if ($updates->has('website')) { $approval->setWebsite($updates->website); } - if ($updates->hasProperty('socialLinks')) { + if ($updates->has('socialLinks')) { $approval->setSocialLinks($updates->socialLinks); } - if ($updates->hasProperty('status')) { + if ($updates->has('status')) { $approval->setStatus($updates->status); } $otherInfo = $approval->getOtherInfo() ?? []; - if ($updates->hasProperty('typeOfContent')) { + if ($updates->has('typeOfContent')) { if ($updates->typeOfContent === null) { unset($otherInfo['type_of_content']); } else { @@ -167,7 +167,7 @@ public function updateApproval( } } - if ($updates->hasProperty('frequency')) { + if ($updates->has('frequency')) { if ($updates->frequency === null) { unset($otherInfo['frequency']); } else { @@ -175,7 +175,7 @@ public function updateApproval( } } - if ($updates->hasProperty('existingList')) { + if ($updates->has('existingList')) { if ($updates->existingList === null) { unset($otherInfo['existing_list']); } else { @@ -183,7 +183,7 @@ public function updateApproval( } } - if ($updates->hasProperty('sample')) { + if ($updates->has('sample')) { if ($updates->sample === null) { unset($otherInfo['sample']); } else { @@ -191,7 +191,7 @@ public function updateApproval( } } - if ($updates->hasProperty('whyPost')) { + if ($updates->has('whyPost')) { if ($updates->whyPost === null) { unset($otherInfo['why_post']); } else { diff --git a/backend/src/Service/Import/ImportService.php b/backend/src/Service/Import/ImportService.php index f002bc13..718f84b3 100644 --- a/backend/src/Service/Import/ImportService.php +++ b/backend/src/Service/Import/ImportService.php @@ -92,13 +92,13 @@ public function createSubscriberImport( public function updateSubscriberImport(SubscriberImport $subscriberImport, UpdateSubscriberImportDto $updates): SubscriberImport { - if ($updates->hasProperty('status')) { + if ($updates->has('status')) { $subscriberImport->setStatus($updates->status); } - if ($updates->hasProperty('fields')) { + if ($updates->has('fields')) { $subscriberImport->setFields($updates->fields); } - if ($updates->hasProperty('errorMessage')) { + if ($updates->has('errorMessage')) { $subscriberImport->setErrorMessage($updates->errorMessage); } $subscriberImport->setUpdatedAt($this->now()); diff --git a/backend/src/Service/Import/MessageHandler/ImportSubscribersMessageHandler.php b/backend/src/Service/Import/MessageHandler/ImportSubscribersMessageHandler.php index 63885a56..fc2c464b 100644 --- a/backend/src/Service/Import/MessageHandler/ImportSubscribersMessageHandler.php +++ b/backend/src/Service/Import/MessageHandler/ImportSubscribersMessageHandler.php @@ -23,13 +23,11 @@ class ImportSubscribersMessageHandler public function __construct( private EntityManagerInterface $em, - private NewsletterListService $newsletterListService, - private ParserFactory $parserFactory, - private ManagerRegistry $registry, - private LoggerInterface $logger, - ) - { - } + private NewsletterListService $newsletterListService, + private ParserFactory $parserFactory, + private ManagerRegistry $registry, + private LoggerInterface $logger, + ) {} public function __invoke(ImportSubscribersMessage $message): void { @@ -62,7 +60,6 @@ public function __invoke(ImportSubscribersMessage $message): void 'exception' => $e, 'importId' => $subscriberImport->getId(), ]); - } else { $subscriberImport->setErrorMessage('An unexpected error occurred.'); $this->logger->error('Unexpected error during import', [ @@ -86,14 +83,14 @@ private function import(SubscriberImport $subscriberImport, CsvParser $parser): $importedCount = 0; foreach ($subscribers as $dto) { - $subscriberLists = []; if (count($dto->lists) === 0) { $subscriberLists = $lists; } else { foreach ($dto->lists as $listName) { - $list = $lists->findFirst(fn($key, $l) => $l->getName() === $listName); + $list = array_find($lists, fn($l) => $l->getName() === $listName); + if ($list === null) { continue; } @@ -104,15 +101,15 @@ private function import(SubscriberImport $subscriberImport, CsvParser $parser): } $query = <<em->getConnection()->fetchOne($query, $params); if ($subscriberId && count($subscriberLists) > 0) { - $placeholders = []; $params = ['subscriber_id' => $subscriberId]; @@ -141,7 +137,7 @@ private function import(SubscriberImport $subscriberImport, CsvParser $parser): $sql = sprintf( 'INSERT INTO list_subscriber (list_id, subscriber_id) VALUES %s', - implode(', ', $placeholders) + implode(', ', $placeholders), ); $this->em->getConnection()->executeStatement($sql, $params); diff --git a/backend/src/Service/Issue/IssueService.php b/backend/src/Service/Issue/IssueService.php index 953ff277..023da144 100644 --- a/backend/src/Service/Issue/IssueService.php +++ b/backend/src/Service/Issue/IssueService.php @@ -22,13 +22,11 @@ class IssueService public function __construct( private EntityManagerInterface $em, - private IssueRepository $issueRepository, - private NewsletterListService $newsletterListService, - private SendingProfileService $sendingProfileService, - private EmailSenderService $emailSenderService, - ) - { - } + private IssueRepository $issueRepository, + private NewsletterListService $newsletterListService, + private SendingProfileService $sendingProfileService, + private EmailSenderService $emailSenderService, + ) {} public function getIssueByUuid(string $uuid): ?Issue { @@ -38,7 +36,7 @@ public function getIssueByUuid(string $uuid): ?Issue public function createIssueDraft(Newsletter $newsletter): Issue { $lists = $this->newsletterListService->getListsOfNewsletter($newsletter); - $listIds = $lists->map(fn(NewsletterList $list) => $list->getId())->toArray(); + $listIds = array_map(fn(NewsletterList $list) => $list->getId(), $lists); $sendingProfile = $this->sendingProfileService->getCurrentDefaultSendingProfileOfNewsletter($newsletter); $issue = new Issue() @@ -58,43 +56,43 @@ public function createIssueDraft(Newsletter $newsletter): Issue public function updateIssue(Issue $issue, UpdateIssueDto $updates): Issue { - if ($updates->hasProperty('subject')) { + if ($updates->has('subject')) { $issue->setSubject($updates->subject); } - if ($updates->hasProperty('content')) { + if ($updates->has('content')) { $issue->setContent($updates->content); } - if ($updates->hasProperty('sendingProfile')) { + if ($updates->has('sendingProfile')) { $issue->setSendingProfile($updates->sendingProfile); } - if ($updates->hasProperty('status')) { + if ($updates->has('status')) { $issue->setStatus($updates->status); } - if ($updates->hasProperty('lists')) { + if ($updates->has('lists')) { $issue->setListids($updates->lists); } - if ($updates->hasProperty('html')) { + if ($updates->has('html')) { $issue->setHtml($updates->html); } - if ($updates->hasProperty('text')) { + if ($updates->has('text')) { $issue->setText($updates->text); } - if ($updates->hasProperty('sendingAt')) { + if ($updates->has('sendingAt')) { $issue->setSendingAt($updates->sendingAt); } - if ($updates->hasProperty('totalSendable')) { + if ($updates->has('totalSendable')) { $issue->setTotalSendable($updates->totalSendable); } - if ($updates->hasProperty('sentAt')) { + if ($updates->has('sentAt')) { $issue->setSentAt($updates->sentAt); } @@ -110,12 +108,11 @@ public function updateIssue(Issue $issue, UpdateIssueDto $updates): Issue * @return ArrayCollection */ public function getIssues( - Newsletter $newsletter, - int $limit, - int $offset, + Newsletter $newsletter, + int $limit, + int $offset, ?IssueStatus $status = null, - ): ArrayCollection - { + ): ArrayCollection { $where = ['newsletter' => $newsletter]; if ($status !== null) { @@ -128,8 +125,8 @@ public function getIssues( $where, ['id' => 'DESC'], $limit, - $offset - ) + $offset, + ), ); } diff --git a/backend/src/Service/Issue/SendService.php b/backend/src/Service/Issue/SendService.php index eec3c36a..17595803 100644 --- a/backend/src/Service/Issue/SendService.php +++ b/backend/src/Service/Issue/SendService.php @@ -174,27 +174,27 @@ public function getIssueProgress(Issue $issue): ?array public function updateSend(Send $send, UpdateSendDto $updates): Send { - if ($updates->hasProperty('status')) { + if ($updates->has('status')) { $send->setStatus($updates->status); } - if ($updates->hasProperty('deliveredAt')) { + if ($updates->has('deliveredAt')) { $send->setDeliveredAt($updates->deliveredAt); } - if ($updates->hasProperty('failedAt')) { + if ($updates->has('failedAt')) { $send->setFailedAt($updates->failedAt); } - if ($updates->hasProperty('bouncedAt')) { + if ($updates->has('bouncedAt')) { $send->setBouncedAt($updates->bouncedAt); } - if ($updates->hasProperty('complainedAt')) { + if ($updates->has('complainedAt')) { $send->setComplainedAt($updates->complainedAt); } - if ($updates->hasProperty('hardBounce')) { + if ($updates->has('hardBounce')) { $send->setHardBounce($updates->hardBounce); } diff --git a/backend/src/Service/Newsletter/NewsletterService.php b/backend/src/Service/Newsletter/NewsletterService.php index a14e5bee..790c913f 100644 --- a/backend/src/Service/Newsletter/NewsletterService.php +++ b/backend/src/Service/Newsletter/NewsletterService.php @@ -266,11 +266,11 @@ public function updateNewsletterMeta(Newsletter $newsletter, UpdateNewsletterMet public function updateNewsletter(Newsletter $newsletter, UpdateNewsletterDto $updates): Newsletter { - if ($updates->hasProperty('name')) { + if ($updates->has('name')) { $newsletter->setName($updates->name); } - if ($updates->hasProperty('subdomain')) { + if ($updates->has('subdomain')) { $newsletter->setSubdomain($updates->subdomain); $systemSendingProfile = $this->sendingProfileService->getSystemSendingProfileOfNewsletter($newsletter); @@ -281,11 +281,11 @@ public function updateNewsletter(Newsletter $newsletter, UpdateNewsletterDto $up ->updateSendingProfile($systemSendingProfile, $sendingProfileUpdates); } - if ($updates->hasProperty('language_code')) { + if ($updates->has('language_code')) { $newsletter->setLanguageCode($updates->language_code); } - if ($updates->hasProperty('is_rtl')) { + if ($updates->has('is_rtl')) { $newsletter->setIsRtl($updates->is_rtl); } diff --git a/backend/src/Service/NewsletterList/NewsletterListService.php b/backend/src/Service/NewsletterList/NewsletterListService.php index a6935eea..8f57d719 100644 --- a/backend/src/Service/NewsletterList/NewsletterListService.php +++ b/backend/src/Service/NewsletterList/NewsletterListService.php @@ -16,15 +16,14 @@ class NewsletterListService public function __construct( private EntityManagerInterface $em, - ) - { - } + ) {} public const int MAX_LIST_DEFINITIONS_PER_NEWSLETTER = 20; public function getListCounter(Newsletter $newsletter): int { - return $this->em->getRepository(NewsletterList::class) + return $this->em + ->getRepository(NewsletterList::class) ->count([ 'newsletter' => $newsletter, ]); @@ -32,10 +31,10 @@ public function getListCounter(Newsletter $newsletter): int public function isNameAvailable( Newsletter $newsletter, - string $name - ): bool - { - return $this->em->getRepository(NewsletterList::class) + string $name, + ): bool { + return $this->em + ->getRepository(NewsletterList::class) ->count([ 'newsletter' => $newsletter, 'name' => $name, @@ -44,10 +43,9 @@ public function isNameAvailable( public function createNewsletterList( Newsletter $newsletter, - string $name, - ?string $description - ): NewsletterList - { + string $name, + ?string $description, + ): NewsletterList { $list = new NewsletterList() ->setNewsletter($newsletter) ->setName($name) @@ -74,19 +72,18 @@ public function getListById(int $id): ?NewsletterList } /** - * @return ArrayCollection + * @return NewsletterList[] */ - public function getListsOfNewsletter(Newsletter $newsletter): ArrayCollection + public function getListsOfNewsletter(Newsletter $newsletter): array { - return new ArrayCollection( - $this->em->getRepository(NewsletterList::class) - ->findBy( - [ - 'newsletter' => $newsletter, - 'deleted_at' => null, - ] - ) - ); + return $this->em + ->getRepository(NewsletterList::class) + ->findBy( + [ + 'newsletter' => $newsletter, + 'deleted_at' => null, + ], + ); } public function updateNewsletterList(NewsletterList $list, string $name, ?string $description): NewsletterList @@ -126,15 +123,45 @@ public function getMissingListIdsOfNewsletter(Newsletter $newsletter, array $lis } /** - * Note that we should validate the lists are within the newsletter (using isListsAvailable) before calling this method - * @param array $listIds - * @return ArrayCollection + * @param int[] $ids + * @return NewsletterList[] + */ + public function getListsByIds(Newsletter $newsletter, array $ids): array + { + $qb = $this->em->createQueryBuilder(); + $qb + ->select('l') + ->from(NewsletterList::class, 'l') + ->where('l.newsletter = :newsletter') + ->andWhere($qb->expr()->in('l.id', ':ids')) + ->setParameter('newsletter', $newsletter) + ->setParameter('ids', $ids); + + /** @var array $result */ + $result = $qb->getQuery()->getResult(); + + return $result; + } + + /** + * @param string[] $names + * @return NewsletterList[] */ - public function getListsByIds(array $listIds): ArrayCollection + public function getListsByNames(Newsletter $newsletter, array $names): array { - return new ArrayCollection( - $this->em->getRepository(NewsletterList::class)->findBy(['id' => $listIds]) - ); + $qb = $this->em->createQueryBuilder(); + $qb + ->select('l') + ->from(NewsletterList::class, 'l') + ->where('l.newsletter = :newsletter') + ->andWhere($qb->expr()->in('l.name', ':names')) + ->setParameter('newsletter', $newsletter) + ->setParameter('names', $names); + + /** @var array $result */ + $result = $qb->getQuery()->getResult(); + + return $result; } /** @@ -145,7 +172,8 @@ public function getSubscriberCountOfLists(array $listIds): array { $qb = $this->em->createQueryBuilder(); - $qb->select('l.id AS list_id, COUNT(s.id) AS subscriber_count') + $qb + ->select('l.id AS list_id, COUNT(s.id) AS subscriber_count') ->from(NewsletterList::class, 'l') ->leftJoin('l.subscribers', 's', 'WITH', 's.status = :subscribed') ->where($qb->expr()->in('l.id', ':listIds')) diff --git a/backend/src/Service/SendingProfile/SendingProfileService.php b/backend/src/Service/SendingProfile/SendingProfileService.php index f670aaf4..dcc4f51d 100644 --- a/backend/src/Service/SendingProfile/SendingProfileService.php +++ b/backend/src/Service/SendingProfile/SendingProfileService.php @@ -86,35 +86,35 @@ public function updateSendingProfile( UpdateSendingProfileDto $updates ): SendingProfile { - if ($updates->hasProperty('fromEmail')) { + if ($updates->has('fromEmail')) { $sendingProfile->setFromEmail($updates->fromEmail); } - if ($updates->hasProperty('fromName')) { + if ($updates->has('fromName')) { $sendingProfile->setFromName($updates->fromName); } - if ($updates->hasProperty('replyToEmail')) { + if ($updates->has('replyToEmail')) { $sendingProfile->setReplyToEmail($updates->replyToEmail); } - if ($updates->hasProperty('brandName')) { + if ($updates->has('brandName')) { $sendingProfile->setBrandName($updates->brandName); } - if ($updates->hasProperty('brandLogo')) { + if ($updates->has('brandLogo')) { $sendingProfile->setBrandLogo($updates->brandLogo); } - if ($updates->hasProperty('brandUrl')) { + if ($updates->has('brandUrl')) { $sendingProfile->setBrandUrl($updates->brandUrl); } - if ($updates->hasProperty('customDomain')) { + if ($updates->has('customDomain')) { $sendingProfile->setDomain($updates->customDomain); } - if ($updates->hasProperty('isDefault')) { + if ($updates->has('isDefault')) { // only true is supported assert($updates->isDefault === true); $sendingProfile->setIsDefault($updates->isDefault); diff --git a/backend/src/Service/Subscriber/ConfirmationMail/ConfirmationMailListener.php b/backend/src/Service/Subscriber/ConfirmationMail/ConfirmationMailListener.php new file mode 100644 index 00000000..65774709 --- /dev/null +++ b/backend/src/Service/Subscriber/ConfirmationMail/ConfirmationMailListener.php @@ -0,0 +1,46 @@ +shouldSendConfirmationEmail() && + $event->getSubscriber()->getStatus() === SubscriberStatus::PENDING + ) { + $this->send($event->getSubscriber()); + } + } + + #[AsEventListener] + public function onSubscriberUpdate(SubscriberUpdatedEvent $event): void + { + if ( + $event->getSubscriberOld()->getStatus() !== SubscriberStatus::PENDING && + $event->getSubscriber()->getStatus() === SubscriberStatus::PENDING && + $event->shouldSendConfirmationEmail() + ) { + $this->send($event->getSubscriber()); + } + } + + private function send(Subscriber $subscriber): void + { + $this->bus->dispatch(new SendConfirmationMailMessage($subscriber->getId())); + } + + +} diff --git a/backend/src/Service/Subscriber/ConfirmationMail/SendConfirmationMailMessage.php b/backend/src/Service/Subscriber/ConfirmationMail/SendConfirmationMailMessage.php new file mode 100644 index 00000000..e71b3f31 --- /dev/null +++ b/backend/src/Service/Subscriber/ConfirmationMail/SendConfirmationMailMessage.php @@ -0,0 +1,19 @@ +subscriberId; + } +} diff --git a/backend/src/Service/Subscriber/ConfirmationMail/SendConfirmationMailMessageHandler.php b/backend/src/Service/Subscriber/ConfirmationMail/SendConfirmationMailMessageHandler.php new file mode 100644 index 00000000..eaeb7d40 --- /dev/null +++ b/backend/src/Service/Subscriber/ConfirmationMail/SendConfirmationMailMessageHandler.php @@ -0,0 +1,91 @@ +em->getRepository(Subscriber::class)->find($message->getSubscriberId()); + + if ($subscriber === null) { + return; + } + + $newsletter = $subscriber->getNewsletter(); + + if ($subscriber->getStatus() !== SubscriberStatus::PENDING) { + // If the subscriber is not pending, we do not send a confirmation email. + return; + } + + $data = [ + 'subscriber_id' => $subscriber->getId(), + 'expires_at' => $this->now()->add(new \DateInterval('P1D'))->format('Y-m-d H:i:s'), + ]; + + $token = $this->encryption->encrypt($data); + $strings = $this->stringsFactory->create(); + + $heading = $strings->get('mail.subscriberConfirmation.heading'); + $variables = $this->templateVariableService->variablesFromNewsletter($newsletter); + + $content = $this->twig->render('newsletter/mail/confirm.json.twig', [ + 'newsletterName' => $newsletter->getName(), + 'buttonUrl' => $this->newsletterService->getArchiveUrl($newsletter) . "/confirm?token=" . $token, + 'buttonText' => $strings->get('mail.subscriberConfirmation.buttonText'), + ]); + + $variables->subject = $heading; + $variables->content = $this->contentService->getHtmlFromJson($content); + + $template = $this->templateService->getTemplateStringFromNewsletter($newsletter); + + $email = new Email(); + $this->sendingProfileService->setSendingProfileToEmail( + $email, + $this->sendingProfileService->getCurrentDefaultSendingProfileOfNewsletter($newsletter), + ); + + $email + ->to($subscriber->getEmail()) + ->html($this->htmlTemplateRenderer->render($template, $variables)) + ->subject($heading . ' to ' . $newsletter->getName()); + + $this->relayApiClient->sendEmail($email); + } +} diff --git a/backend/src/Service/Subscriber/Dto/UpdateSubscriberDto.php b/backend/src/Service/Subscriber/Dto/UpdateSubscriberDto.php index f4b758fa..8c56fb86 100644 --- a/backend/src/Service/Subscriber/Dto/UpdateSubscriberDto.php +++ b/backend/src/Service/Subscriber/Dto/UpdateSubscriberDto.php @@ -3,6 +3,7 @@ namespace App\Service\Subscriber\Dto; use App\Entity\NewsletterList; +use App\Entity\Type\SubscriberSource; use App\Entity\Type\SubscriberStatus; use App\Util\OptionalPropertyTrait; @@ -11,25 +12,21 @@ class UpdateSubscriberDto use OptionalPropertyTrait; - public string $email; - - /** - * @var iterable - */ - public iterable $lists; - public SubscriberStatus $status; + public SubscriberSource $source; + public ?string $subscribeIp; public ?\DateTimeImmutable $subscribedAt; public ?\DateTimeImmutable $optInAt; - public \DateTimeImmutable $unsubscribedAt; - public ?string $unsubscribedReason; + /** @var NewsletterList[] */ + public array $lists; + /** - * @var array + * @var array */ public array $metadata; diff --git a/backend/src/Service/Subscriber/Event/SubscriberCreatedEvent.php b/backend/src/Service/Subscriber/Event/SubscriberCreatedEvent.php new file mode 100644 index 00000000..96d84b31 --- /dev/null +++ b/backend/src/Service/Subscriber/Event/SubscriberCreatedEvent.php @@ -0,0 +1,25 @@ +subscriber; + } + + public function shouldSendConfirmationEmail(): bool + { + return $this->sendConfirmationEmail; + } + +} diff --git a/backend/src/Service/Subscriber/Event/SubscriberUpdatedEvent.php b/backend/src/Service/Subscriber/Event/SubscriberUpdatedEvent.php new file mode 100644 index 00000000..2731331e --- /dev/null +++ b/backend/src/Service/Subscriber/Event/SubscriberUpdatedEvent.php @@ -0,0 +1,32 @@ +subscriber; + } + + public function getSubscriberOld(): Subscriber + { + return $this->subscriberOld; + } + + public function shouldSendConfirmationEmail(): bool + { + return $this->sendConfirmationEmail; + } + +} diff --git a/backend/src/Service/Subscriber/Event/SubscriberUpdatingEvent.php b/backend/src/Service/Subscriber/Event/SubscriberUpdatingEvent.php new file mode 100644 index 00000000..5c3197ed --- /dev/null +++ b/backend/src/Service/Subscriber/Event/SubscriberUpdatingEvent.php @@ -0,0 +1,32 @@ +subscriber; + } + + public function getSubscriberOld(): Subscriber + { + return $this->subscriberOld; + } + + public function getListRemovalReason(): ListRemovalReason + { + return $this->listRemovalReason; + } + +} diff --git a/backend/src/Service/Subscriber/ListRemoval/ListRemovalListener.php b/backend/src/Service/Subscriber/ListRemoval/ListRemovalListener.php new file mode 100644 index 00000000..fd433900 --- /dev/null +++ b/backend/src/Service/Subscriber/ListRemoval/ListRemovalListener.php @@ -0,0 +1,83 @@ +recordRemoving($event); + $this->deleteAdding($event); + } + + private function recordRemoving(SubscriberUpdatingEvent $event): void + { + $oldListIds = $event->getSubscriberOld()->getLists()->map(fn($list) => $list->getId())->toArray(); + $newListIds = $event->getSubscriber()->getLists()->map(fn($list) => $list->getId())->toArray(); + + $removedListIds = array_diff($oldListIds, $newListIds); + + foreach ($removedListIds as $removedListId) { + // PGSQL query with ON CONFLICT to update subscriber_list_removals + + $query = << $removedListId, + 'subscriber_id' => $event->getSubscriber()->getId(), + 'reason' => $event->getListRemovalReason()->value, + 'created_at' => $this->now()->format('Y-m-d H:i:s'), + ]; + + $this->em->getConnection()->executeQuery($query, $params); + } + } + + private function deleteAdding(SubscriberUpdatingEvent $event): void + { + $oldListIds = $event->getSubscriberOld()->getLists()->map(fn($list) => $list->getId())->toArray(); + $newListIds = $event->getSubscriber()->getLists()->map(fn($list) => $list->getId())->toArray(); + + $addedListIds = array_diff($newListIds, $oldListIds); + + if (count($addedListIds) === 0) { + return; + } + + $this->em->getConnection()->executeQuery( + "DELETE FROM subscriber_list_removals + WHERE subscriber_id = :subscriber_id + AND list_id IN (:added_list_ids)", + [ + 'subscriber_id' => $event->getSubscriber()->getId(), + 'added_list_ids' => $addedListIds, + ], + [ + 'subscriber_id' => \Doctrine\DBAL\ParameterType::INTEGER, + 'added_list_ids' => \Doctrine\DBAL\ArrayParameterType::INTEGER, + ], + ); + } + +} diff --git a/backend/src/Service/Subscriber/ListRemoval/ListRemovalService.php b/backend/src/Service/Subscriber/ListRemoval/ListRemovalService.php new file mode 100644 index 00000000..b075b2cd --- /dev/null +++ b/backend/src/Service/Subscriber/ListRemoval/ListRemovalService.php @@ -0,0 +1,42 @@ +em->createQueryBuilder(); + $qb + ->select('r') + ->from(SubscriberListRemoval::class, 'r') + ->where('r.subscriber = :subscriber') + ->andWhere($qb->expr()->in('r.list', ':listIds')) + ->andWhere($qb->expr()->in('r.reason', ':reasons')) + ->setParameter('subscriber', $subscriber) + ->setParameter('listIds', $listIds) + ->setParameter('reasons', $reasons); + + /** @var SubscriberListRemoval[] */ + return $qb->getQuery()->getResult(); + } + +} diff --git a/backend/src/Service/Subscriber/Message/SubscriberCreatedMessage.php b/backend/src/Service/Subscriber/Message/SubscriberCreatedMessage.php deleted file mode 100644 index 0e84ed5c..00000000 --- a/backend/src/Service/Subscriber/Message/SubscriberCreatedMessage.php +++ /dev/null @@ -1,20 +0,0 @@ -subscriberExportId; - } -} diff --git a/backend/src/Service/Subscriber/MessageHandler/SubscriberCreatedMessageHandler.php b/backend/src/Service/Subscriber/MessageHandler/SubscriberCreatedMessageHandler.php deleted file mode 100644 index b471182e..00000000 --- a/backend/src/Service/Subscriber/MessageHandler/SubscriberCreatedMessageHandler.php +++ /dev/null @@ -1,128 +0,0 @@ -em->getRepository(Subscriber::class)->find($message->getSubscriberId()); - assert($subscriber !== null); - $newsletter = $subscriber->getNewsletter(); - - if ($subscriber->getStatus() !== SubscriberStatus::PENDING) { - // If the subscriber is not pending, we do not send a confirmation email. - return; - } - - $data = [ - 'subscriber_id' => $subscriber->getId(), - 'expires_at' => $this->now()->add(new \DateInterval('P1D'))->format('Y-m-d H:i:s'), - ]; - - $token = $this->encryption->encrypt($data); - - $strings = $this->stringsFactory->create(); - - $heading = $strings->get('mail.subscriberConfirmation.heading'); - - $variables = $this->templateVariableService->variablesFromNewsletter($newsletter); - - $content = (string)json_encode([ - 'type' => 'doc', - 'content' => [ - [ - 'type' => 'paragraph', - 'content' => [ - [ - 'type' => 'text', - 'text' => 'Hey 👋,', - ], - ], - ], - [ - 'type' => 'paragraph', - 'content' => [ - [ - 'type' => 'text', - 'text' => 'Thank you for subscribing to ' . $newsletter->getName() . '! To confirm your subscription and start receiving updates, please click the button below.', - ], - ], - ], - [ - 'type' => 'button', - 'attrs' => [ - 'href' => $this->newsletterService->getArchiveUrl($newsletter) . "/confirm?token=" . $token, - ], - 'content' => [ - [ - 'type' => 'text', - 'text' => $strings->get('mail.subscriberConfirmation.buttonText'), - ], - ], - ], - [ - 'type' => 'paragraph', - 'content' => [ - [ - 'type' => 'text', - 'text' => 'This link will expire in 24 hours. If you did not request or expect this invitation, you can safely ignore this email.', - ], - ], - ], - ], - ]); - - $variables->subject = $heading; - $variables->content = $this->contentService->getHtmlFromJson($content); - - $template = $this->templateService->getTemplateStringFromNewsletter($newsletter); - - $email = new Email(); - $this->sendingProfileService->setSendingProfileToEmail( - $email, - $this->sendingProfileService->getCurrentDefaultSendingProfileOfNewsletter($newsletter) - ); - - $email->to($subscriber->getEmail()) - ->html($this->htmlTemplateRenderer->render($template, $variables)) - ->subject($heading . ' to ' . $newsletter->getName()); - $this->relayApiClient->sendEmail($email); - } -} diff --git a/backend/src/Service/Subscriber/SubscriberService.php b/backend/src/Service/Subscriber/SubscriberService.php index 1cc62b9e..4d83adb9 100644 --- a/backend/src/Service/Subscriber/SubscriberService.php +++ b/backend/src/Service/Subscriber/SubscriberService.php @@ -8,18 +8,21 @@ use App\Entity\Send; use App\Entity\Subscriber; use App\Entity\SubscriberExport; +use App\Entity\SubscriberListRemoval; +use App\Entity\Type\ListRemovalReason; use App\Entity\Type\SubscriberExportStatus; use App\Entity\Type\SubscriberSource; use App\Entity\Type\SubscriberStatus; use App\Repository\SubscriberRepository; use App\Service\Subscriber\Dto\UpdateSubscriberDto; +use App\Service\Subscriber\Event\SubscriberCreatedEvent; +use App\Service\Subscriber\Event\SubscriberUpdatedEvent; +use App\Service\Subscriber\Event\SubscriberUpdatingEvent; use App\Service\Subscriber\Message\ExportSubscribersMessage; -use App\Service\Subscriber\Message\SubscriberCreatedMessage; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\Clock\ClockAwareTrait; use Symfony\Component\EventDispatcher\EventDispatcherInterface; -use Symfony\Component\Messenger\MessageBusInterface; class SubscriberService { @@ -29,51 +32,41 @@ class SubscriberService public const BULK_SUBSCRIBER_LIMIT = 100; public function __construct( - private EntityManagerInterface $em, - private SubscriberRepository $subscriberRepository, - private MessageBusInterface $messageBus, - ) - { - } + private EntityManagerInterface $em, + private SubscriberRepository $subscriberRepository, + private EventDispatcherInterface $ed, + ) {} /** - * @param iterable $lists + * @param array $lists + * @param array $metadata */ public function createSubscriber( - Newsletter $newsletter, - string $email, - iterable $lists, - SubscriberStatus $status, - SubscriberSource $source, - ?string $subscribeIp = null, + Newsletter $newsletter, + string $email, + array $lists, + SubscriberStatus $status, + SubscriberSource $source, + ?string $subscribeIp = null, ?\DateTimeImmutable $subscribedAt = null, - ?\DateTimeImmutable $unsubscribedAt = null - ): Subscriber - { + array $metadata = [], + bool $sendConfirmationEmail = true, + ): Subscriber { $subscriber = new Subscriber() ->setNewsletter($newsletter) ->setEmail($email) ->setCreatedAt($this->now()) ->setUpdatedAt($this->now()) ->setStatus($status) - ->setSource($source); + ->setSubscribedAt($subscribedAt) + ->setSubscribeIp($subscribeIp) + ->setSource($source) + ->setMetadata($metadata); // if status is subscribed, subscribed_at should be set to now // if status is unsubscribed, unsubscribed_at should be set to now if ($status === SubscriberStatus::SUBSCRIBED) { - $subscriber->setSubscribedAt($this->now()); - } elseif ($status === SubscriberStatus::UNSUBSCRIBED) { - $subscriber->setUnsubscribedAt($this->now()); - } - - if ($subscribedAt !== null) { - $subscriber->setSubscribedAt($subscribedAt); - } - if ($unsubscribedAt !== null) { - $subscriber->setUnsubscribedAt($unsubscribedAt); - } - if ($subscribeIp !== null) { - $subscriber->setSubscribeIp($subscribeIp); + $subscriber->setSubscribedAt($subscribedAt ?? $this->now()); } foreach ($lists as $list) { @@ -83,7 +76,7 @@ public function createSubscriber( $this->em->persist($subscriber); $this->em->flush(); - $this->messageBus->dispatch(new SubscriberCreatedMessage($subscriber->getId())); + $this->ed->dispatch(new SubscriberCreatedEvent($subscriber, $sendConfirmationEmail)); return $subscriber; } @@ -102,7 +95,8 @@ public function deleteSubscribers(array $subscribers): void $ids = array_map(fn(Subscriber $s) => $s->getId(), $subscribers); $qb = $this->em->createQueryBuilder(); - $qb->delete(Subscriber::class, 's') + $qb + ->delete(Subscriber::class, 's') ->where($qb->expr()->in('s.id', ':ids')) ->setParameter('ids', $ids); @@ -113,14 +107,13 @@ public function deleteSubscribers(array $subscribers): void * @return ArrayCollection */ public function getSubscribers( - Newsletter $newsletter, + Newsletter $newsletter, ?SubscriberStatus $status, - ?int $listId, - ?string $search, - int $limit, - int $offset - ): ArrayCollection - { + ?int $listId, + ?string $search, + int $limit, + int $offset, + ): ArrayCollection { $qb = $this->subscriberRepository->createQueryBuilder('s'); $qb @@ -133,18 +126,21 @@ public function getSubscribers( ->setFirstResult($offset); if ($status !== null) { - $qb->andWhere('s.status = :status') + $qb + ->andWhere('s.status = :status') ->setParameter('status', $status->value); } if ($listId !== null) { - $qb->andWhere('l.id = :listId') + $qb + ->andWhere('l.id = :listId') ->andWhere('l.deleted_at IS NULL') ->setParameter('listId', $listId); } if ($search !== null) { - $qb->andWhere('s.email LIKE :search') + $qb + ->andWhere('s.email LIKE :search') ->setParameter('search', '%' . $search . '%'); } @@ -155,54 +151,71 @@ public function getSubscribers( return new ArrayCollection($results); } - public function updateSubscriber(Subscriber $subscriber, UpdateSubscriberDto $updates): Subscriber - { - if ($updates->hasProperty('email')) { - $subscriber->setEmail($updates->email); - } + public function updateSubscriber( + Subscriber $subscriber, + UpdateSubscriberDto $updates, + + // if some lists are being removed, set the reason to correctly record + // it in ListRemovalListener + ListRemovalReason $listRemovalReason = ListRemovalReason::UNSUBSCRIBE, + // whether to send the confirmation email if the status was changed to "pending" + bool $sendConfirmationEmail = false, + ): Subscriber { + $subscriberOld = clone $subscriber; - if ($updates->hasProperty('status')) { + if ($updates->has('status')) { $subscriber->setStatus($updates->status); } - if ($updates->hasProperty('lists')) { - // Clear & re-add lists - foreach ($subscriber->getLists() as $list) { - $subscriber->removeList($list); - } - foreach ($updates->lists as $list) { - $subscriber->addList($list); - } + if ($updates->has('source')) { + $subscriber->setSource($updates->source); + } + + if ($updates->has('subscribeIp')) { + $subscriber->setSubscribeIp($updates->subscribeIp); } - if ($updates->hasProperty('subscribedAt')) { + if ($updates->has('subscribedAt')) { $subscriber->setSubscribedAt($updates->subscribedAt); } - if ($updates->hasProperty('optInAt')) { + if ($updates->has('optInAt')) { $subscriber->setOptInAt($updates->optInAt); } - if ($updates->hasProperty('unsubscribedAt')) { - $subscriber->setUnsubscribedAt($updates->unsubscribedAt); + if ($updates->has('unsubscribedReason')) { + $subscriber->setUnsubscribeReason($updates->unsubscribedReason); } - if ($updates->hasProperty('unsubscribedReason')) { - $subscriber->setUnsubscribeReason($updates->unsubscribedReason); + if ($updates->has('metadata')) { + $subscriber->setMetadata($updates->metadata); } - if ($updates->hasProperty('metadata')) { - $metadata = $subscriber->getMetadata(); - foreach ($updates->metadata as $key => $value) { - $metadata[$key] = $value; - } - $subscriber->setMetadata($metadata); + if ($updates->has('lists')) { + $subscriber->setLists(new ArrayCollection($updates->lists)); } $subscriber->setUpdatedAt($this->now()); - $this->em->persist($subscriber); - $this->em->flush(); + $this->em->wrapInTransaction(function () use ($subscriberOld, $subscriber, $listRemovalReason) { + $this->em->persist($subscriber); + $this->ed->dispatch( + new SubscriberUpdatingEvent( + $subscriberOld, + $subscriber, + listRemovalReason: $listRemovalReason, + ), + ); + $this->em->flush(); + }); + + $this->ed->dispatch( + new SubscriberUpdatedEvent( + $subscriberOld, + $subscriber, + $sendConfirmationEmail, + ), + ); return $subscriber; } @@ -213,11 +226,10 @@ public function getSubscriberByEmail(Newsletter $newsletter, string $email): ?Su } public function unsubscribeBySend( - Send $send, + Send $send, ?\DateTimeImmutable $at = null, - ?string $reason = null - ): void - { + ?string $reason = null, + ): void { $subscriber = $send->getSubscriber(); $update = new UpdateSubscriberDto(); @@ -231,14 +243,14 @@ public function unsubscribeBySend( } public function unsubscribeByEmail( - string $email, + string $email, ?\DateTimeImmutable $at = null, - ?string $reason = null - ): void - { + ?string $reason = null, + ): void { $qb = $this->em->createQueryBuilder(); - $qb->update(Subscriber::class, 's') + $qb + ->update(Subscriber::class, 's') ->set('s.status', ':status') ->set('s.opt_in_at', ':optInAt') ->set('s.unsubscribed_at', ':unsubscribedAt') @@ -272,9 +284,8 @@ public function exportSubscribers(Newsletter $newsletter): SubscriberExport public function markSubscriberExportAsFailed( SubscriberExport $subscriberExport, - string $errorMessage - ): void - { + string $errorMessage, + ): void { $subscriberExport->setStatus(SubscriberExportStatus::FAILED); $subscriberExport->setErrorMessage($errorMessage); $this->em->persist($subscriberExport); @@ -283,9 +294,8 @@ public function markSubscriberExportAsFailed( public function markSubscriberExportAsCompleted( SubscriberExport $subscriberExport, - Media $media - ): void - { + Media $media, + ): void { $subscriberExport->setStatus(SubscriberExportStatus::COMPLETED); $subscriberExport->setMedia($media); $this->em->persist($subscriberExport); @@ -297,10 +307,64 @@ public function markSubscriberExportAsCompleted( */ public function getExports(Newsletter $newsletter): array { - return $this->em->getRepository(SubscriberExport::class) + return $this->em + ->getRepository(SubscriberExport::class) ->findBy(['newsletter' => $newsletter], ['created_at' => 'DESC']); } + + public function addSubscriberToList( + Subscriber $subscriber, + NewsletterList $list, + ): void { + $subscriber->addList($list); + $subscriber->setUpdatedAt($this->now()); + + $this->em->persist($subscriber); + $this->em->flush(); + } + + public function removeSubscriberFromList( + Subscriber $subscriber, + NewsletterList $list, + bool $recordUnsubscription, + ): void { + $subscriber->removeList($list); + $subscriber->setUpdatedAt($this->now()); + + $this->em->persist($subscriber); + $this->em->flush(); + + + if ($recordUnsubscription) { + $existing = $this->em->getRepository(SubscriberListRemoval::class)->findOneBy([ + 'list' => $list, + 'subscriber' => $subscriber, + ]); + + if ($existing === null) { + $unsubscribed = new SubscriberListRemoval() + ->setList($list) + ->setSubscriber($subscriber) + ->setCreatedAt($this->now()); + + $this->em->persist($unsubscribed); + $this->em->persist($list); // test fails otherwise, since this is used in removeElement, but sure why + $this->em->flush(); + } + } + } + + public function hasSubscriberUnsubscribedFromList(Subscriber $subscriber, NewsletterList $list): bool + { + $record = $this->em->getRepository(SubscriberListRemoval::class)->findOneBy([ + 'list' => $list, + 'subscriber' => $subscriber, + ]); + + return $record !== null; + } + public function getSubscriberById(int $id): ?Subscriber { return $this->subscriberRepository->find($id); diff --git a/backend/src/Service/SubscriberMetadata/Exception/MetadataValidationFailedException.php b/backend/src/Service/SubscriberMetadata/Exception/MetadataValidationFailedException.php new file mode 100644 index 00000000..ea59983b --- /dev/null +++ b/backend/src/Service/SubscriberMetadata/Exception/MetadataValidationFailedException.php @@ -0,0 +1,5 @@ +findOneBy(['newsletter' => $newsletter, 'key' => $key]); } + /** + * @param string[] $keys + * @return SubscriberMetadataDefinition[] + */ + public function getMetadataDefinitionsByKeys(Newsletter $newsletter, array $keys): array + { + return $this->entityManager + ->getRepository(SubscriberMetadataDefinition::class) + ->findBy(['newsletter' => $newsletter, 'key' => $keys]); + } + public function getMetadataDefinitionsCount(Newsletter $newsletter): int { return $this->entityManager @@ -47,10 +57,9 @@ public function getMetadataDefinitionsCount(Newsletter $newsletter): int public function createMetadataDefinition( Newsletter $newsletter, - string $key, - string $name, - ): SubscriberMetadataDefinition - { + string $key, + string $name, + ): SubscriberMetadataDefinition { $metadataDefinition = new SubscriberMetadataDefinition(); $metadataDefinition->setNewsletter($newsletter); $metadataDefinition->setKey($key); @@ -67,9 +76,8 @@ public function createMetadataDefinition( public function updateMetadataDefinition( SubscriberMetadataDefinition $metadataDefinition, - string $name, - ): void - { + string $name, + ): void { $metadataDefinition->setName($name); $metadataDefinition->setUpdatedAt($this->now()); @@ -82,11 +90,38 @@ public function deleteMetadataDefinition(SubscriberMetadataDefinition $metadataD $this->entityManager->flush(); } - public function validateValueType( - SubscriberMetadataDefinition $metadataDefinition, - mixed $value - ): bool + /** + * @param array $metadata + * @throws MetadataValidationFailedException + */ + public function validateMetadata(Newsletter $newsletter, array $metadata): void { + $keys = array_keys($metadata); + $definitions = $this->getMetadataDefinitionsByKeys($newsletter, $keys); + + if (count($definitions) !== count($keys)) { + $foundKeys = array_map(fn(SubscriberMetadataDefinition $def) => $def->getKey(), $definitions); + $missingKeys = array_diff($keys, $foundKeys); + throw new MetadataValidationFailedException( + "Metadata definitions with keys " . implode(', ', $missingKeys) . " not found", + ); + } + + foreach ($definitions as $definition) { + $value = $metadata[$definition->getKey()] ?? null; + if (!$this->validateValueType($definition, $value)) { + throw new MetadataValidationFailedException( + "Invalid value type for metadata key " . $definition->getKey( + ) . ". Expected type: " . $definition->getType()->toJsonType(), + ); + } + } + } + + private function validateValueType( + SubscriberMetadataDefinition $metadataDefinition, + mixed $value, + ): bool { return match ($metadataDefinition->getType()) { // @phpstan-ignore-next-line SubscriberMetadataDefinitionType::TEXT => is_string($value), @@ -94,20 +129,4 @@ public function validateValueType( default => false, }; } - - /** - * @param array $metadata - */ - public function validateMetadata(Newsletter $newsletter, array $metadata): bool - { - foreach ($metadata as $key => $value) { - $metaDef = $this->getMetadataDefinitionByKey($newsletter, $key); - if ($metaDef === null) - throw new \Exception("Metadata definition with key {$key} not found"); - if (!$this->validateValueType($metaDef, $value)) { - throw new \Exception("Value for metadata key {$key} is not valid"); - } - } - return true; - } } diff --git a/backend/src/Service/Template/TemplateService.php b/backend/src/Service/Template/TemplateService.php index 49e9b6d3..235098b1 100644 --- a/backend/src/Service/Template/TemplateService.php +++ b/backend/src/Service/Template/TemplateService.php @@ -59,7 +59,7 @@ public function readDefaultTemplate(): string public function updateTemplate(Template $template, UpdateTemplateDto $updates): Template { - if ($updates->hasProperty('template')) { + if ($updates->has('template')) { $template->setTemplate($updates->template); } diff --git a/backend/src/Util/OptionalPropertyTrait.php b/backend/src/Util/OptionalPropertyTrait.php index 49175041..3c39f721 100644 --- a/backend/src/Util/OptionalPropertyTrait.php +++ b/backend/src/Util/OptionalPropertyTrait.php @@ -8,7 +8,7 @@ trait OptionalPropertyTrait /** * Checks if the property is INITIALIZED */ - public function hasProperty(string $property): bool + public function has(string $property): bool { try { $_ = $this->{$property}; @@ -18,4 +18,4 @@ public function hasProperty(string $property): bool } } -} \ No newline at end of file +} diff --git a/backend/symfony.lock b/backend/symfony.lock index e5fb5c2a..16134c87 100644 --- a/backend/symfony.lock +++ b/backend/symfony.lock @@ -94,6 +94,18 @@ "tests/bootstrap.php" ] }, + "sentry/sentry-symfony": { + "version": "5.9", + "recipe": { + "repo": "github.com/symfony/recipes-contrib", + "branch": "main", + "version": "5.0", + "ref": "12f504985eb24e3b20a9e41e0ec7e398798d18f0" + }, + "files": [ + "config/packages/sentry.yaml" + ] + }, "symfony/console": { "version": "7.2", "recipe": { diff --git a/backend/templates/newsletter/mail/confirm.json.twig b/backend/templates/newsletter/mail/confirm.json.twig new file mode 100644 index 00000000..2e32289c --- /dev/null +++ b/backend/templates/newsletter/mail/confirm.json.twig @@ -0,0 +1,44 @@ +{ + "type": "doc", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Hey 👋," + } + ] + }, + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Thank you for subscribing to {{ newsletterName }}! To confirm your subscription and start receiving updates, please click the button below." + } + ] + }, + { + "type": "button", + "attrs": { + "href": "{{ buttonUrl }}" + }, + "content": [ + { + "type": "text", + "text": "{{ buttonText }}" + } + ] + }, + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "This link will expire in 24 hours. If you did not request or expect this invitation, you can safely ignore this email." + } + ] + } + ] +} diff --git a/backend/tests/Api/Console/AuthorizationTest.php b/backend/tests/Api/Console/AuthorizationTest.php index 28996683..30d0ba13 100644 --- a/backend/tests/Api/Console/AuthorizationTest.php +++ b/backend/tests/Api/Console/AuthorizationTest.php @@ -41,7 +41,7 @@ public function test_api_key_authentication_nothing(): void $this->assertResponseStatusCodeSame(401); $this->assertSame( "Unauthorized", - $this->getJson()["message"] + $this->getJson()["message"], ); } @@ -52,12 +52,12 @@ public function test_wrong_authorization_header(): void "/api/console/issues", server: [ "HTTP_AUTHORIZATION" => "WrongHeader", - ] + ], ); $this->assertResponseStatusCodeSame(403); $this->assertSame( 'Authorization header must start with "Bearer ".', - $this->getJson()["message"] + $this->getJson()["message"], ); } @@ -68,12 +68,12 @@ public function test_missing_bearer_token(): void "/api/console/issues", server: [ "HTTP_AUTHORIZATION" => "Bearer ", - ] + ], ); $this->assertResponseStatusCodeSame(403); $this->assertSame( "API key is missing or empty.", - $this->getJson()["message"] + $this->getJson()["message"], ); } @@ -84,7 +84,7 @@ public function test_invalid_api_key(): void "/api/console/issues", server: [ "HTTP_AUTHORIZATION" => "Bearer InvalidApiKey", - ] + ], ); $this->assertResponseStatusCodeSame(403); $this->assertSame("Invalid API key.", $this->getJson()["message"]); @@ -102,7 +102,7 @@ public function test_invalid_session(): void "/api/console/issues", server: [ "HTTP_X_NEWSLETTER_ID" => $newsletter->getId(), - ] + ], ); $this->assertResponseStatusCodeSame(401); $this->assertSame("Unauthorized", $this->getJson()["message"]); @@ -115,7 +115,7 @@ public function test_fails_when_organization_is_required(): void $this->client->getCookieJar()->set(new Cookie('authsess', 'validSession')); $this->client->request( "GET", - "/api/console/issues" + "/api/console/issues", ); $this->assertResponseStatusCodeSame(403); $this->assertSame("Current organization is missing.", $this->getJson()["message"]); @@ -129,8 +129,8 @@ public function test_fails_organization_mismatch(): void new AuthUserOrganization( id: 1, name: 'Fake Organization', - role: 'admin' - ) + role: 'admin', + ), ); $this->client->getCookieJar()->set(new Cookie('authsess', 'validSession')); @@ -139,7 +139,7 @@ public function test_fails_organization_mismatch(): void "/api/console/issues", server: [ "HTTP_X_ORGANIZATION_ID" => 2, - ] + ], ); $this->assertResponseStatusCodeSame(403); $this->assertSame("org_mismatch", $this->getJson()["message"]); @@ -153,8 +153,8 @@ public function test_fails_when_xnewsletterid_header_is_not_set(): void new AuthUserOrganization( id: 1, name: 'Fake Organization', - role: 'admin' - ) + role: 'admin', + ), ); $this->client->getCookieJar()->set(new Cookie('authsess', 'validSession')); @@ -163,7 +163,7 @@ public function test_fails_when_xnewsletterid_header_is_not_set(): void "/api/console/issues", server: [ "HTTP_X_ORGANIZATION_ID" => 1, - ] + ], ); $this->assertResponseStatusCodeSame(403); $this->assertSame("X-Newsletter-ID is required for this endpoint.", $this->getJson()["message"]); @@ -177,8 +177,8 @@ public function test_invalid_newsletter_id(): void new AuthUserOrganization( id: 1, name: 'Fake Organization', - role: 'admin' - ) + role: 'admin', + ), ); $this->client->getCookieJar()->set(new Cookie('authsess', 'validSession')); $this->client->request( @@ -201,8 +201,8 @@ public function test_newsletter_does_not_belong_to_current_organization(): void new AuthUserOrganization( id: 1, name: 'Fake Organization', - role: 'admin' - ) + role: 'admin', + ), ); $newsletter = NewsletterFactory::createOne(['organization_id' => 2]); $this->client->getCookieJar()->set(new Cookie('authsess', 'validSession')); @@ -212,12 +212,12 @@ public function test_newsletter_does_not_belong_to_current_organization(): void server: [ "HTTP_X_ORGANIZATION_ID" => 1, "HTTP_X_NEWSLETTER_ID" => $newsletter->getId(), - ] + ], ); $this->assertResponseStatusCodeSame(403); $this->assertSame( "does_not_belong_the_resource", - $this->getJson()["message"] + $this->getJson()["message"], ); } @@ -229,8 +229,8 @@ public function test_user_not_authorized_for_newsletter(): void new AuthUserOrganization( id: 1, name: 'Fake Organization', - role: 'admin' - ) + role: 'admin', + ), ); $newsletter = NewsletterFactory::createOne(['organization_id' => 1]); $this->client->getCookieJar()->set(new Cookie('authsess', 'validSession')); @@ -240,12 +240,12 @@ public function test_user_not_authorized_for_newsletter(): void server: [ "HTTP_X_ORGANIZATION_ID" => 1, "HTTP_X_NEWSLETTER_ID" => $newsletter->getId(), - ] + ], ); $this->assertResponseStatusCodeSame(403); $this->assertSame( "You do not have access to this newsletter.", - $this->getJson()["message"] + $this->getJson()["message"], ); } @@ -256,12 +256,12 @@ public function test_missing_scope_required_attribute(): void $newsletter, 'GET', '/issues', - scopes: [Scope::ISSUES_WRITE] + scopes: [Scope::ISSUES_WRITE], ); $this->assertResponseStatusCodeSame(403); $this->assertSame( "You do not have the required scope 'issues.read' to access this resource.", - $this->getJson()["message"] + $this->getJson()["message"], ); } @@ -274,23 +274,23 @@ public function test_authorizes_via_api_key_and_updates_last_usage(): void $newsletter, 'GET', '/issues', - scopes: [Scope::ISSUES_READ] + scopes: [Scope::ISSUES_READ], ); $this->assertResponseStatusCodeSame(200); $newsletterFromAttr = $this->client->getRequest()->attributes->get('console_api_resolved_newsletter'); $this->assertInstanceOf( Newsletter::class, - $newsletterFromAttr + $newsletterFromAttr, ); $this->assertSame($newsletter->getId(), $newsletterFromAttr->getId()); - $apiKey = $this->em->getRepository(ApiKey::class)->findOneBy(['newsletter' => $newsletter->_real()]); + $apiKey = $this->em->getRepository(ApiKey::class)->findOneBy(['newsletter' => $newsletter]); $this->assertInstanceOf(ApiKey::class, $apiKey); $this->assertSame( '2025-06-01 00:00:00', - $apiKey->getLastAccessedAt()?->format('Y-m-d H:i:s') + $apiKey->getLastAccessedAt()?->format('Y-m-d H:i:s'), ); } @@ -302,15 +302,15 @@ public function test_authorizes_via_session(): void new AuthUserOrganization( id: 1, name: 'Fake Organization', - role: 'admin' - ) + role: 'admin', + ), ); $newsletter = NewsletterFactory::createOne([ - 'organization_id' => 1 + 'organization_id' => 1, ]); UserFactory::createOne([ 'hyvor_user_id' => 1, - 'newsletter' => $newsletter + 'newsletter' => $newsletter, ]); $this->client->getCookieJar()->set(new Cookie('authsess', 'validSession')); @@ -320,14 +320,14 @@ public function test_authorizes_via_session(): void server: [ "HTTP_X_ORGANIZATION_ID" => 1, "HTTP_X_NEWSLETTER_ID" => $newsletter->getId(), - ] + ], ); $this->assertResponseStatusCodeSame(200); $newsletterFromAttr = $this->client->getRequest()->attributes->get('console_api_resolved_newsletter'); $this->assertInstanceOf( Newsletter::class, - $newsletterFromAttr + $newsletterFromAttr, ); $this->assertSame($newsletter->getId(), $newsletterFromAttr->getId()); @@ -344,20 +344,20 @@ public function test_user_level_endpoint_works(): void new AuthUserOrganization( id: 1, name: 'Fake Organization', - role: 'admin' - ) + role: 'admin', + ), ); BillingFake::enableForSymfony( $this->container, - [1 => new ResolvedLicense(ResolvedLicenseType::TRIAL, PostLicense::trial())] + [1 => new ResolvedLicense(ResolvedLicenseType::TRIAL, PostLicense::trial())], ); $newsletter = NewsletterFactory::createOne([ - 'organization_id' => 1 + 'organization_id' => 1, ]); UserFactory::createOne([ 'hyvor_user_id' => 1, - 'newsletter' => $newsletter + 'newsletter' => $newsletter, ]); $this->client->getCookieJar()->set(new Cookie('authsess', 'validSession')); @@ -379,7 +379,7 @@ public function test_when_no_organization_is_required(): void $this->client->getCookieJar()->set(new Cookie('authsess', 'validSession')); $this->client->request( "GET", - "/api/console/init" + "/api/console/init", ); $this->assertResponseStatusCodeSame(200); diff --git a/backend/tests/Api/Console/ConsoleInitNewsletterTest.php b/backend/tests/Api/Console/ConsoleInitNewsletterTest.php index 2cc9f97e..470c6639 100644 --- a/backend/tests/Api/Console/ConsoleInitNewsletterTest.php +++ b/backend/tests/Api/Console/ConsoleInitNewsletterTest.php @@ -37,7 +37,7 @@ public function test_stats_subscribers_and_issues(): void BillingFake::enableForSymfony( $this->container, - [1 => new ResolvedLicense(ResolvedLicenseType::SUBSCRIPTION, $license)] + [1 => new ResolvedLicense(ResolvedLicenseType::SUBSCRIPTION, $license)], ); // --- subscribers @@ -54,11 +54,6 @@ public function test_stats_subscribers_and_issues(): void 'newsletter' => $newsletter, 'status' => SubscriberStatus::SUBSCRIBED, ]); - // unsubscribed - SubscriberFactory::createMany(2, [ - 'newsletter' => $newsletter, - 'status' => SubscriberStatus::UNSUBSCRIBED, - ]); // other newsletters SubscriberFactory::createMany(3, [ 'newsletter' => $otherNewsletter, @@ -134,7 +129,7 @@ public function test_when_can_no_permissions_to_change_branding(): void BillingFake::enableForSymfony( $this->container, - [1 => new ResolvedLicense(ResolvedLicenseType::SUBSCRIPTION, PostLicense::trial())] + [1 => new ResolvedLicense(ResolvedLicenseType::SUBSCRIPTION, PostLicense::trial())], ); $response = $this->consoleApi( @@ -217,7 +212,7 @@ public function test_stats_bounced_complained_rates(): void BillingFake::enableForSymfony( $this->container, - [1 => new ResolvedLicense(ResolvedLicenseType::SUBSCRIPTION, PostLicense::trial())] + [1 => new ResolvedLicense(ResolvedLicenseType::SUBSCRIPTION, PostLicense::trial())], ); $response = $this->consoleApi( diff --git a/backend/tests/Api/Console/ConsoleInitTest.php b/backend/tests/Api/Console/ConsoleInitTest.php index 61250b15..9d200bca 100644 --- a/backend/tests/Api/Console/ConsoleInitTest.php +++ b/backend/tests/Api/Console/ConsoleInitTest.php @@ -27,9 +27,6 @@ class ConsoleInitTest extends WebTestCase { - // TODO: tests for input validation - // TODO: tests for authentication - protected function shouldEnableAuthFake(): bool { return false; @@ -43,8 +40,8 @@ private function enableAuthFake(bool $withOrganization = true): void $withOrganization ? new AuthUserOrganization( id: 1, name: 'Fake Organization', - role: 'admin' - ) : null + role: 'admin', + ) : null, ); } @@ -60,7 +57,7 @@ public function testInitConsole(): void UserFactory::createOne([ 'newsletter' => $newsletter, 'hyvor_user_id' => 1, - 'role' => UserRole::OWNER + 'role' => UserRole::OWNER, ]); } @@ -74,14 +71,14 @@ public function testInitConsole(): void ]); $newsletterAdmin = NewsletterFactory::createOne([ - 'organization_id' => 1 + 'organization_id' => 1, ]); // admin $user = UserFactory::createOne([ 'newsletter' => $newsletterAdmin, 'hyvor_user_id' => 1, - 'role' => UserRole::ADMIN + 'role' => UserRole::ADMIN, ]); $noAccessNewsletter = NewsletterFactory::createOne([ @@ -90,18 +87,18 @@ public function testInitConsole(): void UserFactory::createOne([ 'newsletter' => $noAccessNewsletter, 'hyvor_user_id' => 2, - 'role' => UserRole::OWNER + 'role' => UserRole::OWNER, ]); BillingFake::enableForSymfony( $this->container, - [1 => new ResolvedLicense(ResolvedLicenseType::TRIAL, PostLicense::trial())] + [1 => new ResolvedLicense(ResolvedLicenseType::TRIAL, PostLicense::trial())], ); $response = $this->consoleApi( null, 'GET', - '/init' + '/init', ); $this->assertSame(200, $response->getStatusCode()); @@ -139,7 +136,7 @@ public function testInitConsoleWithoutOrg(): void $response = $this->consoleApi( null, 'GET', - '/init' + '/init', ); $this->assertSame(200, $response->getStatusCode()); @@ -182,12 +179,12 @@ public function testInitNewsletter(): void $user = UserFactory::createOne([ 'newsletter' => $newsletter, 'hyvor_user_id' => 1, - 'role' => UserRole::OWNER + 'role' => UserRole::OWNER, ]); BillingFake::enableForSymfony( $this->container, - [1 => new ResolvedLicense(ResolvedLicenseType::SUBSCRIPTION, PostLicense::trial())] + [1 => new ResolvedLicense(ResolvedLicenseType::SUBSCRIPTION, PostLicense::trial())], ); $response = $this->consoleApi( @@ -218,25 +215,18 @@ public function testInitNewsletterWithLists(): void $user = UserFactory::createOne([ 'newsletter' => $newsletter, 'hyvor_user_id' => 1, - 'role' => UserRole::OWNER + 'role' => UserRole::OWNER, ]); $newsletterList = NewsletterListFactory::createOne([ 'newsletter' => $newsletter, ]); - $subscribersOldUnsubscribed = SubscriberFactory::createMany(2, [ - 'newsletter' => $newsletter, - 'lists' => [$newsletterList], - 'created_at' => new \DateTimeImmutable('2021-01-01'), - 'status' => SubscriberStatus::UNSUBSCRIBED - ]); - $subscribersOld = SubscriberFactory::createMany(5, [ 'newsletter' => $newsletter, 'lists' => [$newsletterList], 'created_at' => new \DateTimeImmutable('2021-01-01'), - 'status' => SubscriberStatus::SUBSCRIBED + 'status' => SubscriberStatus::SUBSCRIBED, ]); @@ -244,24 +234,20 @@ public function testInitNewsletterWithLists(): void 'newsletter' => $newsletter, 'lists' => [$newsletterList], 'created_at' => new \DateTimeImmutable(), - 'status' => SubscriberStatus::SUBSCRIBED + 'status' => SubscriberStatus::SUBSCRIBED, ]); - foreach ($subscribersOldUnsubscribed as $subscriber) { - $newsletterList->addSubscriber($subscriber->_real()); - } - foreach ($subscribersOld as $subscriber) { - $newsletterList->addSubscriber($subscriber->_real()); + $newsletterList->addSubscriber($subscriber); } foreach ($subscribersNew as $subscriber) { - $newsletterList->addSubscriber($subscriber->_real()); + $newsletterList->addSubscriber($subscriber); } BillingFake::enableForSymfony( $this->container, - [1 => new ResolvedLicense(ResolvedLicenseType::SUBSCRIPTION, PostLicense::trial())] + [1 => new ResolvedLicense(ResolvedLicenseType::SUBSCRIPTION, PostLicense::trial())], ); $response = $this->consoleApi( diff --git a/backend/tests/Api/Console/Import/ImportTest.php b/backend/tests/Api/Console/Import/ImportTest.php index be0034aa..477b169d 100644 --- a/backend/tests/Api/Console/Import/ImportTest.php +++ b/backend/tests/Api/Console/Import/ImportTest.php @@ -21,7 +21,7 @@ class ImportTest extends WebTestCase /** @var array */ const array MAPPING = [ 'email' => 'email', - 'lists' => 'lists' + 'lists' => 'lists', ]; public function test_import(): void @@ -37,7 +37,7 @@ public function test_import(): void /** @var MediaService $mediaService */ $mediaService = $this->container->get(MediaService::class); $media = $mediaService->upload( - $newsletter->_real(), + $newsletter, MediaFolder::IMPORT, $file, ); @@ -45,7 +45,7 @@ public function test_import(): void $subscriberImport = SubscriberImportFactory::createOne([ 'newsletter' => $newsletter, 'media' => $media, - 'status' => SubscriberImportStatus::REQUIRES_INPUT + 'status' => SubscriberImportStatus::REQUIRES_INPUT, ]); $response = $this->consoleApi( @@ -53,8 +53,8 @@ public function test_import(): void 'POST', '/imports/' . $subscriberImport->getId(), [ - 'mapping' => self::MAPPING - ] + 'mapping' => self::MAPPING, + ], ); $this->assertSame(200, $response->getStatusCode()); @@ -75,7 +75,7 @@ public function test_import_in_non_pending_status(): void $subscriberImport = SubscriberImportFactory::createOne([ 'newsletter' => $newsletter, - 'status' => SubscriberImportStatus::COMPLETED + 'status' => SubscriberImportStatus::COMPLETED, ]); $response = $this->consoleApi( @@ -83,8 +83,8 @@ public function test_import_in_non_pending_status(): void 'POST', '/imports/' . $subscriberImport->getId(), [ - 'mapping' => self::MAPPING - ] + 'mapping' => self::MAPPING, + ], ); $this->assertSame(422, $response->getStatusCode()); @@ -99,7 +99,7 @@ public function test_import_without_email_mapping(): void $subscriberImport = SubscriberImportFactory::createOne([ 'newsletter' => $newsletter, - 'status' => SubscriberImportStatus::REQUIRES_INPUT + 'status' => SubscriberImportStatus::REQUIRES_INPUT, ]); $response = $this->consoleApi( @@ -108,9 +108,9 @@ public function test_import_without_email_mapping(): void '/imports/' . $subscriberImport->getId(), [ 'mapping' => [ - 'lists' => 'lists' - ] - ] + 'lists' => 'lists', + ], + ], ); $this->assertSame(422, $response->getStatusCode()); @@ -129,7 +129,7 @@ public function test_import_with_null_email_mapping(): void $subscriberImport = SubscriberImportFactory::createOne([ 'newsletter' => $newsletter, - 'status' => SubscriberImportStatus::REQUIRES_INPUT + 'status' => SubscriberImportStatus::REQUIRES_INPUT, ]); $response = $this->consoleApi( @@ -138,9 +138,9 @@ public function test_import_with_null_email_mapping(): void '/imports/' . $subscriberImport->getId(), [ 'mapping' => [ - 'email' => null - ] - ] + 'email' => null, + ], + ], ); $this->assertSame(422, $response->getStatusCode()); @@ -159,7 +159,7 @@ public function test_import_with_empty_email_mapping(): void $subscriberImport = SubscriberImportFactory::createOne([ 'newsletter' => $newsletter, - 'status' => SubscriberImportStatus::REQUIRES_INPUT + 'status' => SubscriberImportStatus::REQUIRES_INPUT, ]); $response = $this->consoleApi( @@ -168,9 +168,9 @@ public function test_import_with_empty_email_mapping(): void '/imports/' . $subscriberImport->getId(), [ 'mapping' => [ - 'email' => '' - ] - ] + 'email' => '', + ], + ], ); $this->assertSame(422, $response->getStatusCode()); @@ -192,7 +192,7 @@ public function test_daily_import_limit(): void SubscriberImportFactory::createOne([ 'newsletter' => $newsletter, 'created_at' => $date, - 'status' => SubscriberImportStatus::COMPLETED + 'status' => SubscriberImportStatus::COMPLETED, ]); $response = $this->consoleApi( @@ -200,8 +200,8 @@ public function test_daily_import_limit(): void 'POST', '/imports/upload', parameters: [ - 'source' => 'test' - ] + 'source' => 'test', + ], ); $this->assertSame(422, $response->getStatusCode()); @@ -219,7 +219,7 @@ public function test_monthly_import_limit(): void SubscriberImportFactory::createMany(5, [ 'newsletter' => $newsletter, 'created_at' => $date->modify('-7 day'), - 'status' => SubscriberImportStatus::COMPLETED + 'status' => SubscriberImportStatus::COMPLETED, ]); $response = $this->consoleApi( @@ -227,8 +227,8 @@ public function test_monthly_import_limit(): void 'POST', '/imports/upload', parameters: [ - 'source' => 'test' - ] + 'source' => 'test', + ], ); $this->assertSame(422, $response->getStatusCode()); @@ -252,11 +252,11 @@ public function test_import_upload_small(): void 'POST', '/imports/upload', files: [ - 'file' => $file + 'file' => $file, ], parameters: [ - 'source' => 'test' - ] + 'source' => 'test', + ], ); $this->assertSame(200, $response->getStatusCode()); diff --git a/backend/tests/Api/Console/Subscriber/CreateSubscriberTest.php b/backend/tests/Api/Console/Subscriber/CreateSubscriberTest.php index 15d3d2ad..8dda36fd 100644 --- a/backend/tests/Api/Console/Subscriber/CreateSubscriberTest.php +++ b/backend/tests/Api/Console/Subscriber/CreateSubscriberTest.php @@ -3,36 +3,58 @@ namespace App\Tests\Api\Console\Subscriber; use App\Api\Console\Controller\SubscriberController; -use App\Entity\Newsletter; +use App\Api\Console\Input\Subscriber\CreateSubscriberInput; use App\Entity\Subscriber; +use App\Entity\SubscriberListRemoval; +use App\Entity\Type\ListRemovalReason; +use App\Entity\Type\SubscriberMetadataDefinitionType; use App\Entity\Type\SubscriberSource; use App\Entity\Type\SubscriberStatus; -use App\Repository\SubscriberRepository; -use App\Service\Subscriber\Message\SubscriberCreatedMessage; +use App\Service\App\Messenger\MessageTransport; +use App\Service\NewsletterList\NewsletterListService; +use App\Service\Subscriber\ConfirmationMail\ConfirmationMailListener; +use App\Service\Subscriber\ConfirmationMail\SendConfirmationMailMessage; +use App\Service\Subscriber\Event\SubscriberCreatedEvent; +use App\Service\Subscriber\Event\SubscriberUpdatedEvent; +use App\Service\Subscriber\Event\SubscriberUpdatingEvent; +use App\Service\Subscriber\ListRemoval\ListRemovalListener; +use App\Service\Subscriber\ListRemoval\ListRemovalService; use App\Service\Subscriber\SubscriberService; use App\Tests\Case\WebTestCase; -use App\Tests\Factory\NewsletterListFactory; use App\Tests\Factory\NewsletterFactory; +use App\Tests\Factory\NewsletterListFactory; use App\Tests\Factory\SubscriberFactory; +use App\Tests\Factory\SubscriberListRemovalFactory; +use App\Tests\Factory\SubscriberMetadataDefinitionFactory; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\TestWith; +use Symfony\Component\Clock\Test\ClockSensitiveTrait; + +use function Zenstruck\Foundry\Persistence\refresh; #[CoversClass(SubscriberController::class)] #[CoversClass(SubscriberService::class)] -#[CoversClass(SubscriberRepository::class)] -#[CoversClass(Subscriber::class)] +#[CoversClass(SubscriberCreatedEvent::class)] +#[CoversClass(SubscriberUpdatingEvent::class)] +#[CoversClass(SubscriberUpdatedEvent::class)] +#[CoversClass(NewsletterListService::class)] +#[CoversClass(ListRemovalListener::class)] +#[CoversClass(ListRemovalService::class)] +#[CoversClass(CreateSubscriberInput::class)] +#[CoversClass(ConfirmationMailListener::class)] class CreateSubscriberTest extends WebTestCase { - // TODO: tests for authentication + use ClockSensitiveTrait; - public function testCreateSubscriberMinimal(): void + public function test_create_subscriber_minimal(): void { - $this->mockRelayClient(); $newsletter = NewsletterFactory::createOne(); - $list1 = NewsletterListFactory::createOne(['newsletter' => $newsletter]); - $list2 = NewsletterListFactory::createOne(['newsletter' => $newsletter]); + $list = NewsletterListFactory::createOne([ + 'newsletter' => $newsletter, + 'name' => 'List 1', + ]); $response = $this->consoleApi( $newsletter, @@ -40,8 +62,10 @@ public function testCreateSubscriberMinimal(): void '/subscribers', [ 'email' => 'test@email.com', - 'list_ids' => [$list1->getId(), $list2->getId()] - ] + 'lists' => [ + 'List 1', + ], + ], ); $this->assertSame(200, $response->getStatusCode()); @@ -54,191 +78,517 @@ public function testCreateSubscriberMinimal(): void $subscriber = $repository->find($json['id']); $this->assertInstanceOf(Subscriber::class, $subscriber); $this->assertSame('test@email.com', $subscriber->getEmail()); - $this->assertSame(SubscriberStatus::PENDING, $subscriber->getStatus()); + $this->assertSame(SubscriberStatus::SUBSCRIBED, $subscriber->getStatus()); $this->assertSame('console', $subscriber->getSource()->value); - $subscriberLists = $subscriber->getLists(); - $this->assertCount(2, $subscriberLists); - $this->assertSame($list1->getId(), $subscriberLists[0]?->getId()); - $this->assertSame($list2->getId(), $subscriberLists[1]?->getId()); + $lists = $subscriber->getLists(); + $this->assertCount(1, $lists); + $this->assertSame('List 1', $lists[0]?->getName()); - $transport = $this->transport('async'); - $transport->queue()->assertCount(1); - $message = $transport->queue()->first()->getMessage(); - $this->assertInstanceOf(SubscriberCreatedMessage::class, $message); + $event = $this->getEd()->getFirstEvent(SubscriberCreatedEvent::class); + $this->assertSame($json['id'], $event->getSubscriber()->getId()); + $this->assertFalse($event->shouldSendConfirmationEmail()); } - public function testCreateSubscriberWithAllInputs(): void + public function test_create_subscriber_with_all_inputs(): void { - $this->mockRelayClient(); + $this->getEd()->setMockEvents([SubscriberCreatedEvent::class]); + $newsletter = NewsletterFactory::createOne(); - $list = NewsletterListFactory::createOne(['newsletter' => $newsletter]); + $list1 = NewsletterListFactory::createOne(['newsletter' => $newsletter]); + $list2 = NewsletterListFactory::createOne(['newsletter' => $newsletter, 'name' => 'Named List']); + + $subscribedAt = new \DateTimeImmutable('2023-06-15 10:00:00'); - $subscribedAt = new \DateTimeImmutable('2021-08-27 12:00:00'); - $unsubscribedAt = new \DateTimeImmutable('2021-08-29 12:00:00'); + SubscriberMetadataDefinitionFactory::createOne([ + 'newsletter' => $newsletter, + 'key' => 'test-key', + 'type' => SubscriberMetadataDefinitionType::TEXT, + ]); $response = $this->consoleApi( $newsletter, 'POST', '/subscribers', [ - 'email' => 'supun@hyvor.com', - 'list_ids' => [$list->getId()], - 'source' => 'form', - 'subscribe_ip' => '79.255.1.1', + 'email' => 'test@hyvor.com', + 'lists' => [$list1->getId(), 'Named List'], + 'status' => 'pending', + 'source' => 'import', + 'subscribe_ip' => '203.0.113.1', 'subscribed_at' => $subscribedAt->getTimestamp(), - 'unsubscribed_at' => $unsubscribedAt->getTimestamp(), - ] + 'metadata' => [ + 'test-key' => 'test', + ], + 'send_pending_confirmation_email' => true, + ], ); - $this->assertSame(200, $response->getStatusCode()); $json = $this->getJson(); $this->assertIsInt($json['id']); - $this->assertSame('supun@hyvor.com', $json['email']); - $this->assertSame(SubscriberStatus::PENDING->value, $json['status']); - $this->assertSame('form', $json['source']); - $this->assertSame('79.255.1.1', $json['subscribe_ip']); + $this->assertSame('test@hyvor.com', $json['email']); + $this->assertSame('pending', $json['status']); + $this->assertSame('import', $json['source']); + $this->assertSame('203.0.113.1', $json['subscribe_ip']); $this->assertSame($subscribedAt->getTimestamp(), $json['subscribed_at']); - $this->assertSame($unsubscribedAt->getTimestamp(), $json['unsubscribed_at']); + $this->assertCount(2, $json['list_ids']); + $this->assertContains($list1->getId(), $json['list_ids']); + $this->assertContains($list2->getId(), $json['list_ids']); - $repository = $this->em->getRepository(Subscriber::class); - $subscriber = $repository->find($json['id']); + $this->em->clear(); + $subscriber = $this->em->getRepository(Subscriber::class)->find($json['id']); $this->assertInstanceOf(Subscriber::class, $subscriber); - $this->assertSame('supun@hyvor.com', $subscriber->getEmail()); + $this->assertSame('test@hyvor.com', $subscriber->getEmail()); $this->assertSame(SubscriberStatus::PENDING, $subscriber->getStatus()); - $this->assertSame(SubscriberSource::FORM, $subscriber->getSource()); - $this->assertSame('79.255.1.1', $subscriber->getSubscribeIp()); - $this->assertSame('2021-08-27 12:00:00', $subscriber->getSubscribedAt()?->format('Y-m-d H:i:s')); - $this->assertSame('2021-08-29 12:00:00', $subscriber->getUnsubscribedAt()?->format('Y-m-d H:i:s')); - - $transport = $this->transport('async'); - $transport->queue()->assertCount(1); - $message = $transport->queue()->first()->getMessage(); - $this->assertInstanceOf(SubscriberCreatedMessage::class, $message); + $this->assertSame(SubscriberSource::IMPORT, $subscriber->getSource()); + $this->assertSame('203.0.113.1', $subscriber->getSubscribeIp()); + $this->assertSame('2023-06-15 10:00:00', $subscriber->getSubscribedAt()?->format('Y-m-d H:i:s')); + $this->assertSame( + [ + 'test-key' => 'test', + ], + $subscriber->getMetadata(), + ); + $listIds = $subscriber->getLists()->map(fn($l) => $l->getId())->toArray(); + $this->assertCount(2, $listIds); + $this->assertContains($list1->getId(), $listIds); + $this->assertContains($list2->getId(), $listIds); + + $event = $this->getEd()->getFirstEvent(SubscriberCreatedEvent::class); + $this->assertSame($json['id'], $event->getSubscriber()->getId()); + $this->assertTrue($event->shouldSendConfirmationEmail()); } - public function testInputValidationEmptyEmailAndListIds(): void + + public function test_creates_subscriber_fills_subscribed_at(): void { - $this->validateInput( - fn(Newsletter $newsletter) => [], + $this->mockTime('2026-01-01'); + + $newsletter = NewsletterFactory::createOne(); + + $response = $this->consoleApi( + $newsletter, + 'POST', + '/subscribers', [ - [ - 'property' => 'email', - 'message' => 'This value should not be blank.', - ], - [ - 'property' => 'list_ids', - 'message' => 'This value should not be blank.', - ] - ] + 'email' => 'test@email.com', + 'subscribed_at' => null, // even if set + ], ); + + $subscriber = $this->em->getRepository(Subscriber::class)->find($this->getJson()['id']); + $this->assertNotNull($subscriber); + $this->assertSame(SubscriberStatus::SUBSCRIBED, $subscriber->getStatus()); + $this->assertSame('2026-01-01', $subscriber->getSubscribedAt()?->format('Y-m-d')); } - public function testInputValidationInvalidEmailAndListIds(): void + public function test_updates_subscriber_all(): void { - $this->validateInput( - fn(Newsletter $newsletter) => [ - 'email' => 'not-email', - 'list_ids' => [ - null, - 1, - 'string', - ], + $newsletter = NewsletterFactory::createOne(); + $list1 = NewsletterListFactory::createOne(['newsletter' => $newsletter]); + $list2 = NewsletterListFactory::createOne(['newsletter' => $newsletter]); + + SubscriberMetadataDefinitionFactory::createOne([ + 'newsletter' => $newsletter, + 'key' => 'a', + ]); + + $subscriber = SubscriberFactory::createOne([ + 'email' => 'supun@hyvor.com', + 'newsletter' => $newsletter, + 'lists' => [ + $list1, ], + 'status' => SubscriberStatus::PENDING, + 'source' => SubscriberSource::FORM, + 'subscribe_ip' => '1.2.3.4', + 'subscribed_at' => new \DateTimeImmutable('2026-01-02'), + ]); + + $this->consoleApi( + $newsletter, + 'POST', + '/subscribers', [ - [ - 'property' => 'email', - 'message' => 'This value is not a valid email address.', - ], - [ - 'property' => 'list_ids[0]', - 'message' => 'This value should not be blank.', - ], - [ - 'property' => 'list_ids[2]', - 'message' => 'This value should be of type int.', + 'email' => 'supun@hyvor.com', + 'lists' => [$list2->getId()], // merge + 'status' => 'subscribed', + 'source' => 'console', + 'subscribe_ip' => '2.3.4.5', + 'subscribed_at' => new \DateTimeImmutable('2026-01-01')->getTimestamp(), + 'metadata' => [ + 'a' => 'b', ], - ] + ], ); + + $this->assertResponseIsSuccessful(); + + refresh($subscriber); + + $this->assertSame(SubscriberStatus::SUBSCRIBED, $subscriber->getStatus()); + $this->assertSame(SubscriberSource::CONSOLE, $subscriber->getSource()); + $this->assertSame('2.3.4.5', $subscriber->getSubscribeIp()); + $this->assertSame('2026-01-01', $subscriber->getSubscribedAt()?->format('Y-m-d')); + $this->assertSame([ + 'a' => 'b', + ], $subscriber->getMetadata()); + + $lists = $subscriber->getLists(); + $this->assertCount(2, $lists); + + $listIds = $lists->map(fn($l) => $l->getId())->toArray(); + $this->assertContains($list1->getId(), $listIds); + $this->assertContains($list2->getId(), $listIds); } - public function testInputValidationEmailTooLong(): void + public function test_lists_strategy_overwrite(): void { - $this->validateInput( - fn(Newsletter $newsletter) => [ - 'email' => str_repeat('a', 256) . '@hyvor.com', - 'list_ids' => [1], - ], - [ - [ - 'property' => 'email', - 'message' => 'This value is too long. It should have 255 characters or less.', - ], - ] - ); + $newsletter = NewsletterFactory::createOne(); + $list1 = NewsletterListFactory::createOne(['newsletter' => $newsletter]); + $list2 = NewsletterListFactory::createOne(['newsletter' => $newsletter]); + + $subscriber = SubscriberFactory::createOne([ + 'newsletter' => $newsletter, + 'lists' => [$list1], + ]); + + $this->consoleApi($newsletter, 'POST', '/subscribers', [ + 'email' => $subscriber->getEmail(), + 'lists' => [$list2->getId()], + 'lists_strategy' => 'overwrite', + ]); + + $this->assertResponseIsSuccessful(); + + refresh($subscriber); + $listIds = $subscriber->getLists()->map(fn($l) => $l->getId())->toArray(); + $this->assertCount(1, $listIds); + $this->assertContains($list2->getId(), $listIds); } - public function testInputValidationOptionalValues(): void + public function test_lists_strategy_remove(): void { - $this->validateInput( - fn(Newsletter $newsletter) => [ - 'email' => 'supun@hyvor.com', - 'list_ids' => [1], - 'source' => 'invalid-source', - 'subscribe_ip' => '127.0.0.1', - 'subscribed_at' => 'invalid-date', - 'unsubscribed_at' => 'invalid-date', - ], - [ - [ - 'property' => 'source', - 'message' => 'This value should be of type int|string.', - ], - [ - 'property' => 'subscribed_at', - 'message' => 'This value should be of type int|null.', - ], - [ - 'property' => 'unsubscribed_at', - 'message' => 'This value should be of type int|null.', - ], - ] - ); + $newsletter = NewsletterFactory::createOne(); + $list1 = NewsletterListFactory::createOne(['newsletter' => $newsletter]); + $list2 = NewsletterListFactory::createOne(['newsletter' => $newsletter]); + + $subscriber = SubscriberFactory::createOne([ + 'newsletter' => $newsletter, + 'lists' => [$list1, $list2], + ]); + + $this->consoleApi($newsletter, 'POST', '/subscribers', [ + 'email' => $subscriber->getEmail(), + 'lists' => [$list1->getId()], + 'lists_strategy' => 'remove', + ]); + + $this->assertResponseIsSuccessful(); + + refresh($subscriber); + $listIds = $subscriber->getLists()->map(fn($l) => $l->getId())->toArray(); + $this->assertCount(1, $listIds); + $this->assertContains($list2->getId(), $listIds); } - #[TestWith(['not a valid ip'])] - #[TestWith(['127.0.0.1'])] // private ip - #[TestWith(['::1'])] // localhost - #[TestWith(['169.254.255.255'])] // reserved ip - public function testValidatesIp( - string $ip - ): void + public function test_metadata_strategy_merge(): void { - $this->validateInput( - fn(Newsletter $newsletter) => [ - 'email' => 'supun@hyvor.com', - 'list_ids' => [1], - 'subscribe_ip' => $ip, - ], - [ - [ - 'property' => 'subscribe_ip', - 'message' => 'This value is not a valid IP address.', - ], - ] + $newsletter = NewsletterFactory::createOne(); + + foreach (['a', 'b', 'c'] as $key) { + SubscriberMetadataDefinitionFactory::createOne(['newsletter' => $newsletter, 'key' => $key]); + } + + $subscriber = SubscriberFactory::createOne([ + 'newsletter' => $newsletter, + 'metadata' => ['a' => '1', 'b' => '2'], + ]); + + $this->consoleApi($newsletter, 'POST', '/subscribers', [ + 'email' => $subscriber->getEmail(), + 'metadata' => ['b' => 'updated', 'c' => '3'], + 'metadata_strategy' => 'merge', + ]); + + $this->assertResponseIsSuccessful(); + + refresh($subscriber); + $this->assertSame(['a' => '1', 'b' => 'updated', 'c' => '3'], $subscriber->getMetadata()); + } + + public function test_metadata_strategy_overwrite(): void + { + $newsletter = NewsletterFactory::createOne(); + + foreach (['a', 'b', 'c'] as $key) { + SubscriberMetadataDefinitionFactory::createOne(['newsletter' => $newsletter, 'key' => $key]); + } + + $subscriber = SubscriberFactory::createOne([ + 'newsletter' => $newsletter, + 'metadata' => ['a' => '1', 'b' => '2'], + ]); + + $this->consoleApi($newsletter, 'POST', '/subscribers', [ + 'email' => $subscriber->getEmail(), + 'metadata' => ['c' => '3'], + 'metadata_strategy' => 'overwrite', + ]); + + $this->assertResponseIsSuccessful(); + + refresh($subscriber); + $this->assertSame(['c' => '3'], $subscriber->getMetadata()); + } + + public function test_validates_metadata_definition_exists(): void + { + $newsletter = NewsletterFactory::createOne(); + + $this->consoleApi($newsletter, 'POST', '/subscribers', [ + 'email' => 'test@email.com', + 'metadata' => ['nonexistent-key' => 'value'], + ]); + + $this->assertResponseFailed(422, 'nonexistent-key'); + } + + + #[TestWith([ListRemovalReason::UNSUBSCRIBE])] + #[TestWith([ListRemovalReason::BOUNCE])] + public function test_records_list_removal(ListRemovalReason $reason): void + { + $newsletter = NewsletterFactory::createOne(); + $list1 = NewsletterListFactory::createOne(['newsletter' => $newsletter]); + $list2 = NewsletterListFactory::createOne(['newsletter' => $newsletter]); + + $subscriber = SubscriberFactory::createOne([ + 'newsletter' => $newsletter, + 'lists' => [$list1, $list2], + ]); + + $this->consoleApi($newsletter, 'POST', '/subscribers', [ + 'email' => $subscriber->getEmail(), + 'lists' => [], + 'lists_strategy' => 'overwrite', + 'list_removal_reason' => $reason->value, + ]); + + $this->assertResponseIsSuccessful(); + + $repo = $this->em->getRepository(SubscriberListRemoval::class); + foreach ([$list1, $list2] as $list) { + $record = $repo->findOneBy(['list' => $list, 'subscriber' => $subscriber]); + $this->assertInstanceOf(SubscriberListRemoval::class, $record); + $this->assertSame($reason, $record->getReason()); + } + } + + public function test_updates_list_removal(): void + { + $newsletter = NewsletterFactory::createOne(); + $list = NewsletterListFactory::createOne(['newsletter' => $newsletter]); + + $subscriber = SubscriberFactory::createOne([ + 'newsletter' => $newsletter, + 'lists' => [$list], + ]); + + $removal = SubscriberListRemovalFactory::createOne([ + 'subscriber' => $subscriber, + 'list' => $list, + 'reason' => ListRemovalReason::OTHER, + ]); + + $this->consoleApi($newsletter, 'POST', '/subscribers', [ + 'email' => $subscriber->getEmail(), + 'lists' => [], + 'lists_strategy' => 'overwrite', + 'list_removal_reason' => 'bounce', + ]); + + $this->assertResponseIsSuccessful(); + + $this->em->clear(); + + $records = $this->em->getRepository(SubscriberListRemoval::class)->findAll(); + $this->assertCount(1, $records); + $this->assertSame($removal->getId(), $records[0]->getId()); + $this->assertSame(ListRemovalReason::BOUNCE, $records[0]->getReason()); + $this->assertSame($subscriber->getId(), $records[0]->getSubscriber()->getId()); + $this->assertSame($list->getId(), $records[0]->getList()->getId()); + } + + public function test_list_removal_make_sure_adding_lists_is_not_recorded(): void + { + $newsletter = NewsletterFactory::createOne(); + $list = NewsletterListFactory::createOne(['newsletter' => $newsletter]); + + $subscriber = SubscriberFactory::createOne(['newsletter' => $newsletter]); + + $this->consoleApi($newsletter, 'POST', '/subscribers', [ + 'email' => $subscriber->getEmail(), + 'lists' => [$list->getId()], + 'lists_strategy' => 'overwrite', + ]); + + $this->assertResponseIsSuccessful(); + + $records = $this->em->getRepository(SubscriberListRemoval::class)->findBy([ + 'subscriber' => $subscriber, + ]); + $this->assertCount(0, $records); + } + + public function test_list_removal_with_strategy_remove(): void + { + $newsletter = NewsletterFactory::createOne(); + $list1 = NewsletterListFactory::createOne(['newsletter' => $newsletter]); + $list2 = NewsletterListFactory::createOne(['newsletter' => $newsletter]); + + $subscriber = SubscriberFactory::createOne([ + 'newsletter' => $newsletter, + 'lists' => [$list1, $list2], + ]); + + $this->consoleApi($newsletter, 'POST', '/subscribers', [ + 'email' => $subscriber->getEmail(), + 'lists' => [$list1->getId()], + 'lists_strategy' => 'remove', + 'list_removal_reason' => 'other', + ]); + + $this->assertResponseIsSuccessful(); + + refresh($subscriber); + $this->assertCount(1, $subscriber->getLists()); + $this->assertSame($list2->getId(), $subscriber->getLists()[0]?->getId()); + + $record = $this->em->getRepository(SubscriberListRemoval::class)->findOneBy([ + 'list' => $list1, + 'subscriber' => $subscriber, + ]); + $this->assertInstanceOf(SubscriberListRemoval::class, $record); + $this->assertSame(ListRemovalReason::OTHER, $record->getReason()); + } + + #[TestWith([ListRemovalReason::UNSUBSCRIBE])] + #[TestWith([ListRemovalReason::BOUNCE])] + public function test_list_skips_if_previously_removed(ListRemovalReason $reason): void + { + $newsletter = NewsletterFactory::createOne(); + $list = NewsletterListFactory::createOne(['newsletter' => $newsletter]); + $subscriber = SubscriberFactory::createOne(['newsletter' => $newsletter]); + + SubscriberListRemovalFactory::createOne([ + 'subscriber' => $subscriber, + 'list' => $list, + 'reason' => $reason, + ]); + + $this->consoleApi($newsletter, 'POST', '/subscribers', [ + 'email' => $subscriber->getEmail(), + 'lists' => [$list->getId()], + ]); + + $this->assertResponseIsSuccessful(); + + refresh($subscriber); + $this->assertCount(0, $subscriber->getLists()); + } + + public function test_list_does_not_skip_other(): void + { + $newsletter = NewsletterFactory::createOne(); + $list = NewsletterListFactory::createOne(['newsletter' => $newsletter]); + $subscriber = SubscriberFactory::createOne(['newsletter' => $newsletter]); + + SubscriberListRemovalFactory::createOne([ + 'subscriber' => $subscriber, + 'list' => $list, + 'reason' => ListRemovalReason::OTHER, + ]); + + $this->consoleApi($newsletter, 'POST', '/subscribers', [ + 'email' => $subscriber->getEmail(), + 'lists' => [$list->getId()], + ]); + + $this->assertResponseIsSuccessful(); + + refresh($subscriber); + $this->assertCount(1, $subscriber->getLists()); + $this->assertSame($list->getId(), $subscriber->getLists()[0]?->getId()); + } + + public function test_list_can_bypass_removal_and_removes_removal(): void + { + $newsletter = NewsletterFactory::createOne(); + $list = NewsletterListFactory::createOne(['newsletter' => $newsletter]); + $subscriber = SubscriberFactory::createOne(['newsletter' => $newsletter]); + + $removal = SubscriberListRemovalFactory::createOne([ + 'subscriber' => $subscriber, + 'list' => $list, + 'reason' => ListRemovalReason::UNSUBSCRIBE, + ]); + $removalId = $removal->getId(); + + $otherRemoval = SubscriberListRemovalFactory::createOne([ + 'subscriber' => $subscriber, + 'list' => NewsletterListFactory::createOne(['newsletter' => $newsletter]), + 'reason' => ListRemovalReason::UNSUBSCRIBE, + ]); + + $this->consoleApi($newsletter, 'POST', '/subscribers', [ + 'email' => $subscriber->getEmail(), + 'lists' => [$list->getId()], + 'list_skip_resubscribe_on' => [], + ]); + $this->em->clear(); + + $this->assertResponseIsSuccessful(); + + refresh($subscriber); + $this->assertCount(1, $subscriber->getLists()); + $this->assertSame($list->getId(), $subscriber->getLists()[0]?->getId()); + + $record = $this->em->getRepository(SubscriberListRemoval::class)->find($removalId); + $this->assertNull($record); + + $recordOther = $this->em->getRepository(SubscriberListRemoval::class)->find($otherRemoval->getId()); + $this->assertInstanceOf(SubscriberListRemoval::class, $recordOther); + } + + public function test_updates_with_confirmation_email_true(): void + { + $newsletter = NewsletterFactory::createOne(); + $subscriber = SubscriberFactory::createOne( + ['newsletter' => $newsletter, 'status' => SubscriberStatus::SUBSCRIBED], ); + + $this->consoleApi($newsletter, 'POST', '/subscribers', [ + 'email' => $subscriber->getEmail(), + 'status' => 'pending', + 'send_pending_confirmation_email' => true, + ]); + + $this->assertResponseIsSuccessful(); + + $event = $this->getEd()->getFirstEvent(SubscriberUpdatedEvent::class); + $this->assertTrue($event->shouldSendConfirmationEmail()); + + $transport = $this->transport(MessageTransport::ASYNC); + $transport->queue()->assertContains(SendConfirmationMailMessage::class); + $message = $transport->queue()->messages(SendConfirmationMailMessage::class)[0]; + $this->assertSame($subscriber->getId(), $message->getSubscriberId()); } - /** - * @param callable(Newsletter): array $input - * @param array $violations - * @return void - */ - private function validateInput( - callable $input, - array $violations - ): void + #[TestWith([9999])] + #[TestWith(['list'])] + public function test_list_not_found(int|string $val): void { $newsletter = NewsletterFactory::createOne(); @@ -246,60 +596,112 @@ private function validateInput( $newsletter, 'POST', '/subscribers', - $input($newsletter), + [ + 'email' => 'test@email.com', + 'lists' => [$val], + ], ); - $this->assertSame(422, $response->getStatusCode()); - $this->assertHasViolation($violations[0]['property'], $violations[0]['message']); + $this->assertResponseFailed( + 422, + 'Lists with ' . (is_int($val) ? 'IDs' : 'names') . ' ' . $val . ' not found', + ); } - public function testCreateSubscriberInvalidList(): void + /** + * @param array $input + */ + #[TestWith( + [ + [ + 'email' => '', + ], + 'email: This value should not be blank', + ], + 'empty email' + )] + #[TestWith( + [ + ['email' => 'not-an-email'], + 'email: This value is not a valid email address', + ], + 'invalid email' + )] + #[TestWith( + [ + ['email' => 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa@h.co'], + 'email: This value is too long', + ], + 'too long email' + )] + #[TestWith( + [ + ['email' => 'test@email.com', 'lists' => 'not-array'], + 'lists', + ], + 'string for lists' + )] + #[TestWith( + [ + ['email' => 'test@email.com', 'status' => 'invalid-status'], + 'status', + ], + 'invalid status' + )] + #[TestWith( + [ + ['email' => 'test@email.com', 'subscribe_ip' => 'not-an-ip'], + 'subscribe_ip: This value is not a valid IP address', + ], + 'invalid IP address' + )] + #[TestWith( + [ + ['email' => 'test@email.com', 'list_skip_resubscribe_on' => ['invalid-reason']], + 'not a valid choice', + ], + 'invalid list_skip_resubscribe_on value' + )] + public function test_validation(array $input, string $message): void { - $newsletter1 = NewsletterFactory::createOne(); - $newsletter2 = NewsletterFactory::createOne(); - - $newsletterList1 = NewsletterListFactory::createOne(['newsletter' => $newsletter2]); + $newsletter = NewsletterFactory::createOne(); $response = $this->consoleApi( - $newsletter1, + $newsletter, 'POST', '/subscribers', - [ - 'email' => 'supun@hyvor.com', - 'list_ids' => [$newsletterList1->getId()] - ] + $input, ); - $this->assertSame(422, $response->getStatusCode()); - $this->assertSame('List with id ' . $newsletterList1->getId() . ' not found', $this->getJson()['message']); + $this->assertResponseFailed( + 422, + $message, + ); } - public function testCreateSubscriberDuplicateEmail(): void - { + #[TestWith(['not a valid ip'])] + #[TestWith(['127.0.0.1'])] // private ip + #[TestWith(['::1'])] // localhost + #[TestWith(['169.254.255.255'])] // reserved ip + public function test_validates_ip( + string $ip, + ): void { $newsletter = NewsletterFactory::createOne(); - $list = NewsletterListFactory::createOne(['newsletter' => $newsletter]); - $subscriber = SubscriberFactory::createOne( - [ - 'newsletter' => $newsletter, - 'email' => 'thibault@hyvor.com', - 'lists' => [$list], - ] - ); $response = $this->consoleApi( $newsletter, 'POST', '/subscribers', [ - 'email' => 'thibault@hyvor.com', - 'list_ids' => [$list->getId()], - ] + 'email' => 'supun@hyvor.com', + 'lists' => [], + 'subscribe_ip' => $ip, + ], ); - $this->assertSame(422, $response->getStatusCode()); - $this->assertSame( - 'Subscriber with email ' . $subscriber->getEmail() . ' already exists', - $this->getJson()['message'] + $this->assertResponseFailed( + 422, + 'This value is not a valid IP address.', ); } diff --git a/backend/tests/Api/Console/Subscriber/GetSubscribersTest.php b/backend/tests/Api/Console/Subscriber/GetSubscribersTest.php index 6cbe8034..9ec0f458 100644 --- a/backend/tests/Api/Console/Subscriber/GetSubscribersTest.php +++ b/backend/tests/Api/Console/Subscriber/GetSubscribersTest.php @@ -23,8 +23,6 @@ class GetSubscribersTest extends WebTestCase { - // TODO: tests for authentication - public function testListSubscribersNonEmpty(): void { $newsletter = NewsletterFactory::createOne(); @@ -48,7 +46,7 @@ public function testListSubscribersNonEmpty(): void $response = $this->consoleApi( $newsletter, 'GET', - '/subscribers' + '/subscribers', ); $this->assertSame(200, $response->getStatusCode()); @@ -81,7 +79,7 @@ public function testListSubscribersPagination(): void $response = $this->consoleApi( $newsletter, 'GET', - '/subscribers?limit=2&offset=1' + '/subscribers?limit=2&offset=1', ); $this->assertSame(200, $response->getStatusCode()); @@ -101,7 +99,7 @@ public function testListSubscribersEmpty(): void $response = $this->consoleApi( $newsletter, 'GET', - '/subscribers' + '/subscribers', ); $this->assertSame(200, $response->getStatusCode()); @@ -109,8 +107,8 @@ public function testListSubscribersEmpty(): void $this->assertCount(0, $json); } - #[TestWith([SubscriberStatus::SUBSCRIBED, SubscriberStatus::UNSUBSCRIBED])] - #[TestWith([SubscriberStatus::UNSUBSCRIBED, SubscriberStatus::SUBSCRIBED])] + #[TestWith([SubscriberStatus::SUBSCRIBED, SubscriberStatus::PENDING])] + #[TestWith([SubscriberStatus::PENDING, SubscriberStatus::SUBSCRIBED])] public function testListSubscribersByStatus(SubscriberStatus $status, SubscriberStatus $oppositeStatus): void { $newsletter = NewsletterFactory::createOne(); @@ -133,7 +131,7 @@ public function testListSubscribersByStatus(SubscriberStatus $status, Subscriber $response = $this->consoleApi( $newsletter, 'GET', - "/subscribers?status={$status->value}" + "/subscribers?status={$status->value}", ); $this->assertSame(200, $response->getStatusCode()); @@ -176,7 +174,7 @@ public function test_list_subscribers_email_search(): void $response = $this->consoleApi( $newsletter, 'GET', - "/subscribers?search=thibault" + "/subscribers?search=thibault", ); $this->assertSame(200, $response->getStatusCode()); @@ -194,15 +192,15 @@ public function test_list_subscribers_list_search(): void $list1 = NewsletterListFactory::createOne( [ 'newsletter' => $newsletter, - 'name' => 'list_1' - ] + 'name' => 'list_1', + ], ); $list2 = NewsletterListFactory::createOne( [ 'newsletter' => $newsletter, - 'name' => 'list_2' - ] + 'name' => 'list_2', + ], ); $subscriber1 = SubscriberFactory::createOne([ @@ -222,7 +220,7 @@ public function test_list_subscribers_list_search(): void $response = $this->consoleApi( $newsletter, 'GET', - "/subscribers?list_id={$list1->getId()}" + "/subscribers?list_id={$list1->getId()}", ); $this->assertSame(200, $response->getStatusCode()); diff --git a/backend/tests/Api/Console/Subscriber/UpdateSubscriberTest.php b/backend/tests/Api/Console/Subscriber/UpdateSubscriberTest.php deleted file mode 100644 index 6056b32f..00000000 --- a/backend/tests/Api/Console/Subscriber/UpdateSubscriberTest.php +++ /dev/null @@ -1,294 +0,0 @@ - $newsletter]); - $list2 = NewsletterListFactory::createOne(['newsletter' => $newsletter]); - - $subscriber = SubscriberFactory::createOne([ - 'newsletter' => $newsletter, - 'lists' => [$list1], - 'status' => SubscriberStatus::UNSUBSCRIBED, - ]); - - $response = $this->consoleApi( - $newsletter, - 'PATCH', - '/subscribers/' . $subscriber->getId(), - [ - 'email' => 'new@email.com', - 'list_ids' => [$list1->getId(), $list2->getId()], - 'status' => 'subscribed', - ] - ); - - $this->assertSame(200, $response->getStatusCode()); - $json = $this->getJson(); - $this->assertSame('new@email.com', $json['email']); - - $repository = $this->em->getRepository(Subscriber::class); - $subscriber = $repository->find($json['id']); - $this->assertInstanceOf(Subscriber::class, $subscriber); - $this->assertSame('new@email.com', $subscriber->getEmail()); - $this->assertSame('subscribed', $subscriber->getStatus()->value); - $this->assertCount(2, $subscriber->getLists()); - $this->assertContains($list1->_real(), $subscriber->getLists()); - $this->assertContains($list2->_real(), $subscriber->getLists()); - $this->assertSame('2025-02-21 00:00:00', $subscriber->getUpdatedAt()->format('Y-m-d H:i:s')); - } - - public function testCannotUpdateSubscriberToEmptyList(): void - { - $this->validateInput( - fn(Newsletter $newsletter) => [ - 'email' => 'mybademail', - 'list_ids' => [], - ], - [ - [ - 'property' => 'email', - 'message' => 'This value is not a valid email address.', - ], - [ - 'property' => 'list_ids', - 'message' => 'There should be at least one list.', - ], - ] - ); - } - - public function testValidatesStatus(): void - { - $this->validateInput( - fn(Newsletter $newsletter) => [ - 'status' => 'invalid', - ], - [ - [ - 'property' => 'status', - 'message' => 'This value should be of type int|string.', - ], - ] - ); - } - - /** - * @param callable(Newsletter): array $input - * @param array $violations - * @return void - */ - private function validateInput( - callable $input, - array $violations - ): void - { - $newsletter = NewsletterFactory::createOne(); - $subscriber = SubscriberFactory::createOne(['newsletter' => $newsletter]); - - $response = $this->consoleApi( - $newsletter, - 'PATCH', - '/subscribers/' . $subscriber->getId(), - $input($newsletter), - ); - - $this->assertSame(422, $response->getStatusCode()); - $this->assertHasViolation($violations[0]['property'], $violations[0]['message']); - } - - public function testUpdateSubscriberInvalidListId(): void - { - $newsletter1 = NewsletterFactory::createOne(); - $newsletter2 = NewsletterFactory::createOne(); - - $newsletterList = NewsletterListFactory::createOne(['newsletter' => $newsletter2]); - $subscriber = SubscriberFactory::createOne(['newsletter' => $newsletter1]); - - $response = $this->consoleApi( - $newsletter1, - 'PATCH', - '/subscribers/' . $subscriber->getId(), - [ - 'list_ids' => [$newsletterList->getId()], - ] - ); - - $this->assertSame(422, $response->getStatusCode()); - $json = $this->getJson(); - - $this->assertSame( - 'List with id ' . $newsletterList->getId() . ' not found', - $json['message'] - ); - } - - public function testCannotUpdateSubscriberOfOtherNewsletter(): void - { - $newsletter1 = NewsletterFactory::createOne(); - $newsletter2 = NewsletterFactory::createOne(); - - $newsletterList = NewsletterListFactory::createOne(['newsletter' => $newsletter1]); - $subscriber = SubscriberFactory::createOne([ - 'newsletter' => $newsletter1, - 'email' => 'ishini@hyvor.com', - 'lists' => [$newsletterList], - ]); - - $response = $this->consoleApi( - $newsletter2, - 'PATCH', - '/subscribers/' . $subscriber->getId(), - [ - 'email' => 'supun@hyvor.com', - ] - ); - - $this->assertSame(403, $response->getStatusCode()); - $this->assertSame('Entity does not belong to the newsletter', $this->getJson()['message']); - - $repository = $this->em->getRepository(Subscriber::class); - $subscriber = $repository->find($subscriber->getId()); - $this->assertSame('ishini@hyvor.com', $subscriber?->getEmail()); - } - - public function testUpdateSubscriberWithTakenEmail(): void - { - $newsletter = NewsletterFactory::createOne(); - $subscriber1 = SubscriberFactory::createOne(['newsletter' => $newsletter, 'email' => 'thibault@hyvor.com']); - $subscriber2 = SubscriberFactory::createOne(['newsletter' => $newsletter, 'email' => 'supun@hyvor.com']); - - $response = $this->consoleApi( - $newsletter, - 'PATCH', - '/subscribers/' . $subscriber1->getId(), - [ - 'email' => 'supun@hyvor.com', - ] - ); - - $this->assertSame(422, $response->getStatusCode()); - $this->assertSame( - 'Subscriber with email ' . $subscriber2->getEmail() . ' already exists', - $this->getJson()['message'] - ); - } - - public function test_update_subscriber_metadata(): void - { - $newsletter = NewsletterFactory::createOne(); - $list1 = NewsletterListFactory::createOne(['newsletter' => $newsletter]); - $list2 = NewsletterListFactory::createOne(['newsletter' => $newsletter]); - - $metadata = SubscriberMetadataDefinitionFactory::createOne([ - 'key' => 'name', - 'name' => 'Name', - 'newsletter' => $newsletter, - ]); - - $subscriber = SubscriberFactory::createOne([ - 'newsletter' => $newsletter, - 'lists' => [$list1], - 'status' => SubscriberStatus::UNSUBSCRIBED, - ]); - - $metaUpdate = [ - 'name' => 'Thibault', - ]; - - $response = $this->consoleApi( - $newsletter, - 'PATCH', - '/subscribers/' . $subscriber->getId(), - [ - 'email' => 'new@email.com', - 'list_ids' => [$list1->getId(), $list2->getId()], - 'status' => 'subscribed', - 'metadata' => $metaUpdate - ] - ); - - $this->assertSame(200, $response->getStatusCode()); - - $subscriber = $this->em->getRepository(Subscriber::class)->find($subscriber->getId()); - $this->assertInstanceOf(Subscriber::class, $subscriber); - $this->assertSame($subscriber->getMetadata(), $metaUpdate); - } - - public function test_update_subscriber_metadata_invalid_name(): void - { - $newsletter = NewsletterFactory::createOne(); - $list1 = NewsletterListFactory::createOne(['newsletter' => $newsletter]); - $list2 = NewsletterListFactory::createOne(['newsletter' => $newsletter]); - - $subscriber = SubscriberFactory::createOne([ - 'newsletter' => $newsletter, - 'lists' => [$list1], - 'status' => SubscriberStatus::UNSUBSCRIBED, - ]); - - $metadata = SubscriberMetadataDefinitionFactory::createOne([ - 'key' => 'age', - 'name' => 'Age', - 'newsletter' => $newsletter, - ]); - - - $metaUpdate = [ - 'name' => 'Thibault', - ]; - - $response = $this->consoleApi( - $newsletter, - 'PATCH', - '/subscribers/' . $subscriber->getId(), - [ - 'metadata' => $metaUpdate, - ] - ); - - $this->assertSame(422, $response->getStatusCode()); - $json = $this->getJson(); - $this->assertSame( - 'Metadata definition with key name not found', - $json['message'] - ); - } - - - public function test_update_subscriber_metadata_invalid_type(): void - { - // TODO: Implement this test when other metadata types are implemented - $this->markTestSkipped(); - } -} diff --git a/backend/tests/Api/Sudo/SubscriberImports/GetImportingSubscribersTest.php b/backend/tests/Api/Sudo/SubscriberImports/GetImportingSubscribersTest.php index 71cb6667..a55a7841 100644 --- a/backend/tests/Api/Sudo/SubscriberImports/GetImportingSubscribersTest.php +++ b/backend/tests/Api/Sudo/SubscriberImports/GetImportingSubscribersTest.php @@ -31,7 +31,7 @@ private function uploadImportFile(): SubscriberImport /** @var MediaService $mediaService */ $mediaService = $this->container->get(MediaService::class); $media = $mediaService->upload( - $newsletter->_real(), + $newsletter, MediaFolder::IMPORT, $file, ); @@ -42,7 +42,7 @@ private function uploadImportFile(): SubscriberImport 'status' => SubscriberImportStatus::PENDING_APPROVAL, 'fields' => [ 'email' => 'email', - 'lists' => 'lists' + 'lists' => 'lists', ], 'csv_fields' => ['email', 'lists', 'extra_col_1', 'extra_col_2'], @@ -102,4 +102,4 @@ public function test_get_importing_subscribers_with_limit_and_offset(): void $this->assertArrayHasKey('email', $importingSubscriber2); $this->assertSame('doe@hyvor.com', $importingSubscriber2['email']); } -} \ No newline at end of file +} diff --git a/backend/tests/Case/WebTestCase.php b/backend/tests/Case/WebTestCase.php index 8e68ccbe..4d91571d 100644 --- a/backend/tests/Case/WebTestCase.php +++ b/backend/tests/Case/WebTestCase.php @@ -46,7 +46,7 @@ protected function setUp(): void id: 1, name: 'Fake Organization', role: 'admin', - ) + ), ); } /** @var EntityManagerInterface $em */ @@ -67,11 +67,13 @@ protected function mockRelayClient(?callable $callback = null, bool $forSystemNo { if (!$callback) { $callback = function ($method, $url, $options) use ($forSystemNotification): JsonMockResponse { - $this->assertSame('POST', $method); $this->assertStringStartsWith('https://relay.hyvor.com/api/console/', $url); $this->assertContains('Content-Type: application/json', $options['headers']); - $this->assertContains('Authorization: Bearer ' . ($forSystemNotification ? 'test-notification-relay-key' : 'test-relay-key'), $options['headers']); + $this->assertContains( + 'Authorization: Bearer ' . ($forSystemNotification ? 'test-notification-relay-key' : 'test-relay-key'), + $options['headers'], + ); if ($forSystemNotification) { $body = json_decode($options['body'], true); @@ -100,20 +102,20 @@ protected function mockRelayClient(?callable $callback = null, bool $forSystemNo */ public function consoleApi( Newsletter|int|null $newsletter, - string $method, - string $uri, - array $data = [], - array $files = [], + string $method, + string $uri, + array $data = [], + array $files = [], // only use this if $files is used. otherwise, use $data - array $parameters = [], - array $server = [], - true|array $scopes = true, - bool $useSession = false - ): Response - { + array $parameters = [], + array $server = [], + true|array $scopes = true, + bool $useSession = false, + ): Response { + $newsletterId = null; if ($newsletter instanceof Newsletter) { $newsletterId = $newsletter->getId(); - } else if ($newsletter) { + } elseif ($newsletter) { $newsletterId = $newsletter; $newsletter = NewsletterFactory::findBy(['id' => $newsletterId]); $newsletter = count($newsletter) > 0 ? $newsletter[0] : null; @@ -143,7 +145,7 @@ public function consoleApi( if ($scopes !== true) { $apiKeyFactory['scopes'] = array_map( fn(Scope|string $scope) => is_string($scope) ? $scope : $scope->value, - $scopes + $scopes, ); } ApiKeyFactory::createOne($apiKeyFactory); @@ -166,7 +168,7 @@ public function consoleApi( if ($response->getStatusCode() === 500) { throw new \Exception( 'API call failed with status code 500. ' . - 'Response: ' . $response->getContent() + 'Response: ' . $response->getContent(), ); } @@ -180,10 +182,9 @@ public function consoleApi( public function publicApi( string $method, string $uri, - array $data = [], - array $headers = [], - ): Response - { + array $data = [], + array $headers = [], + ): Response { $server = [ 'CONTENT_TYPE' => 'application/json', ]; @@ -196,7 +197,7 @@ public function publicApi( $method, '/api/public' . $uri, server: $server, - content: (string)json_encode($data) + content: (string)json_encode($data), ); return $this->client->getResponse(); } @@ -208,12 +209,11 @@ public function publicApi( public function sudoApi( string $method, string $uri, - array $data = [], - array $server = [], - ): Response - { + array $data = [], + array $server = [], + ): Response { SudoUserFactory::findOrCreate([ - 'user_id' => 1 + 'user_id' => 1, ]); $this->client->getCookieJar()->set(new Cookie('authsess', 'test-session')); @@ -232,7 +232,7 @@ public function sudoApi( if ($response->getStatusCode() === 500) { throw new \Exception( 'API call failed with status code 500. ' . - 'Response: ' . $response->getContent() + 'Response: ' . $response->getContent(), ); } @@ -248,7 +248,6 @@ public function getTestLogger(): TestHandler public function assertApiFailed(int $expectedStatus, string $expectedMessage): void { - $response = $this->client->getResponse(); $this->assertSame($expectedStatus, $response->getStatusCode()); @@ -256,7 +255,6 @@ public function assertApiFailed(int $expectedStatus, string $expectedMessage): v $this->assertArrayHasKey('message', $json); $this->assertIsString($json['message']); $this->assertStringContainsString($expectedMessage, $json['message']); - } diff --git a/backend/tests/Factory/ApiKeyFactory.php b/backend/tests/Factory/ApiKeyFactory.php index 85766d13..70d1405c 100644 --- a/backend/tests/Factory/ApiKeyFactory.php +++ b/backend/tests/Factory/ApiKeyFactory.php @@ -4,12 +4,12 @@ use App\Api\Console\Authorization\Scope; use App\Entity\ApiKey; -use Zenstruck\Foundry\Persistence\PersistentProxyObjectFactory; +use Zenstruck\Foundry\Persistence\PersistentObjectFactory; /** - * @extends PersistentProxyObjectFactory + * @extends PersistentObjectFactory */ -final class ApiKeyFactory extends PersistentProxyObjectFactory +final class ApiKeyFactory extends PersistentObjectFactory { public function __construct() { @@ -34,8 +34,8 @@ protected function defaults(): array 'scopes' => [ ...array_map( fn(Scope $scope) => $scope->value, - Scope::cases() - ) + Scope::cases(), + ), ], 'is_enabled' => true, 'last_accessed_at' => null, diff --git a/backend/tests/Factory/ApprovalFactory.php b/backend/tests/Factory/ApprovalFactory.php index 82c29588..39e8b5b0 100644 --- a/backend/tests/Factory/ApprovalFactory.php +++ b/backend/tests/Factory/ApprovalFactory.php @@ -4,12 +4,12 @@ use App\Entity\Approval; use App\Entity\Type\ApprovalStatus; -use Zenstruck\Foundry\Persistence\PersistentProxyObjectFactory; +use Zenstruck\Foundry\Persistence\PersistentObjectFactory; /** - * @extends PersistentProxyObjectFactory + * @extends PersistentObjectFactory */ -final class ApprovalFactory extends PersistentProxyObjectFactory +final class ApprovalFactory extends PersistentObjectFactory { public function __construct() { diff --git a/backend/tests/Factory/DomainFactory.php b/backend/tests/Factory/DomainFactory.php index dce4535e..1efbe5af 100644 --- a/backend/tests/Factory/DomainFactory.php +++ b/backend/tests/Factory/DomainFactory.php @@ -4,21 +4,19 @@ use App\Entity\Domain; use App\Entity\Type\RelayDomainStatus; -use Zenstruck\Foundry\Persistence\PersistentProxyObjectFactory; +use Zenstruck\Foundry\Persistence\PersistentObjectFactory; /** - * @extends PersistentProxyObjectFactory + * @extends PersistentObjectFactory */ -final class DomainFactory extends PersistentProxyObjectFactory +final class DomainFactory extends PersistentObjectFactory { /** * @see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#factories-as-services * * @todo inject services if required */ - public function __construct() - { - } + public function __construct() {} public static function class(): string { diff --git a/backend/tests/Factory/IssueFactory.php b/backend/tests/Factory/IssueFactory.php index b70e2acd..3da90685 100644 --- a/backend/tests/Factory/IssueFactory.php +++ b/backend/tests/Factory/IssueFactory.php @@ -5,21 +5,19 @@ use App\Entity\Issue; use App\Entity\Type\IssueStatus; use Symfony\Component\Uid\Uuid; -use Zenstruck\Foundry\Persistence\PersistentProxyObjectFactory; +use Zenstruck\Foundry\Persistence\PersistentObjectFactory; /** - * @extends PersistentProxyObjectFactory + * @extends PersistentObjectFactory */ -final class IssueFactory extends PersistentProxyObjectFactory +final class IssueFactory extends PersistentObjectFactory { /** * @see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#factories-as-services * * @todo inject services if required */ - public function __construct() - { - } + public function __construct() {} public static function class(): string { diff --git a/backend/tests/Factory/MediaFactory.php b/backend/tests/Factory/MediaFactory.php index 0abf7bdf..464680a1 100644 --- a/backend/tests/Factory/MediaFactory.php +++ b/backend/tests/Factory/MediaFactory.php @@ -4,12 +4,12 @@ use App\Entity\Media; use App\Entity\Type\MediaFolder; -use Zenstruck\Foundry\Persistence\PersistentProxyObjectFactory; +use Zenstruck\Foundry\Persistence\PersistentObjectFactory; /** - * @extends PersistentProxyObjectFactory + * @extends PersistentObjectFactory */ -final class MediaFactory extends PersistentProxyObjectFactory +final class MediaFactory extends PersistentObjectFactory { public static function class(): string diff --git a/backend/tests/Factory/NewsletterFactory.php b/backend/tests/Factory/NewsletterFactory.php index c2bdebda..dd302db7 100644 --- a/backend/tests/Factory/NewsletterFactory.php +++ b/backend/tests/Factory/NewsletterFactory.php @@ -4,21 +4,19 @@ use App\Entity\Meta\NewsletterMeta; use App\Entity\Newsletter; -use Zenstruck\Foundry\Persistence\PersistentProxyObjectFactory; +use Zenstruck\Foundry\Persistence\PersistentObjectFactory; /** - * @extends PersistentProxyObjectFactory + * @extends PersistentObjectFactory */ -final class NewsletterFactory extends PersistentProxyObjectFactory +final class NewsletterFactory extends PersistentObjectFactory { /** * @see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#factories-as-services * * @todo inject services if required */ - public function __construct() - { - } + public function __construct() {} public static function class(): string { diff --git a/backend/tests/Factory/NewsletterListFactory.php b/backend/tests/Factory/NewsletterListFactory.php index 6d94d596..5304e9b4 100644 --- a/backend/tests/Factory/NewsletterListFactory.php +++ b/backend/tests/Factory/NewsletterListFactory.php @@ -3,21 +3,19 @@ namespace App\Tests\Factory; use App\Entity\NewsletterList; -use Zenstruck\Foundry\Persistence\PersistentProxyObjectFactory; +use Zenstruck\Foundry\Persistence\PersistentObjectFactory; /** - * @extends PersistentProxyObjectFactory + * @extends PersistentObjectFactory */ -final class NewsletterListFactory extends PersistentProxyObjectFactory +final class NewsletterListFactory extends PersistentObjectFactory { /** * @see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#factories-as-services * * @todo inject services if required */ - public function __construct() - { - } + public function __construct() {} public static function class(): string { @@ -45,8 +43,7 @@ protected function defaults(): array */ protected function initialize(): static { - return $this - // ->afterInstantiate(function(NewsletterList $newsletterList): void {}) - ; + return $this// ->afterInstantiate(function(NewsletterList $newsletterList): void {}) + ; } } diff --git a/backend/tests/Factory/SendFactory.php b/backend/tests/Factory/SendFactory.php index 4fb9edfa..0d945449 100644 --- a/backend/tests/Factory/SendFactory.php +++ b/backend/tests/Factory/SendFactory.php @@ -5,21 +5,19 @@ use App\Entity\Send; use App\Entity\Type\IssueStatus; use App\Entity\Type\SendStatus; -use Zenstruck\Foundry\Persistence\PersistentProxyObjectFactory; +use Zenstruck\Foundry\Persistence\PersistentObjectFactory; /** - * @extends PersistentProxyObjectFactory + * @extends PersistentObjectFactory */ -final class SendFactory extends PersistentProxyObjectFactory +final class SendFactory extends PersistentObjectFactory { /** * @see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#factories-as-services * * @todo inject services if required */ - public function __construct() - { - } + public function __construct() {} public static function class(): string { diff --git a/backend/tests/Factory/SendingProfileFactory.php b/backend/tests/Factory/SendingProfileFactory.php index 0c128019..99411e11 100644 --- a/backend/tests/Factory/SendingProfileFactory.php +++ b/backend/tests/Factory/SendingProfileFactory.php @@ -3,21 +3,19 @@ namespace App\Tests\Factory; use App\Entity\SendingProfile; -use Zenstruck\Foundry\Persistence\PersistentProxyObjectFactory; +use Zenstruck\Foundry\Persistence\PersistentObjectFactory; /** - * @extends PersistentProxyObjectFactory + * @extends PersistentObjectFactory */ -final class SendingProfileFactory extends PersistentProxyObjectFactory +final class SendingProfileFactory extends PersistentObjectFactory { /** * @see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#factories-as-services * * @todo inject services if required */ - public function __construct() - { - } + public function __construct() {} public static function class(): string { diff --git a/backend/tests/Factory/SubscriberExportFactory.php b/backend/tests/Factory/SubscriberExportFactory.php index 3317d34b..27326be0 100644 --- a/backend/tests/Factory/SubscriberExportFactory.php +++ b/backend/tests/Factory/SubscriberExportFactory.php @@ -4,21 +4,19 @@ use App\Entity\SubscriberExport; use App\Entity\Type\SubscriberExportStatus; -use Zenstruck\Foundry\Persistence\PersistentProxyObjectFactory; +use Zenstruck\Foundry\Persistence\PersistentObjectFactory; /** - * @extends PersistentProxyObjectFactory + * @extends PersistentObjectFactory */ -final class SubscriberExportFactory extends PersistentProxyObjectFactory +final class SubscriberExportFactory extends PersistentObjectFactory { /** * @see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#factories-as-services * * @todo inject services if required */ - public function __construct() - { - } + public function __construct() {} public static function class(): string { @@ -45,8 +43,7 @@ protected function defaults(): array */ protected function initialize(): static { - return $this - // ->afterInstantiate(function(Domain $domain): void {}) - ; + return $this// ->afterInstantiate(function(Domain $domain): void {}) + ; } } diff --git a/backend/tests/Factory/SubscriberFactory.php b/backend/tests/Factory/SubscriberFactory.php index 2d8f36ee..809f194a 100644 --- a/backend/tests/Factory/SubscriberFactory.php +++ b/backend/tests/Factory/SubscriberFactory.php @@ -5,21 +5,19 @@ use App\Entity\Subscriber; use App\Entity\Type\SubscriberSource; use App\Entity\Type\SubscriberStatus; -use Zenstruck\Foundry\Persistence\PersistentProxyObjectFactory; +use Zenstruck\Foundry\Persistence\PersistentObjectFactory; /** - * @extends PersistentProxyObjectFactory + * @extends PersistentObjectFactory */ -final class SubscriberFactory extends PersistentProxyObjectFactory +final class SubscriberFactory extends PersistentObjectFactory { /** * @see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#factories-as-services * * @todo inject services if required */ - public function __construct() - { - } + public function __construct() {} public static function class(): string { @@ -44,7 +42,6 @@ protected function defaults(): array 'subscribed_at' => \DateTimeImmutable::createFromMutable(self::faker()->dateTime()), 'opt_in_at' => \DateTimeImmutable::createFromMutable(self::faker()->dateTime()), 'unsubscribe_reason' => self::faker()->text(255), - 'unsubscribed_at' => \DateTimeImmutable::createFromMutable(self::faker()->dateTime()), 'updated_at' => \DateTimeImmutable::createFromMutable(self::faker()->dateTime()), ]; } diff --git a/backend/tests/Factory/SubscriberImportFactory.php b/backend/tests/Factory/SubscriberImportFactory.php index 3beabedd..febd31a1 100644 --- a/backend/tests/Factory/SubscriberImportFactory.php +++ b/backend/tests/Factory/SubscriberImportFactory.php @@ -4,21 +4,19 @@ use App\Entity\SubscriberImport; use App\Entity\Type\SubscriberImportStatus; -use Zenstruck\Foundry\Persistence\PersistentProxyObjectFactory; +use Zenstruck\Foundry\Persistence\PersistentObjectFactory; /** - * @extends PersistentProxyObjectFactory + * @extends PersistentObjectFactory */ -final class SubscriberImportFactory extends PersistentProxyObjectFactory +final class SubscriberImportFactory extends PersistentObjectFactory { /** * @see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#factories-as-services * * @todo inject services if required */ - public function __construct() - { - } + public function __construct() {} public static function class(): string { diff --git a/backend/tests/Factory/SubscriberListRemovalFactory.php b/backend/tests/Factory/SubscriberListRemovalFactory.php new file mode 100644 index 00000000..f56a9705 --- /dev/null +++ b/backend/tests/Factory/SubscriberListRemovalFactory.php @@ -0,0 +1,38 @@ + + */ +final class SubscriberListRemovalFactory extends PersistentObjectFactory +{ + public function __construct() {} + + public static function class(): string + { + return SubscriberListRemoval::class; + } + + /** + * @return array + */ + protected function defaults(): array + { + return [ + 'list' => NewsletterListFactory::new(), + 'subscriber' => SubscriberFactory::new(), + 'reason' => self::faker()->randomElement(ListRemovalReason::cases()), + 'created_at' => \DateTimeImmutable::createFromMutable(self::faker()->dateTime()), + ]; + } + + protected function initialize(): static + { + return $this; + } +} diff --git a/backend/tests/Factory/SubscriberMetadataDefinitionFactory.php b/backend/tests/Factory/SubscriberMetadataDefinitionFactory.php index cdedcc87..86b5271f 100644 --- a/backend/tests/Factory/SubscriberMetadataDefinitionFactory.php +++ b/backend/tests/Factory/SubscriberMetadataDefinitionFactory.php @@ -4,12 +4,12 @@ use App\Entity\SubscriberMetadataDefinition; use App\Entity\Type\SubscriberMetadataDefinitionType; -use Zenstruck\Foundry\Persistence\PersistentProxyObjectFactory; +use Zenstruck\Foundry\Persistence\PersistentObjectFactory; /** - * @extends PersistentProxyObjectFactory + * @extends PersistentObjectFactory */ -final class SubscriberMetadataDefinitionFactory extends PersistentProxyObjectFactory +final class SubscriberMetadataDefinitionFactory extends PersistentObjectFactory { public static function class(): string @@ -34,4 +34,4 @@ protected function defaults(): array ]; } -} \ No newline at end of file +} diff --git a/backend/tests/Factory/TemplateFactory.php b/backend/tests/Factory/TemplateFactory.php index d0258863..538af452 100644 --- a/backend/tests/Factory/TemplateFactory.php +++ b/backend/tests/Factory/TemplateFactory.php @@ -6,21 +6,19 @@ use App\Entity\Template; use App\Entity\Type\IssueStatus; use App\Entity\Type\SendStatus; -use Zenstruck\Foundry\Persistence\PersistentProxyObjectFactory; +use Zenstruck\Foundry\Persistence\PersistentObjectFactory; /** - * @extends PersistentProxyObjectFactory