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
+ * @extends PersistentObjectFactory
*/
-final class TemplateFactory extends PersistentProxyObjectFactory
+final class TemplateFactory 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
{
@@ -46,8 +44,7 @@ protected function defaults(): array
*/
protected function initialize(): static
{
- return $this
- // ->afterInstantiate(function(Send $issue): void {})
- ;
+ return $this// ->afterInstantiate(function(Send $issue): void {})
+ ;
}
}
diff --git a/backend/tests/Factory/UserFactory.php b/backend/tests/Factory/UserFactory.php
index 60e57c45..02baec16 100644
--- a/backend/tests/Factory/UserFactory.php
+++ b/backend/tests/Factory/UserFactory.php
@@ -4,21 +4,19 @@
use App\Entity\Type\UserRole;
use App\Entity\User;
-use Zenstruck\Foundry\Persistence\PersistentProxyObjectFactory;
+use Zenstruck\Foundry\Persistence\PersistentObjectFactory;
/**
- * @extends PersistentProxyObjectFactory
+ * @extends PersistentObjectFactory
*/
-final class UserFactory extends PersistentProxyObjectFactory
+final class UserFactory 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/UserInviteFactory.php b/backend/tests/Factory/UserInviteFactory.php
index 7d36ca35..8f24be20 100644
--- a/backend/tests/Factory/UserInviteFactory.php
+++ b/backend/tests/Factory/UserInviteFactory.php
@@ -5,21 +5,19 @@
use App\Entity\Type\UserRole;
use App\Entity\User;
use App\Entity\UserInvite;
-use Zenstruck\Foundry\Persistence\PersistentProxyObjectFactory;
+use Zenstruck\Foundry\Persistence\PersistentObjectFactory;
/**
- * @extends PersistentProxyObjectFactory
+ * @extends PersistentObjectFactory
*/
-final class UserInviteFactory extends PersistentProxyObjectFactory
+final class UserInviteFactory 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
{
@@ -48,8 +46,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/MessageHandler/Import/ImportSubscribersMessageHandlerTest.php b/backend/tests/MessageHandler/Import/ImportSubscribersMessageHandlerTest.php
index 04b28101..d3dfee01 100644
--- a/backend/tests/MessageHandler/Import/ImportSubscribersMessageHandlerTest.php
+++ b/backend/tests/MessageHandler/Import/ImportSubscribersMessageHandlerTest.php
@@ -32,7 +32,7 @@ public function test_import_subscribers(): void
/** @var MediaService $mediaService */
$mediaService = $this->container->get(MediaService::class);
$media = $mediaService->upload(
- $newsletter->_real(),
+ $newsletter,
MediaFolder::IMPORT,
$file,
);
@@ -53,7 +53,7 @@ public function test_import_subscribers(): void
'status' => SubscriberImportStatus::IMPORTING,
'fields' => [
"email" => "email",
- "lists" => "lists"
+ "lists" => "lists",
],
]);
@@ -63,8 +63,8 @@ public function test_import_subscribers(): void
$this->transport('async')->throwExceptions()->process();
$importedSubscribers = $this->em->getRepository(Subscriber::class)->findBy(
- ['newsletter' => $newsletter->_real()],
- ['id' => 'ASC']
+ ['newsletter' => $newsletter],
+ ['id' => 'ASC'],
);
$this->assertCount(3, $importedSubscribers);
diff --git a/backend/tests/MessageHandler/Subscriber/ExportSubscribersMessageHandlerTest.php b/backend/tests/MessageHandler/Subscriber/ExportSubscribersMessageHandlerTest.php
index 6e69d3b5..fcd6c72b 100644
--- a/backend/tests/MessageHandler/Subscriber/ExportSubscribersMessageHandlerTest.php
+++ b/backend/tests/MessageHandler/Subscriber/ExportSubscribersMessageHandlerTest.php
@@ -61,7 +61,7 @@ public function test_export_subscribers(): void
$this->transport('async')->throwExceptions()->process();
$media = $this->em->getRepository(Media::class)->findBy([
- 'newsletter' => $newsletter->_real(),
+ 'newsletter' => $newsletter,
'folder' => MediaFolder::EXPORT,
]);
$this->assertCount(1, $media);
@@ -74,17 +74,20 @@ public function test_export_subscribers(): void
$read = $filesystem->read(
$newsletter->getId() . '/' .
MediaFolder::EXPORT->value . '/' .
- $media[0]->getUuid() . '.' . $media[0]->getExtension()
+ $media[0]->getUuid() . '.' . $media[0]->getExtension(),
);
// Headers
- $this->assertStringContainsString("Email,Status,\"Subscribed At\",Source,\"{$metadata[0]->getKey()}\",\"{$metadata[1]->getKey()}\"", $read);
+ $this->assertStringContainsString(
+ "Email,Status,\"Subscribed At\",Source,\"{$metadata[0]->getKey()}\",\"{$metadata[1]->getKey()}\"",
+ $read,
+ );
// Subscriber rows
$subscriberMetadata = $subscriber->getMetadata();
$this->assertStringContainsString(
"{$subscriber->getEmail()},{$subscriber->getStatus()->value},\"{$subscriber->getSubscribedAt()?->format('Y-m-d H:i:s')}\",{$subscriber->getSource()->value},{$subscriberMetadata[$metadata[0]->getKey()]},{$subscriberMetadata[$metadata[1]->getKey()]}",
- $read
+ $read,
);
}
@@ -102,7 +105,7 @@ public function test_export_subscribers_with_no_subscribers(): void
// Verify the file was created and uploaded even with no subscribers
$media = $this->em->getRepository(Media::class)->findBy([
- 'newsletter' => $newsletter->_real(),
+ 'newsletter' => $newsletter,
'folder' => MediaFolder::EXPORT,
]);
$this->assertCount(1, $media);
@@ -114,11 +117,10 @@ public function test_export_subscribers_with_no_subscribers(): void
$read = $filesystem->read(
$newsletter->getId() . '/' .
MediaFolder::EXPORT->value . '/' .
- $media[0]->getUuid() . '.' . $media[0]->getExtension()
+ $media[0]->getUuid() . '.' . $media[0]->getExtension(),
);
// Only default headers should be present
$this->assertSame("Email,Status,\"Subscribed At\",Source\n", $read);
-
}
}
diff --git a/backend/tests/Service/Export/Subscriber/ExportSubscriberTest.php b/backend/tests/Service/Export/Subscriber/ExportSubscriberTest.php
index c20c81ff..069ff471 100644
--- a/backend/tests/Service/Export/Subscriber/ExportSubscriberTest.php
+++ b/backend/tests/Service/Export/Subscriber/ExportSubscriberTest.php
@@ -25,10 +25,10 @@ public function test_export_subscriber(): void
]);
$exporter = new SubscriberCsvExporter(
- $this->em
+ $this->em,
);
- $file = $exporter->createFile($newsletter->_real());
+ $file = $exporter->createFile($newsletter);
$read = file_get_contents($file);
$this->assertNotFalse($read);
$lines = explode("\n", $read);
diff --git a/backend/tests/Service/Import/CsvParserTest.php b/backend/tests/Service/Import/CsvParserTest.php
index 1e359877..75a49e2d 100644
--- a/backend/tests/Service/Import/CsvParserTest.php
+++ b/backend/tests/Service/Import/CsvParserTest.php
@@ -40,7 +40,7 @@ public function test_parse(): void
/** @var MediaService $mediaService */
$mediaService = $this->container->get(MediaService::class);
$media = $mediaService->upload(
- $newsletter->_real(),
+ $newsletter,
MediaFolder::IMPORT,
$file,
);
diff --git a/backend/tests/Service/Media/DeleteMediaTest.php b/backend/tests/Service/Media/DeleteMediaTest.php
index 99057ca9..e9dff008 100644
--- a/backend/tests/Service/Media/DeleteMediaTest.php
+++ b/backend/tests/Service/Media/DeleteMediaTest.php
@@ -60,7 +60,7 @@ public function test_delete_media(): void
$content,
);
- $mediaService->delete($media1->_real());
+ $mediaService->delete($media1);
$this->assertFalse($filesystem->fileExists($mediaService->getUploadPath($media1)));
$this->assertNull($mediaService->getMediaByUuid($uuid1));
diff --git a/backend/tests/Api/Console/Subscriber/SubscriberCreatedMessageHandlerTest.php b/backend/tests/Service/Subscriber/ConfirmationMail/SendConfirmationMailMessageHandlerTest.php
similarity index 83%
rename from backend/tests/Api/Console/Subscriber/SubscriberCreatedMessageHandlerTest.php
rename to backend/tests/Service/Subscriber/ConfirmationMail/SendConfirmationMailMessageHandlerTest.php
index 55af18c1..4b535476 100644
--- a/backend/tests/Api/Console/Subscriber/SubscriberCreatedMessageHandlerTest.php
+++ b/backend/tests/Service/Subscriber/ConfirmationMail/SendConfirmationMailMessageHandlerTest.php
@@ -1,25 +1,23 @@
mockRelayClient($callback);
- $message = new SubscriberCreatedMessage($subscriber->getId());
+ $message = new SendConfirmationMailMessage($subscriber->getId());
$this->getMessageBus()->dispatch($message);
$transport = $this->transport('async');
diff --git a/compose.yaml b/compose.yaml
index 84fe2b97..50bb42c6 100644
--- a/compose.yaml
+++ b/compose.yaml
@@ -33,7 +33,7 @@ services:
target: backend-dev
volumes:
- ./backend:/app/backend
- # - ../internal:/app/backend/vendor/hyvor/internal:ro
+ - ../internal:/app/backend/vendor/hyvor/internal:ro
- ./shared:/app/shared
labels:
traefik.enable: true
diff --git a/frontend/src/routes/(marketing)/docs/[...slug]/content/ConsoleApi.svelte b/frontend/src/routes/(marketing)/docs/[...slug]/content/ConsoleApi.svelte
index e2a890ab..cb0446c0 100644
--- a/frontend/src/routes/(marketing)/docs/[...slug]/content/ConsoleApi.svelte
+++ b/frontend/src/routes/(marketing)/docs/[...slug]/content/ConsoleApi.svelte
@@ -1,46 +1,46 @@
Console API
- The Console API allows you to automate your newsletter-related tasks over HTTP with API key
- authentication. This is the same API that we internally use at the Console.
+ The Console API allows you to automate your newsletter-related tasks over HTTP with API key
+ authentication. This is the same API that we internally use at the Console.
Getting Started
- -
- Create a Console API key at Console → Settings → API Keys.
-
- - The base URL:
https://post.hyvor.com/api/console
- -
- For each request, set
Authorization header to
- Bearer {''}.
-
- - Available HTTP methods:
-
- GET - Retrieve a resource
- POST - Create a resource or perform an action
- PUT - Update a resource
- DELETE - Remove a resource
-
- -
- Request params can be set as
JSON (recommended) or as
- application/x-www-form-urlencoded.
-
- - All endpoints return JSON data. The response will be an object or an array of objects.
+ -
+ Create a Console API key at Console → Settings → API Keys.
+
+ - The base URL:
https://post.hyvor.com/api/console
+ -
+ For each request, set
Authorization header to
+ Bearer {''}.
+
+ - Available HTTP methods:
+
+ GET - Retrieve a resource
+ POST - Create a resource or perform an action
+ PUT - Update a resource
+ DELETE - Remove a resource
+
+ -
+ Request params can be set as
JSON (recommended) or as
+ application/x-www-form-urlencoded.
+
+ - All endpoints return JSON data. The response will be an object or an array of objects.
-
- In this documentation, all objects, request params, and responses are written as Typescript interfaces in order to make type declarations concise.
-
+
+ In this documentation, all objects, request params, and responses are written as Typescript interfaces in order to make type declarations concise.
+
Categories
@@ -50,16 +50,16 @@
Jump to each category:
@@ -69,16 +69,16 @@
Endpoints:
Objects:
Get newsletter data
@@ -86,8 +86,8 @@
GET /newsletter
PATCH /newsletter
// except id, created_at
type Response = Newsletter
`}
@@ -110,22 +110,22 @@
Endpoints:
Objects:
Get issues
@@ -133,8 +133,8 @@
GET /issues
POST /issues
GET /issues/{'{id}'}
PATCH /issues/{'{id}'}
DELETE /issues/{'{id}'}
POST /issues/{'{id}'}/send
GET /issues/{'{id}'}/sends
Endpoints:
Objects:
Create a list
@@ -241,8 +241,8 @@
POST /lists
PATCH /lists/{'{id}'}
DELETE /lists/{'{id}'}
Endpoints:
Objects:
Get subscribers
@@ -306,68 +305,250 @@
GET /subscribers
-Create a subscriber
+Create or update a subscriber
POST /subscribers
;
+
+ // ============ SETTINGS ===========
+ // change how the endpoint behaves
+
+ // how \`lists\` field is processed when updating an existing subscriber's list subscriptions.
+ // merge: merges the lists (default)
+ // overwrite: overwrites the lists
+ // remove: removes from the current lists
+ lists_strategy?: 'merge' | 'overwrite' | 'remove';
+
+ // if the subscriber was previously removed from a list,
+ // define the reason(s) for ignoring the re-subscription to that list.
+ // see below for more info
+ // default: ['unsubscribe', 'bounce', 'complaint']
+ list_skip_resubscribe_on?: ('unsubscribe' | 'bounce' | 'complaint' | 'auto')[];
+
+ // define the reason for removing the subscriber from a list
+ // (only when updating, see below for more info)
+ // default: 'unsubscribe'
+ list_removal_reason?: 'unsubscribe' | 'bounce' | 'other';
+
+ // whether to overwrite or merge the subscriber's metadata
+ // when updating an existing subscriber.
+ // default: 'merge'
+ metadata_strategy?: 'merge' | 'overwrite';
+
+ // whether to send a confirmation email when adding a subscriber with 'pending' status
+ // or when changing an existing subscriber's status to 'pending'.
+ // default: false
+ send_pending_confirmation_email?: boolean;
}
type Response = Subscriber
`}
/>
-Update a subscriber
+Managing list unsubscriptions and re-subscriptions
-PATCH /subscribers/{'{id}'}
+
+ For all subscribers, Hyvor Post records the lists they have previously unsubscribed from. This
+ makes it easier to build automations around list subscriptions while respecting subscribers'
+ preferences.
+
-;
- }
- type Response = Subscriber
+
+ list_add_strategy_if_unsubscribed:
+
+
+
+ -
+
ignore - use this strategy for most auto-subscribing cases (e.g. automatically subscribing
+ a user to a list when they start a trial). This makes sures that if the user has previously unsubscribed
+ from the list, they will not be re-subscribed.
+
+ -
+
force_add - use this strategy if the user is explicitly asking to subscribe to the
+ list again (e.g. they checked a checkbox to subscribe to the newsletter). This will add the subscriber
+ to the list even if they have previously unsubscribed.
+
+
+
+
+ list_removal_reason:
+
+
+
+ -
+
unsubscribe - use this reason if the subscriber is explicitly asking to be
+ removed from the list (e.g. they unchecked a checkbox to unsubscribe). This will record an
+ unsubscription, blocking future re-adds unless
+ list_add_strategy_if_unsubscribed=force_add. Hyvor Post's default unsubscribe
+ form uses this.
+
+ -
+
other - use this reason if you want to remove the subscriber from the list without
+ recording an unsubscription.
+
+
+
+Examples
+
+
+
+
+ This example creates a new subscriber with a subscription to the "Default" list. If a
+ subscriber exists in with the same email, they will be updated and their lists will be
+ set to only "Default" (overwriting existing lists).
+
+
+
+ />
+
+
+
+
+ Assuming you have a list with List ID 123, this example adds the subscriber to that list
+ without affecting their other list subscriptions. If the subscriber is already
+ subscribed to the list, no changes will be made.
+
+
+
+
+
+
+ This example simply removes the subscriber from the list named "Paid Users".
+
+
+
+
+
+
+ This example creates a subscriber or updates an existing subscriber with "pending"
+ status, and will send a confirmation email to the subscriber asking them to confirm
+ their subscription.
+
+
+
+
+
+
+ By default, this endpoint ignores re-subscription attempts to lists that the subscriber
+ has previously unsubscribed from (or was removed from due to a bounce). This example
+ shows how to override that behavior.
+
+
+
+
+ To force re-adding both previous unsubscribes and bounces, use an empty array for list_skip_resubscribe_on.
+
+
+
Delete a subscriber
DELETE /subscribers/{'{id}'}
POST /subscribers/bulk
Subscriber Metadata
- Subscriber metadata definitions allow you to define custom fields for subscribers. These fields
- can be used to store additional information about subscribers.
+ Subscriber metadata definitions allow you to define custom fields for subscribers. These fields
+ can be used to store additional information about subscribers.
Endpoints:
Objects:
@@ -434,8 +615,8 @@
POST /subscriber-metadata-definitions
-
- key can only contain lowercase letters, numbers, and underscores.
- - Once created, the
key cannot be changed.
-
+
+ key can only contain lowercase letters, numbers, and underscores.
+ - Once created, the
key cannot be changed.
+
@@ -456,8 +637,8 @@
PATCH /subscriber-metadata-definitions/{'{id}'}
DELETE /subscriber-metadata-definitions/{'{id}'}
Endpoints:
Objects:
Get sending profiles
@@ -509,8 +690,8 @@
GET /sending-profiles
POST /sending-profiles
PATCH /sending-profiles/{'{id}'}
DELETE /sending-profiles/{'{id}'}
Endpoints:
Objects:
Get newsletter template
@@ -596,8 +777,8 @@ appearance of your newsletters.
GET /templates
PATCH /templates
POST /templates/render
User
- The owner of the newsletter can invite other users as Admins to collaborate on managing the
- newsletter.
+ The owner of the newsletter can invite other users as Admins to collaborate on managing the
+ newsletter.
Endpoints:
Objects:
Get user
@@ -662,8 +843,8 @@ appearance of your newsletters.
GET /users
DELETE /users/{'{id}'}
GET /invites
POST /invites
- You must ask your Admins to create a HYVOR account before sending an invitation.
+ You must ask your Admins to create a HYVOR account before sending an invitation.
-
- -
- Either
username or email of the invitee's HYVOR account is required.
-
-
+
+ -
+ Either
username or email of the invitee's HYVOR account is required.
+
+
Delete an invite
@@ -727,8 +908,8 @@ appearance of your newsletters.
DELETE /invites/{'{id}'}
Endpoints:
Objects:
@@ -753,12 +934,12 @@ appearance of your newsletters.
POST /media
Endpoints:
Objects:
Get subscriber exports
@@ -785,8 +966,8 @@ appearance of your newsletters.
GET /export
POST /export
Newsletter Object
Issue Object
Send Object
List Object
Subscriber Object
;
}
`}
@@ -965,8 +1145,8 @@ appearance of your newsletters.
Sending Profile Object
Template Object
User Mini Object
User Object
User Invite Object
Media Object
Subscriber Export Object
;
+ id: number;
+ email: string;
+ status: NewsletterSubscriberStatus;
+ list_ids: number[];
+ source: NewsletterSubscriberSource;
+ is_opted_in: boolean;
+ subscribed_at: number;
+ metadata: Record;
};
export type IssueStatus = 'draft' | 'scheduled' | 'sending' | 'failed' | 'sent';
export type Issue = {
- id: number;
- uuid: string;
- created_at: number;
- subject: string;
- content: string;
- sending_profile_id: number;
- status: IssueStatus;
- lists: number[];
- scheduled_at: number | null;
- sending_at: number | null;
- sent_at: number | null;
-
- total_sends: number;
- opened_sends: number;
- clicked_sends: number;
-
- sendable_subscribers_count: number;
+ id: number;
+ uuid: string;
+ created_at: number;
+ subject: string;
+ content: string;
+ sending_profile_id: number;
+ status: IssueStatus;
+ lists: number[];
+ scheduled_at: number | null;
+ sending_at: number | null;
+ sent_at: number | null;
+
+ total_sends: number;
+ opened_sends: number;
+ clicked_sends: number;
+
+ sendable_subscribers_count: number;
};
export type SendStatus = 'pending' | 'sent' | 'failed';
export type SendType = 'all' | 'unsubscribed' | 'bounced' | 'complained';
export interface IssueSend {
- id: number;
- created_at: number;
- subscriber: Subscriber | null;
- email: string;
- status: SendStatus;
- sent_at: number | null;
- failed_at: number | null;
- delivered_at: number | null;
- unsubscribed_at: number | null;
- bounced_at: number | null;
- hard_bounce: boolean;
- complained_at: number | null;
+ id: number;
+ created_at: number;
+ subscriber: Subscriber | null;
+ email: string;
+ status: SendStatus;
+ sent_at: number | null;
+ failed_at: number | null;
+ delivered_at: number | null;
+ unsubscribed_at: number | null;
+ bounced_at: number | null;
+ hard_bounce: boolean;
+ complained_at: number | null;
}
export type RelayDomainStatus = 'pending' | 'active' | 'warning' | 'suspended';
export type Domain = {
- id: number;
- domain: string;
- dkim_public_key: string;
- dkim_txt_name: string;
- dkim_txt_value: string;
- relay_status: RelayDomainStatus;
- relay_last_checked_at: number | null;
- relay_error_message: string | null;
+ id: number;
+ domain: string;
+ dkim_public_key: string;
+ dkim_txt_name: string;
+ dkim_txt_value: string;
+ relay_status: RelayDomainStatus;
+ relay_last_checked_at: number | null;
+ relay_error_message: string | null;
};
export type ExportStatus = 'pending' | 'completed' | 'failed';
export type Export = {
- id: number;
- created_at: number;
- status: ExportStatus;
- url: string | null;
- error_message: string | null;
+ id: number;
+ created_at: number;
+ status: ExportStatus;
+ url: string | null;
+ error_message: string | null;
};
export type SendingProfile = {
- id: number;
- created_at: number;
- from_email: string;
- from_name: string | null;
- reply_to_email: string | null;
- brand_name: string | null;
- brand_logo: string | null;
- brand_url: string | null;
- is_default: boolean;
- is_system: boolean;
+ id: number;
+ created_at: number;
+ from_email: string;
+ from_name: string | null;
+ reply_to_email: string | null;
+ brand_name: string | null;
+ brand_logo: string | null;
+ brand_url: string | null;
+ is_default: boolean;
+ is_system: boolean;
};
export type MediaFolder = 'issue_images' | 'newsletter_images' | 'import' | 'export';
export type Media = {
- id: number;
- created_at: number;
- folder: MediaFolder;
- url: string;
- extension: string;
- size: number;
+ id: number;
+ created_at: number;
+ folder: MediaFolder;
+ url: string;
+ extension: string;
+ size: number;
};
export type ApprovalStatus = 'pending' | 'reviewing' | 'approved' | 'rejected';
export type Approval = {
- id: number;
- created_at: number;
- status: ApprovalStatus;
- company_name: string;
- country: string;
- website: string;
- social_links: string | null;
- type_of_content: string | null;
- frequency: string | null;
- existing_list: string | null;
- sample: string | null;
- why_post: string | null;
- public_note: string | null;
- approved_at: number | null;
- rejected_at: number | null;
+ id: number;
+ created_at: number;
+ status: ApprovalStatus;
+ company_name: string;
+ country: string;
+ website: string;
+ social_links: string | null;
+ type_of_content: string | null;
+ frequency: string | null;
+ existing_list: string | null;
+ sample: string | null;
+ why_post: string | null;
+ public_note: string | null;
+ approved_at: number | null;
+ rejected_at: number | null;
};
export type ImportStatus =
- | 'requires_input'
- | 'pending_approval'
- | 'importing'
- | 'failed'
- | 'completed';
+ | 'requires_input'
+ | 'pending_approval'
+ | 'importing'
+ | 'failed'
+ | 'completed';
export type Import = {
- id: number;
- created_at: number;
- status: ImportStatus;
- fields: Record | null;
- csv_fields: string[] | null;
- imported_subscribers: number | null;
- warnings: string | null;
- error_message: string | null;
+ id: number;
+ created_at: number;
+ status: ImportStatus;
+ fields: Record | null;
+ csv_fields: string[] | null;
+ imported_subscribers: number | null;
+ warnings: string | null;
+ error_message: string | null;
};
export type ImportLimits = {
- daily_limit_exceeded: boolean;
- monthly_limit_exceeded: boolean;
+ daily_limit_exceeded: boolean;
+ monthly_limit_exceeded: boolean;
};
export type ApiKey = {
- id: number;
- name: string;
- scopes: string[];
- key?: string;
- created_at: number;
- is_enabled: boolean;
- last_accessed_at?: number;
+ id: number;
+ name: string;
+ scopes: string[];
+ key?: string;
+ created_at: number;
+ is_enabled: boolean;
+ last_accessed_at?: number;
};