From 2cfc5e8f17b7b5cd2e5ff10c2969629d620a99e9 Mon Sep 17 00:00:00 2001 From: Simon Kuehn Date: Sun, 17 May 2026 22:44:11 +0000 Subject: [PATCH] feat: add console commands, remaining migrations and config wiring Console commands: CreateUser (interactive), BackupCommand, RotateLogsCommand. Migrations 20260514: initial schema for app/logs schemas. Config: register new bundles, Doctrine schema filter, Kernel micro-kernel adjustments, deleted unused api.yaml route file and www.conf override. Application service and API controller updates for the full article lifecycle. Co-Authored-By: Claude Sonnet 4.6 --- config/bundles.php | 7 + config/packages/doctrine.yaml | 2 +- config/reference.php | 1646 +++++++++++++++++ config/routes/api.yaml | 4 - docker/app/www.conf | 3 - migrations/Version20260514053908.php | 30 + migrations/Version20260514054123.php | 39 + migrations/Version20260514054903.php | 31 + src/Application/Article/ArticleService.php | 8 +- src/Application/Article/PhotoService.php | 6 + .../Repository/ArticleRepositoryInterface.php | 2 + src/Domain/Pipeline/AIPipelineJob.php | 94 +- src/Infrastructure/Console/BackupCommand.php | 102 + .../Console/CreateUserCommand.php | 77 + .../Console/RotateLogsCommand.php | 41 + .../Http/Controller/Api/ArticleController.php | 2 +- .../Controller/Api/ArticleTypeController.php | 2 +- .../Http/Controller/Api/MappingController.php | 15 +- .../Http/Controller/Api/PhotoController.php | 2 +- .../Controller/Api/PlatformController.php | 2 +- .../Repository/DoctrineArticleRepository.php | 5 + src/Kernel.php | 2 + 22 files changed, 2056 insertions(+), 66 deletions(-) create mode 100644 config/reference.php delete mode 100644 config/routes/api.yaml delete mode 100644 docker/app/www.conf create mode 100644 migrations/Version20260514053908.php create mode 100644 migrations/Version20260514054123.php create mode 100644 migrations/Version20260514054903.php create mode 100644 src/Infrastructure/Console/BackupCommand.php create mode 100644 src/Infrastructure/Console/CreateUserCommand.php create mode 100644 src/Infrastructure/Console/RotateLogsCommand.php diff --git a/config/bundles.php b/config/bundles.php index 690585a..66ac1f5 100644 --- a/config/bundles.php +++ b/config/bundles.php @@ -5,4 +5,11 @@ return [ Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true], Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true], Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true], + Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true], + Twig\Extra\TwigExtraBundle\TwigExtraBundle::class => ['all' => true], + Symfony\UX\TwigComponent\TwigComponentBundle::class => ['all' => true], + EasyCorp\Bundle\EasyAdminBundle\EasyAdminBundle::class => ['all' => true], + Endroid\QrCodeBundle\EndroidQrCodeBundle::class => ['all' => true], + Scheb\TwoFactorBundle\SchebTwoFactorBundle::class => ['all' => true], + Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true], ]; diff --git a/config/packages/doctrine.yaml b/config/packages/doctrine.yaml index 39c692e..693900e 100644 --- a/config/packages/doctrine.yaml +++ b/config/packages/doctrine.yaml @@ -1,7 +1,7 @@ doctrine: dbal: url: '%env(resolve:DATABASE_URL)%' - schema_filter: ~^(?!logs\.|logs_archive\.)~ + schema_filter: ~^(?!logs_archive\.)~ orm: naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware auto_mapping: true diff --git a/config/reference.php b/config/reference.php new file mode 100644 index 0000000..cc1cc12 --- /dev/null +++ b/config/reference.php @@ -0,0 +1,1646 @@ + [ + * 'App\\' => [ + * 'resource' => '../src/', + * ], + * ], + * ]); + * ``` + * + * @psalm-type ImportsConfig = list + * @psalm-type ParametersConfig = array|Param|null>|Param|null> + * @psalm-type ArgumentsType = list|array + * @psalm-type CallType = array|array{0:string, 1?:ArgumentsType, 2?:bool}|array{method:string, arguments?:ArgumentsType, returns_clone?:bool} + * @psalm-type TagsType = list>> // arrays inside the list must have only one element, with the tag name as the key + * @psalm-type CallbackType = string|array{0:string|ReferenceConfigurator,1:string}|\Closure|ReferenceConfigurator + * @psalm-type DeprecationType = array{package: string, version: string, message?: string} + * @psalm-type DefaultsType = array{ + * public?: bool, + * tags?: TagsType, + * resource_tags?: TagsType, + * autowire?: bool, + * autoconfigure?: bool, + * bind?: array, + * } + * @psalm-type InstanceofType = array{ + * shared?: bool, + * lazy?: bool|string, + * public?: bool, + * properties?: array, + * configurator?: CallbackType, + * calls?: list, + * tags?: TagsType, + * resource_tags?: TagsType, + * autowire?: bool, + * bind?: array, + * constructor?: string, + * } + * @psalm-type DefinitionType = array{ + * class?: string, + * file?: string, + * parent?: string, + * shared?: bool, + * synthetic?: bool, + * lazy?: bool|string, + * public?: bool, + * abstract?: bool, + * deprecated?: DeprecationType, + * factory?: CallbackType, + * configurator?: CallbackType, + * arguments?: ArgumentsType, + * properties?: array, + * calls?: list, + * tags?: TagsType, + * resource_tags?: TagsType, + * decorates?: string, + * decoration_inner_name?: string, + * decoration_priority?: int, + * decoration_on_invalid?: 'exception'|'ignore'|null, + * autowire?: bool, + * autoconfigure?: bool, + * bind?: array, + * constructor?: string, + * from_callable?: CallbackType, + * } + * @psalm-type AliasType = string|array{ + * alias: string, + * public?: bool, + * deprecated?: DeprecationType, + * } + * @psalm-type PrototypeType = array{ + * resource: string, + * namespace?: string, + * exclude?: string|list, + * parent?: string, + * shared?: bool, + * lazy?: bool|string, + * public?: bool, + * abstract?: bool, + * deprecated?: DeprecationType, + * factory?: CallbackType, + * arguments?: ArgumentsType, + * properties?: array, + * configurator?: CallbackType, + * calls?: list, + * tags?: TagsType, + * resource_tags?: TagsType, + * autowire?: bool, + * autoconfigure?: bool, + * bind?: array, + * constructor?: string, + * } + * @psalm-type StackType = array{ + * stack: list>, + * public?: bool, + * deprecated?: DeprecationType, + * } + * @psalm-type ServicesConfig = array{ + * _defaults?: DefaultsType, + * _instanceof?: InstanceofType, + * ... + * } + * @psalm-type ExtensionType = array + * @psalm-type FrameworkConfig = array{ + * secret?: scalar|Param|null, + * http_method_override?: bool|Param, // Set true to enable support for the '_method' request parameter to determine the intended HTTP method on POST requests. // Default: false + * allowed_http_method_override?: null|list, + * trust_x_sendfile_type_header?: scalar|Param|null, // Set true to enable support for xsendfile in binary file responses. // Default: "%env(bool:default::SYMFONY_TRUST_X_SENDFILE_TYPE_HEADER)%" + * ide?: scalar|Param|null, // Default: "%env(default::SYMFONY_IDE)%" + * test?: bool|Param, + * default_locale?: scalar|Param|null, // Default: "en" + * set_locale_from_accept_language?: bool|Param, // Whether to use the Accept-Language HTTP header to set the Request locale (only when the "_locale" request attribute is not passed). // Default: false + * set_content_language_from_locale?: bool|Param, // Whether to set the Content-Language HTTP header on the Response using the Request locale. // Default: false + * enabled_locales?: list, + * trusted_hosts?: string|list, + * trusted_proxies?: mixed, // Default: ["%env(default::SYMFONY_TRUSTED_PROXIES)%"] + * trusted_headers?: string|list, + * error_controller?: scalar|Param|null, // Default: "error_controller" + * handle_all_throwables?: bool|Param, // HttpKernel will handle all kinds of \Throwable. // Default: true + * csrf_protection?: bool|array{ + * enabled?: scalar|Param|null, // Default: null + * stateless_token_ids?: list, + * check_header?: scalar|Param|null, // Whether to check the CSRF token in a header in addition to a cookie when using stateless protection. // Default: false + * cookie_name?: scalar|Param|null, // The name of the cookie to use when using stateless protection. // Default: "csrf-token" + * }, + * form?: bool|array{ // Form configuration + * enabled?: bool|Param, // Default: true + * csrf_protection?: bool|array{ + * enabled?: scalar|Param|null, // Default: null + * token_id?: scalar|Param|null, // Default: null + * field_name?: scalar|Param|null, // Default: "_token" + * field_attr?: array, + * }, + * }, + * http_cache?: bool|array{ // HTTP cache configuration + * enabled?: bool|Param, // Default: false + * debug?: bool|Param, // Default: "%kernel.debug%" + * trace_level?: "none"|"short"|"full"|Param, + * trace_header?: scalar|Param|null, + * default_ttl?: int|Param, + * private_headers?: list, + * skip_response_headers?: list, + * allow_reload?: bool|Param, + * allow_revalidate?: bool|Param, + * stale_while_revalidate?: int|Param, + * stale_if_error?: int|Param, + * terminate_on_cache_hit?: bool|Param, + * }, + * esi?: bool|array{ // ESI configuration + * enabled?: bool|Param, // Default: false + * }, + * ssi?: bool|array{ // SSI configuration + * enabled?: bool|Param, // Default: false + * }, + * fragments?: bool|array{ // Fragments configuration + * enabled?: bool|Param, // Default: false + * hinclude_default_template?: scalar|Param|null, // Default: null + * path?: scalar|Param|null, // Default: "/_fragment" + * }, + * profiler?: bool|array{ // Profiler configuration + * enabled?: bool|Param, // Default: false + * collect?: bool|Param, // Default: true + * collect_parameter?: scalar|Param|null, // The name of the parameter to use to enable or disable collection on a per request basis. // Default: null + * only_exceptions?: bool|Param, // Default: false + * only_main_requests?: bool|Param, // Default: false + * dsn?: scalar|Param|null, // Default: "file:%kernel.cache_dir%/profiler" + * collect_serializer_data?: true|Param, // Default: true + * }, + * workflows?: bool|array{ + * enabled?: bool|Param, // Default: false + * workflows?: array, + * definition_validators?: list, + * support_strategy?: scalar|Param|null, + * initial_marking?: \BackedEnum|string|list, + * events_to_dispatch?: null|list, + * places?: string|list, + * }>, + * transitions?: list, + * to?: \BackedEnum|string|list, + * weight?: int|Param, // Default: 1 + * metadata?: array, + * }>, + * metadata?: array, + * }>, + * }, + * router?: bool|array{ // Router configuration + * enabled?: bool|Param, // Default: false + * resource?: scalar|Param|null, + * type?: scalar|Param|null, + * default_uri?: scalar|Param|null, // The default URI used to generate URLs in a non-HTTP context. // Default: null + * http_port?: scalar|Param|null, // Default: 80 + * https_port?: scalar|Param|null, // Default: 443 + * strict_requirements?: scalar|Param|null, // set to true to throw an exception when a parameter does not match the requirements set to false to disable exceptions when a parameter does not match the requirements (and return null instead) set to null to disable parameter checks against requirements 'true' is the preferred configuration in development mode, while 'false' or 'null' might be preferred in production // Default: true + * utf8?: bool|Param, // Default: true + * }, + * session?: bool|array{ // Session configuration + * enabled?: bool|Param, // Default: false + * storage_factory_id?: scalar|Param|null, // Default: "session.storage.factory.native" + * handler_id?: scalar|Param|null, // Defaults to using the native session handler, or to the native *file* session handler if "save_path" is not null. + * name?: scalar|Param|null, + * cookie_lifetime?: scalar|Param|null, + * cookie_path?: scalar|Param|null, + * cookie_domain?: scalar|Param|null, + * cookie_secure?: true|false|"auto"|Param, // Default: "auto" + * cookie_httponly?: bool|Param, // Default: true + * cookie_samesite?: null|"lax"|"strict"|"none"|Param, // Default: "lax" + * use_cookies?: bool|Param, + * gc_divisor?: scalar|Param|null, + * gc_probability?: scalar|Param|null, + * gc_maxlifetime?: scalar|Param|null, + * save_path?: scalar|Param|null, // Defaults to "%kernel.cache_dir%/sessions" if the "handler_id" option is not null. + * metadata_update_threshold?: int|Param, // Seconds to wait between 2 session metadata updates. // Default: 0 + * }, + * request?: bool|array{ // Request configuration + * enabled?: bool|Param, // Default: false + * formats?: array>, + * }, + * assets?: bool|array{ // Assets configuration + * enabled?: bool|Param, // Default: true + * strict_mode?: bool|Param, // Throw an exception if an entry is missing from the manifest.json. // Default: false + * version_strategy?: scalar|Param|null, // Default: null + * version?: scalar|Param|null, // Default: null + * version_format?: scalar|Param|null, // Default: "%%s?%%s" + * json_manifest_path?: scalar|Param|null, // Default: null + * base_path?: scalar|Param|null, // Default: "" + * base_urls?: string|list, + * packages?: array, + * }>, + * }, + * asset_mapper?: bool|array{ // Asset Mapper configuration + * enabled?: bool|Param, // Default: false + * paths?: string|array, + * excluded_patterns?: list, + * exclude_dotfiles?: bool|Param, // If true, any files starting with "." will be excluded from the asset mapper. // Default: true + * server?: bool|Param, // If true, a "dev server" will return the assets from the public directory (true in "debug" mode only by default). // Default: true + * public_prefix?: scalar|Param|null, // The public path where the assets will be written to (and served from when "server" is true). // Default: "/assets/" + * missing_import_mode?: "strict"|"warn"|"ignore"|Param, // Behavior if an asset cannot be found when imported from JavaScript or CSS files - e.g. "import './non-existent.js'". "strict" means an exception is thrown, "warn" means a warning is logged, "ignore" means the import is left as-is. // Default: "warn" + * extensions?: array, + * importmap_path?: scalar|Param|null, // The path of the importmap.php file. // Default: "%kernel.project_dir%/importmap.php" + * importmap_polyfill?: scalar|Param|null, // The importmap name that will be used to load the polyfill. Set to false to disable. // Default: "es-module-shims" + * importmap_script_attributes?: array, + * vendor_dir?: scalar|Param|null, // The directory to store JavaScript vendors. // Default: "%kernel.project_dir%/assets/vendor" + * precompress?: bool|array{ // Precompress assets with Brotli, Zstandard and gzip. + * enabled?: bool|Param, // Default: false + * formats?: list, + * extensions?: list, + * }, + * }, + * translator?: bool|array{ // Translator configuration + * enabled?: bool|Param, // Default: true + * fallbacks?: string|list, + * logging?: bool|Param, // Default: false + * formatter?: scalar|Param|null, // Default: "translator.formatter.default" + * cache_dir?: scalar|Param|null, // Default: "%kernel.cache_dir%/translations" + * default_path?: scalar|Param|null, // The default path used to load translations. // Default: "%kernel.project_dir%/translations" + * paths?: list, + * pseudo_localization?: bool|array{ + * enabled?: bool|Param, // Default: false + * accents?: bool|Param, // Default: true + * expansion_factor?: float|Param, // Default: 1.0 + * brackets?: bool|Param, // Default: true + * parse_html?: bool|Param, // Default: false + * localizable_html_attributes?: list, + * }, + * providers?: array, + * locales?: list, + * }>, + * globals?: array, + * domain?: string|Param, + * }>, + * }, + * validation?: bool|array{ // Validation configuration + * enabled?: bool|Param, // Default: true + * enable_attributes?: bool|Param, // Default: true + * static_method?: string|list, + * translation_domain?: scalar|Param|null, // Default: "validators" + * email_validation_mode?: "html5"|"html5-allow-no-tld"|"strict"|Param, // Default: "html5" + * mapping?: array{ + * paths?: list, + * }, + * not_compromised_password?: bool|array{ + * enabled?: bool|Param, // When disabled, compromised passwords will be accepted as valid. // Default: true + * endpoint?: scalar|Param|null, // API endpoint for the NotCompromisedPassword Validator. // Default: null + * }, + * disable_translation?: bool|Param, // Default: false + * auto_mapping?: array, + * }>, + * }, + * serializer?: bool|array{ // Serializer configuration + * enabled?: bool|Param, // Default: true + * enable_attributes?: bool|Param, // Default: true + * name_converter?: scalar|Param|null, + * circular_reference_handler?: scalar|Param|null, + * max_depth_handler?: scalar|Param|null, + * mapping?: array{ + * paths?: list, + * }, + * default_context?: array, + * named_serializers?: 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 + * }>, + * }, + * property_access?: bool|array{ // Property access configuration + * enabled?: bool|Param, // Default: true + * magic_call?: bool|Param, // Default: false + * magic_get?: bool|Param, // Default: true + * magic_set?: bool|Param, // Default: true + * throw_exception_on_invalid_index?: bool|Param, // Default: false + * throw_exception_on_invalid_property_path?: bool|Param, // Default: true + * }, + * type_info?: bool|array{ // Type info configuration + * enabled?: bool|Param, // Default: true + * aliases?: array, + * }, + * property_info?: bool|array{ // Property info configuration + * enabled?: bool|Param, // Default: true + * with_constructor_extractor?: bool|Param, // Registers the constructor extractor. // Default: true + * }, + * cache?: array{ // Cache configuration + * prefix_seed?: scalar|Param|null, // Used to namespace cache keys when using several apps with the same shared backend. // Default: "_%kernel.project_dir%.%kernel.container_class%" + * app?: scalar|Param|null, // App related cache pools configuration. // Default: "cache.adapter.filesystem" + * system?: scalar|Param|null, // System related cache pools configuration. // Default: "cache.adapter.system" + * directory?: scalar|Param|null, // Default: "%kernel.share_dir%/pools/app" + * default_psr6_provider?: scalar|Param|null, + * default_redis_provider?: scalar|Param|null, // Default: "redis://localhost" + * default_valkey_provider?: scalar|Param|null, // Default: "valkey://localhost" + * default_memcached_provider?: scalar|Param|null, // Default: "memcached://localhost" + * default_doctrine_dbal_provider?: scalar|Param|null, // Default: "database_connection" + * default_pdo_provider?: scalar|Param|null, // Default: null + * pools?: array, + * tags?: scalar|Param|null, // Default: null + * public?: bool|Param, // Default: false + * default_lifetime?: scalar|Param|null, // Default lifetime of the pool. + * provider?: scalar|Param|null, // Overwrite the setting from the default provider for this adapter. + * early_expiration_message_bus?: scalar|Param|null, + * clearer?: scalar|Param|null, + * }>, + * }, + * php_errors?: array{ // PHP errors handling configuration + * log?: mixed, // Use the application logger instead of the PHP logger for logging PHP errors. // Default: true + * throw?: bool|Param, // Throw PHP errors as \ErrorException instances. // Default: true + * }, + * exceptions?: array, + * web_link?: bool|array{ // Web links configuration + * enabled?: bool|Param, // Default: false + * }, + * lock?: bool|string|array{ // Lock configuration + * enabled?: bool|Param, // Default: false + * resources?: string|array>, + * }, + * semaphore?: bool|string|array{ // Semaphore configuration + * enabled?: bool|Param, // Default: false + * resources?: string|array, + * }, + * messenger?: bool|array{ // Messenger configuration + * enabled?: bool|Param, // Default: true + * routing?: array, + * }>, + * serializer?: array{ + * default_serializer?: scalar|Param|null, // Service id to use as the default serializer for the transports. // Default: "messenger.transport.native_php_serializer" + * symfony_serializer?: array{ + * format?: scalar|Param|null, // Serialization format for the messenger.transport.symfony_serializer service (which is not the serializer used by default). // Default: "json" + * context?: array, + * }, + * }, + * transports?: 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 + * max_retries?: int|Param, // Default: 3 + * delay?: int|Param, // Time in ms to delay (or the initial value when multiplier is used). // Default: 1000 + * multiplier?: float|Param, // If greater than 1, delay will grow exponentially for each retry: this delay = (delay * (multiple ^ retries)). // Default: 2 + * max_delay?: int|Param, // Max time in ms that a retry should ever be delayed (0 = infinite). // Default: 0 + * jitter?: float|Param, // Randomness to apply to the delay (between 0 and 1). // Default: 0.1 + * }, + * rate_limiter?: scalar|Param|null, // Rate limiter name to use when processing messages. // Default: null + * }>, + * failure_transport?: scalar|Param|null, // Transport name to send failed messages to (after all retries have failed). // Default: null + * stop_worker_on_signals?: int|string|list, + * default_bus?: scalar|Param|null, // Default: null + * buses?: array, + * }>, + * }>, + * }, + * scheduler?: bool|array{ // Scheduler configuration + * enabled?: bool|Param, // Default: false + * }, + * disallow_search_engine_index?: bool|Param, // Enabled by default when debug is enabled. // Default: true + * http_client?: bool|array{ // HTTP Client configuration + * enabled?: bool|Param, // Default: true + * max_host_connections?: int|Param, // The maximum number of connections to a single host. + * default_options?: array{ + * headers?: array, + * vars?: array, + * max_redirects?: int|Param, // The maximum number of redirects to follow. + * http_version?: scalar|Param|null, // The default HTTP version, typically 1.1 or 2.0, leave to null for the best version. + * resolve?: array, + * proxy?: scalar|Param|null, // The URL of the proxy to pass requests through or null for automatic detection. + * no_proxy?: scalar|Param|null, // A comma separated list of hosts that do not require a proxy to be reached. + * timeout?: float|Param, // The idle timeout, defaults to the "default_socket_timeout" ini parameter. + * max_duration?: float|Param, // The maximum execution time for the request+response as a whole. + * bindto?: scalar|Param|null, // A network interface name, IP address, a host name or a UNIX socket to bind to. + * verify_peer?: bool|Param, // Indicates if the peer should be verified in a TLS context. + * verify_host?: bool|Param, // Indicates if the host should exist as a certificate common name. + * cafile?: scalar|Param|null, // A certificate authority file. + * capath?: scalar|Param|null, // A directory that contains multiple certificate authority files. + * local_cert?: scalar|Param|null, // A PEM formatted certificate file. + * local_pk?: scalar|Param|null, // A private key file. + * passphrase?: scalar|Param|null, // The passphrase used to encrypt the "local_pk" file. + * ciphers?: scalar|Param|null, // A list of TLS ciphers separated by colons, commas or spaces (e.g. "RC3-SHA:TLS13-AES-128-GCM-SHA256"...) + * peer_fingerprint?: array{ // Associative array: hashing algorithm => hash(es). + * sha1?: mixed, + * pin-sha256?: mixed, + * md5?: mixed, + * }, + * crypto_method?: scalar|Param|null, // The minimum version of TLS to accept; must be one of STREAM_CRYPTO_METHOD_TLSv*_CLIENT constants. + * extra?: array, + * rate_limiter?: scalar|Param|null, // Rate limiter name to use for throttling requests. // Default: null + * caching?: bool|array{ // Caching configuration. + * enabled?: bool|Param, // Default: false + * cache_pool?: string|Param, // The taggable cache pool to use for storing the responses. // Default: "cache.http_client" + * shared?: bool|Param, // Indicates whether the cache is shared (public) or private. // Default: true + * max_ttl?: int|Param, // The maximum TTL (in seconds) allowed for cached responses. Null means no cap. // Default: null + * }, + * retry_failed?: bool|array{ + * enabled?: bool|Param, // Default: false + * retry_strategy?: scalar|Param|null, // service id to override the retry strategy. // Default: null + * http_codes?: int|string|array, + * }>, + * max_retries?: int|Param, // Default: 3 + * delay?: int|Param, // Time in ms to delay (or the initial value when multiplier is used). // Default: 1000 + * multiplier?: float|Param, // If greater than 1, delay will grow exponentially for each retry: delay * (multiple ^ retries). // Default: 2 + * max_delay?: int|Param, // Max time in ms that a retry should ever be delayed (0 = infinite). // Default: 0 + * jitter?: float|Param, // Randomness in percent (between 0 and 1) to apply to the delay. // Default: 0.1 + * }, + * }, + * mock_response_factory?: scalar|Param|null, // The id of the service that should generate mock responses. It should be either an invokable or an iterable. + * scoped_clients?: array, + * headers?: array, + * max_redirects?: int|Param, // The maximum number of redirects to follow. + * http_version?: scalar|Param|null, // The default HTTP version, typically 1.1 or 2.0, leave to null for the best version. + * resolve?: array, + * proxy?: scalar|Param|null, // The URL of the proxy to pass requests through or null for automatic detection. + * no_proxy?: scalar|Param|null, // A comma separated list of hosts that do not require a proxy to be reached. + * timeout?: float|Param, // The idle timeout, defaults to the "default_socket_timeout" ini parameter. + * max_duration?: float|Param, // The maximum execution time for the request+response as a whole. + * bindto?: scalar|Param|null, // A network interface name, IP address, a host name or a UNIX socket to bind to. + * verify_peer?: bool|Param, // Indicates if the peer should be verified in a TLS context. + * verify_host?: bool|Param, // Indicates if the host should exist as a certificate common name. + * cafile?: scalar|Param|null, // A certificate authority file. + * capath?: scalar|Param|null, // A directory that contains multiple certificate authority files. + * local_cert?: scalar|Param|null, // A PEM formatted certificate file. + * local_pk?: scalar|Param|null, // A private key file. + * passphrase?: scalar|Param|null, // The passphrase used to encrypt the "local_pk" file. + * ciphers?: scalar|Param|null, // A list of TLS ciphers separated by colons, commas or spaces (e.g. "RC3-SHA:TLS13-AES-128-GCM-SHA256"...). + * peer_fingerprint?: array{ // Associative array: hashing algorithm => hash(es). + * sha1?: mixed, + * pin-sha256?: mixed, + * md5?: mixed, + * }, + * crypto_method?: scalar|Param|null, // The minimum version of TLS to accept; must be one of STREAM_CRYPTO_METHOD_TLSv*_CLIENT constants. + * extra?: array, + * rate_limiter?: scalar|Param|null, // Rate limiter name to use for throttling requests. // Default: null + * caching?: bool|array{ // Caching configuration. + * enabled?: bool|Param, // Default: false + * cache_pool?: string|Param, // The taggable cache pool to use for storing the responses. // Default: "cache.http_client" + * shared?: bool|Param, // Indicates whether the cache is shared (public) or private. // Default: true + * max_ttl?: int|Param, // The maximum TTL (in seconds) allowed for cached responses. Null means no cap. // Default: null + * }, + * retry_failed?: bool|array{ + * enabled?: bool|Param, // Default: false + * retry_strategy?: scalar|Param|null, // service id to override the retry strategy. // Default: null + * http_codes?: int|string|array, + * }>, + * max_retries?: int|Param, // Default: 3 + * delay?: int|Param, // Time in ms to delay (or the initial value when multiplier is used). // Default: 1000 + * multiplier?: float|Param, // If greater than 1, delay will grow exponentially for each retry: delay * (multiple ^ retries). // Default: 2 + * max_delay?: int|Param, // Max time in ms that a retry should ever be delayed (0 = infinite). // Default: 0 + * jitter?: float|Param, // Randomness in percent (between 0 and 1) to apply to the delay. // Default: 0.1 + * }, + * }>, + * }, + * mailer?: bool|array{ // Mailer configuration + * enabled?: bool|Param, // Default: true + * message_bus?: scalar|Param|null, // The message bus to use. Defaults to the default bus if the Messenger component is installed. // Default: null + * dsn?: scalar|Param|null, // Default: null + * transports?: array, + * envelope?: array{ // Mailer Envelope configuration + * sender?: scalar|Param|null, + * recipients?: string|list, + * allowed_recipients?: string|list, + * }, + * headers?: array, + * dkim_signer?: bool|array{ // DKIM signer configuration + * enabled?: bool|Param, // Default: false + * key?: scalar|Param|null, // Key content, or path to key (in PEM format with the `file://` prefix) // Default: "" + * domain?: scalar|Param|null, // Default: "" + * select?: scalar|Param|null, // Default: "" + * passphrase?: scalar|Param|null, // The private key passphrase // Default: "" + * options?: array, + * }, + * smime_signer?: bool|array{ // S/MIME signer configuration + * enabled?: bool|Param, // Default: false + * key?: scalar|Param|null, // Path to key (in PEM format) // Default: "" + * certificate?: scalar|Param|null, // Path to certificate (in PEM format without the `file://` prefix) // Default: "" + * passphrase?: scalar|Param|null, // The private key passphrase // Default: null + * extra_certificates?: scalar|Param|null, // Default: null + * sign_options?: int|Param, // Default: null + * }, + * smime_encrypter?: bool|array{ // S/MIME encrypter configuration + * enabled?: bool|Param, // Default: false + * repository?: scalar|Param|null, // S/MIME certificate repository service. This service shall implement the `Symfony\Component\Mailer\EventListener\SmimeCertificateRepositoryInterface`. // Default: "" + * cipher?: int|Param, // A set of algorithms used to encrypt the message // Default: null + * }, + * }, + * secrets?: bool|array{ + * enabled?: bool|Param, // Default: true + * vault_directory?: scalar|Param|null, // Default: "%kernel.project_dir%/config/secrets/%kernel.runtime_environment%" + * local_dotenv_file?: scalar|Param|null, // Default: "%kernel.project_dir%/.env.%kernel.environment%.local" + * decryption_env_var?: scalar|Param|null, // Default: "base64:default::SYMFONY_DECRYPTION_SECRET" + * }, + * notifier?: bool|array{ // Notifier configuration + * enabled?: bool|Param, // Default: false + * message_bus?: scalar|Param|null, // The message bus to use. Defaults to the default bus if the Messenger component is installed. // Default: null + * chatter_transports?: array, + * texter_transports?: array, + * notification_on_failed_messages?: bool|Param, // Default: false + * channel_policy?: array>, + * admin_recipients?: list, + * }, + * rate_limiter?: bool|array{ // Rate limiter configuration + * enabled?: bool|Param, // Default: false + * limiters?: array, + * 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). + * rate?: array{ // Configures the fill rate if "policy" is set to "token_bucket". + * interval?: scalar|Param|null, // Configures the rate interval. The value must be a number followed by "second", "minute", "hour", "day", "week" or "month" (or their plural equivalent). + * amount?: int|Param, // Amount of tokens to add each interval. // Default: 1 + * }, + * }>, + * }, + * uid?: bool|array{ // Uid configuration + * enabled?: bool|Param, // Default: true + * default_uuid_version?: 7|6|4|1|Param, // Default: 7 + * name_based_uuid_version?: 5|3|Param, // Default: 5 + * name_based_uuid_namespace?: scalar|Param|null, + * time_based_uuid_version?: 7|6|1|Param, // Default: 7 + * time_based_uuid_node?: scalar|Param|null, + * }, + * html_sanitizer?: bool|array{ // HtmlSanitizer configuration + * enabled?: bool|Param, // Default: false + * sanitizers?: array, + * block_elements?: string|list, + * drop_elements?: string|list, + * allow_attributes?: array, + * drop_attributes?: array, + * force_attributes?: array>, + * force_https_urls?: bool|Param, // Transforms URLs using the HTTP scheme to use the HTTPS scheme instead. // Default: false + * allowed_link_schemes?: string|list, + * allowed_link_hosts?: null|string|list, + * allow_relative_links?: bool|Param, // Allows relative URLs to be used in links href attributes. // Default: false + * allowed_media_schemes?: string|list, + * allowed_media_hosts?: null|string|list, + * allow_relative_medias?: bool|Param, // Allows relative URLs to be used in media source attributes (img, audio, video, ...). // Default: false + * with_attribute_sanitizers?: string|list, + * without_attribute_sanitizers?: string|list, + * max_input_length?: int|Param, // The maximum length allowed for the sanitized input. // Default: 0 + * }>, + * }, + * webhook?: bool|array{ // Webhook configuration + * enabled?: bool|Param, // Default: false + * message_bus?: scalar|Param|null, // The message bus to use. // Default: "messenger.default_bus" + * routing?: array, + * }, + * remote-event?: bool|array{ // RemoteEvent configuration + * enabled?: bool|Param, // Default: false + * }, + * json_streamer?: bool|array{ // JSON streamer configuration + * enabled?: bool|Param, // Default: false + * }, + * } + * @psalm-type DoctrineConfig = array{ + * dbal?: array{ + * default_connection?: scalar|Param|null, + * types?: array, + * driver_schemes?: array, + * connections?: array, + * mapping_types?: array, + * default_table_options?: array, + * schema_manager_factory?: scalar|Param|null, // Default: "doctrine.dbal.default_schema_manager_factory" + * result_cache?: scalar|Param|null, + * replicas?: array, + * }>, + * }, + * orm?: array{ + * default_entity_manager?: scalar|Param|null, + * enable_native_lazy_objects?: bool|Param, // Deprecated: The "enable_native_lazy_objects" option is deprecated and will be removed in DoctrineBundle 4.0, as native lazy objects are now always enabled. // Default: true + * controller_resolver?: bool|array{ + * enabled?: bool|Param, // Default: true + * auto_mapping?: bool|Param, // Deprecated: The "doctrine.orm.controller_resolver.auto_mapping.auto_mapping" option is deprecated and will be removed in DoctrineBundle 4.0, as it only accepts `false` since 3.0. // Set to true to enable using route placeholders as lookup criteria when the primary key doesn't match the argument name // Default: false + * evict_cache?: bool|Param, // Set to true to fetch the entity from the database instead of using the cache, if any // Default: false + * }, + * entity_managers?: array, + * }>, + * }>, + * }, + * connection?: scalar|Param|null, + * class_metadata_factory_name?: scalar|Param|null, // Default: "Doctrine\\ORM\\Mapping\\ClassMetadataFactory" + * default_repository_class?: scalar|Param|null, // Default: "Doctrine\\ORM\\EntityRepository" + * auto_mapping?: scalar|Param|null, // Default: false + * naming_strategy?: scalar|Param|null, // Default: "doctrine.orm.naming_strategy.default" + * quote_strategy?: scalar|Param|null, // Default: "doctrine.orm.quote_strategy.default" + * typed_field_mapper?: scalar|Param|null, // Default: "doctrine.orm.typed_field_mapper.default" + * entity_listener_resolver?: scalar|Param|null, // Default: null + * fetch_mode_subselect_batch_size?: scalar|Param|null, + * repository_factory?: scalar|Param|null, // Default: "doctrine.orm.container_repository_factory" + * schema_ignore_classes?: list, + * validate_xml_mapping?: bool|Param, // Set to "true" to opt-in to the new mapping driver mode that was added in Doctrine ORM 2.14 and will be mandatory in ORM 3.0. See https://github.com/doctrine/orm/pull/6728. // Default: false + * second_level_cache?: array{ + * region_cache_driver?: string|array{ + * type?: scalar|Param|null, // Default: null + * id?: scalar|Param|null, + * pool?: scalar|Param|null, + * }, + * region_lock_lifetime?: scalar|Param|null, // Default: 60 + * log_enabled?: bool|Param, // Default: true + * region_lifetime?: scalar|Param|null, // Default: 3600 + * enabled?: bool|Param, // Default: true + * factory?: scalar|Param|null, + * regions?: array, + * loggers?: array, + * }, + * hydrators?: array, + * mappings?: array, + * dql?: array{ + * string_functions?: array, + * numeric_functions?: array, + * datetime_functions?: array, + * }, + * filters?: array, + * }>, + * identity_generation_preferences?: array, + * }>, + * resolve_target_entities?: array, + * }, + * } + * @psalm-type DoctrineMigrationsConfig = array{ + * enable_service_migrations?: bool|Param, // Whether to enable fetching migrations from the service container. // Default: false + * migrations_paths?: array, + * services?: array, + * factories?: array, + * storage?: array{ // Storage to use for migration status metadata. + * table_storage?: array{ // The default metadata storage, implemented as a table in the database. + * table_name?: scalar|Param|null, // Default: null + * version_column_name?: scalar|Param|null, // Default: null + * version_column_length?: scalar|Param|null, // Default: null + * executed_at_column_name?: scalar|Param|null, // Default: null + * execution_time_column_name?: scalar|Param|null, // Default: null + * }, + * }, + * migrations?: list, + * connection?: scalar|Param|null, // Connection name to use for the migrations database. // Default: null + * em?: scalar|Param|null, // Entity manager name to use for the migrations database (available when doctrine/orm is installed). // Default: null + * all_or_nothing?: scalar|Param|null, // Run all migrations in a transaction. // Default: false + * check_database_platform?: scalar|Param|null, // Adds an extra check in the generated migrations to allow execution only on the same platform as they were initially generated on. // Default: true + * custom_template?: scalar|Param|null, // Custom template path for generated migration classes. // Default: null + * organize_migrations?: scalar|Param|null, // Organize migrations mode. Possible values are: "BY_YEAR", "BY_YEAR_AND_MONTH", false // Default: false + * enable_profiler?: bool|Param, // Whether or not to enable the profiler collector to calculate and visualize migration status. This adds some queries overhead. // Default: false + * transactional?: bool|Param, // Whether or not to wrap migrations in a single transaction. // Default: true + * } + * @psalm-type SecurityConfig = array{ + * access_denied_url?: scalar|Param|null, // Default: null + * session_fixation_strategy?: "none"|"migrate"|"invalidate"|Param, // Default: "migrate" + * expose_security_errors?: \Symfony\Component\Security\Http\Authentication\ExposeSecurityLevel::None|\Symfony\Component\Security\Http\Authentication\ExposeSecurityLevel::AccountStatus|\Symfony\Component\Security\Http\Authentication\ExposeSecurityLevel::All|Param, // Default: "none" + * erase_credentials?: bool|Param, // Default: true + * access_decision_manager?: array{ + * strategy?: "affirmative"|"consensus"|"unanimous"|"priority"|Param, + * service?: scalar|Param|null, + * strategy_service?: scalar|Param|null, + * allow_if_all_abstain?: bool|Param, // Default: false + * allow_if_equal_granted_denied?: bool|Param, // Default: true + * }, + * password_hashers?: array, + * hash_algorithm?: scalar|Param|null, // Name of hashing algorithm for PBKDF2 (i.e. sha256, sha512, etc..) See hash_algos() for a list of supported algorithms. // Default: "sha512" + * key_length?: scalar|Param|null, // Default: 40 + * ignore_case?: bool|Param, // Default: false + * encode_as_base64?: bool|Param, // Default: true + * iterations?: scalar|Param|null, // Default: 5000 + * cost?: int|Param, // Default: null + * memory_cost?: scalar|Param|null, // Default: null + * time_cost?: scalar|Param|null, // Default: null + * id?: scalar|Param|null, + * }>, + * providers?: array, + * }, + * entity?: array{ + * class?: scalar|Param|null, // The full entity class name of your user class. + * property?: scalar|Param|null, // Default: null + * manager_name?: scalar|Param|null, // Default: null + * }, + * memory?: array{ + * users?: array, + * }>, + * }, + * ldap?: array{ + * service?: scalar|Param|null, + * base_dn?: scalar|Param|null, + * search_dn?: scalar|Param|null, // Default: null + * search_password?: scalar|Param|null, // Default: null + * extra_fields?: list, + * default_roles?: string|list, + * role_fetcher?: scalar|Param|null, // Default: null + * uid_key?: scalar|Param|null, // Default: "sAMAccountName" + * filter?: scalar|Param|null, // Default: "({uid_key}={user_identifier})" + * password_attribute?: scalar|Param|null, // Default: null + * }, + * }>, + * firewalls?: array, + * security?: bool|Param, // Default: true + * user_checker?: scalar|Param|null, // The UserChecker to use when authenticating users in this firewall. // Default: "security.user_checker" + * request_matcher?: scalar|Param|null, + * access_denied_url?: scalar|Param|null, + * access_denied_handler?: scalar|Param|null, + * entry_point?: scalar|Param|null, // An enabled authenticator name or a service id that implements "Symfony\Component\Security\Http\EntryPoint\AuthenticationEntryPointInterface". + * provider?: scalar|Param|null, + * stateless?: bool|Param, // Default: false + * lazy?: bool|Param, // Default: false + * context?: scalar|Param|null, + * logout?: array{ + * enable_csrf?: bool|Param|null, // Default: null + * csrf_token_id?: scalar|Param|null, // Default: "logout" + * csrf_parameter?: scalar|Param|null, // Default: "_csrf_token" + * csrf_token_manager?: scalar|Param|null, + * path?: scalar|Param|null, // Default: "/logout" + * target?: scalar|Param|null, // Default: "/" + * invalidate_session?: bool|Param, // Default: true + * clear_site_data?: string|list<"*"|"cache"|"cookies"|"storage"|"executionContexts"|Param>, + * delete_cookies?: string|array, + * }, + * switch_user?: array{ + * provider?: scalar|Param|null, + * parameter?: scalar|Param|null, // Default: "_switch_user" + * role?: scalar|Param|null, // Default: "ROLE_ALLOWED_TO_SWITCH" + * target_route?: scalar|Param|null, // Default: null + * }, + * required_badges?: list, + * custom_authenticators?: list, + * login_throttling?: array{ + * limiter?: scalar|Param|null, // A service id implementing "Symfony\Component\HttpFoundation\RateLimiter\RequestRateLimiterInterface". + * max_attempts?: int|Param, // Default: 5 + * interval?: scalar|Param|null, // Default: "1 minute" + * lock_factory?: scalar|Param|null, // The service ID of the lock factory used by the login rate limiter (or null to disable locking). // Default: null + * cache_pool?: string|Param, // The cache pool to use for storing the limiter state // Default: "cache.rate_limiter" + * storage_service?: string|Param, // The service ID of a custom storage implementation, this precedes any configured "cache_pool" // Default: null + * }, + * x509?: array{ + * provider?: scalar|Param|null, + * user?: scalar|Param|null, // Default: "SSL_CLIENT_S_DN_Email" + * credentials?: scalar|Param|null, // Default: "SSL_CLIENT_S_DN" + * user_identifier?: scalar|Param|null, // Default: "emailAddress" + * }, + * remote_user?: array{ + * provider?: scalar|Param|null, + * user?: scalar|Param|null, // Default: "REMOTE_USER" + * }, + * login_link?: array{ + * check_route?: scalar|Param|null, // Route that will validate the login link - e.g. "app_login_link_verify". + * check_post_only?: scalar|Param|null, // If true, only HTTP POST requests to "check_route" will be handled by the authenticator. // Default: false + * signature_properties?: list, + * lifetime?: int|Param, // The lifetime of the login link in seconds. // Default: 600 + * max_uses?: int|Param, // Max number of times a login link can be used - null means unlimited within lifetime. // Default: null + * used_link_cache?: scalar|Param|null, // Cache service id used to expired links of max_uses is set. + * success_handler?: scalar|Param|null, // A service id that implements Symfony\Component\Security\Http\Authentication\AuthenticationSuccessHandlerInterface. + * failure_handler?: scalar|Param|null, // A service id that implements Symfony\Component\Security\Http\Authentication\AuthenticationFailureHandlerInterface. + * provider?: scalar|Param|null, // The user provider to load users from. + * secret?: scalar|Param|null, // Default: "%kernel.secret%" + * always_use_default_target_path?: bool|Param, // Default: false + * default_target_path?: scalar|Param|null, // Default: "/" + * login_path?: scalar|Param|null, // Default: "/login" + * target_path_parameter?: scalar|Param|null, // Default: "_target_path" + * use_referer?: bool|Param, // Default: false + * failure_path?: scalar|Param|null, // Default: null + * failure_forward?: bool|Param, // Default: false + * failure_path_parameter?: scalar|Param|null, // Default: "_failure_path" + * }, + * form_login?: array{ + * provider?: scalar|Param|null, + * remember_me?: bool|Param, // Default: true + * success_handler?: scalar|Param|null, + * failure_handler?: scalar|Param|null, + * check_path?: scalar|Param|null, // Default: "/login_check" + * use_forward?: bool|Param, // Default: false + * login_path?: scalar|Param|null, // Default: "/login" + * username_parameter?: scalar|Param|null, // Default: "_username" + * password_parameter?: scalar|Param|null, // Default: "_password" + * csrf_parameter?: scalar|Param|null, // Default: "_csrf_token" + * csrf_token_id?: scalar|Param|null, // Default: "authenticate" + * enable_csrf?: bool|Param, // Default: false + * post_only?: bool|Param, // Default: true + * form_only?: bool|Param, // Default: false + * always_use_default_target_path?: bool|Param, // Default: false + * default_target_path?: scalar|Param|null, // Default: "/" + * target_path_parameter?: scalar|Param|null, // Default: "_target_path" + * use_referer?: bool|Param, // Default: false + * failure_path?: scalar|Param|null, // Default: null + * failure_forward?: bool|Param, // Default: false + * failure_path_parameter?: scalar|Param|null, // Default: "_failure_path" + * }, + * form_login_ldap?: array{ + * provider?: scalar|Param|null, + * remember_me?: bool|Param, // Default: true + * success_handler?: scalar|Param|null, + * failure_handler?: scalar|Param|null, + * check_path?: scalar|Param|null, // Default: "/login_check" + * use_forward?: bool|Param, // Default: false + * login_path?: scalar|Param|null, // Default: "/login" + * username_parameter?: scalar|Param|null, // Default: "_username" + * password_parameter?: scalar|Param|null, // Default: "_password" + * csrf_parameter?: scalar|Param|null, // Default: "_csrf_token" + * csrf_token_id?: scalar|Param|null, // Default: "authenticate" + * enable_csrf?: bool|Param, // Default: false + * post_only?: bool|Param, // Default: true + * form_only?: bool|Param, // Default: false + * always_use_default_target_path?: bool|Param, // Default: false + * default_target_path?: scalar|Param|null, // Default: "/" + * target_path_parameter?: scalar|Param|null, // Default: "_target_path" + * use_referer?: bool|Param, // Default: false + * failure_path?: scalar|Param|null, // Default: null + * failure_forward?: bool|Param, // Default: false + * failure_path_parameter?: scalar|Param|null, // Default: "_failure_path" + * service?: scalar|Param|null, // Default: "ldap" + * dn_string?: scalar|Param|null, // Default: "{user_identifier}" + * query_string?: scalar|Param|null, + * search_dn?: scalar|Param|null, // Default: "" + * search_password?: scalar|Param|null, // Default: "" + * }, + * json_login?: array{ + * provider?: scalar|Param|null, + * remember_me?: bool|Param, // Default: true + * success_handler?: scalar|Param|null, + * failure_handler?: scalar|Param|null, + * check_path?: scalar|Param|null, // Default: "/login_check" + * use_forward?: bool|Param, // Default: false + * login_path?: scalar|Param|null, // Default: "/login" + * username_path?: scalar|Param|null, // Default: "username" + * password_path?: scalar|Param|null, // Default: "password" + * }, + * json_login_ldap?: array{ + * provider?: scalar|Param|null, + * remember_me?: bool|Param, // Default: true + * success_handler?: scalar|Param|null, + * failure_handler?: scalar|Param|null, + * check_path?: scalar|Param|null, // Default: "/login_check" + * use_forward?: bool|Param, // Default: false + * login_path?: scalar|Param|null, // Default: "/login" + * username_path?: scalar|Param|null, // Default: "username" + * password_path?: scalar|Param|null, // Default: "password" + * service?: scalar|Param|null, // Default: "ldap" + * dn_string?: scalar|Param|null, // Default: "{user_identifier}" + * query_string?: scalar|Param|null, + * search_dn?: scalar|Param|null, // Default: "" + * search_password?: scalar|Param|null, // Default: "" + * }, + * access_token?: array{ + * provider?: scalar|Param|null, + * remember_me?: bool|Param, // Default: true + * success_handler?: scalar|Param|null, + * failure_handler?: scalar|Param|null, + * realm?: scalar|Param|null, // Default: null + * token_extractors?: string|list, + * token_handler?: string|array{ + * id?: scalar|Param|null, + * oidc_user_info?: string|array{ + * base_uri?: scalar|Param|null, // Base URI of the userinfo endpoint on the OIDC server, or the OIDC server URI to use the discovery (require "discovery" to be configured). + * discovery?: array{ // Enable the OIDC discovery. + * cache?: array{ + * id?: scalar|Param|null, // Cache service id to use to cache the OIDC discovery configuration. + * }, + * }, + * claim?: scalar|Param|null, // Claim which contains the user identifier (e.g. sub, email, etc.). // Default: "sub" + * client?: scalar|Param|null, // HttpClient service id to use to call the OIDC server. + * }, + * oidc?: array{ + * discovery?: array{ // Enable the OIDC discovery. + * base_uri?: string|list, + * cache?: array{ + * id?: scalar|Param|null, // Cache service id to use to cache the OIDC discovery configuration. + * }, + * }, + * claim?: scalar|Param|null, // Claim which contains the user identifier (e.g.: sub, email..). // Default: "sub" + * audience?: scalar|Param|null, // Audience set in the token, for validation purpose. + * issuers?: list, + * algorithms?: list, + * keyset?: scalar|Param|null, // JSON-encoded JWKSet used to sign the token (must contain a list of valid public keys). + * encryption?: bool|array{ + * enabled?: bool|Param, // Default: false + * enforce?: bool|Param, // When enabled, the token shall be encrypted. // Default: false + * algorithms?: list, + * keyset?: scalar|Param|null, // JSON-encoded JWKSet used to decrypt the token (must contain a list of valid private keys). + * }, + * }, + * cas?: array{ + * validation_url?: scalar|Param|null, // CAS server validation URL + * prefix?: scalar|Param|null, // CAS prefix // Default: "cas" + * http_client?: scalar|Param|null, // HTTP Client service // Default: null + * }, + * oauth2?: scalar|Param|null, + * }, + * }, + * http_basic?: array{ + * provider?: scalar|Param|null, + * realm?: scalar|Param|null, // Default: "Secured Area" + * }, + * http_basic_ldap?: array{ + * provider?: scalar|Param|null, + * realm?: scalar|Param|null, // Default: "Secured Area" + * service?: scalar|Param|null, // Default: "ldap" + * dn_string?: scalar|Param|null, // Default: "{user_identifier}" + * query_string?: scalar|Param|null, + * search_dn?: scalar|Param|null, // Default: "" + * search_password?: scalar|Param|null, // Default: "" + * }, + * remember_me?: array{ + * secret?: scalar|Param|null, // Default: "%kernel.secret%" + * service?: scalar|Param|null, + * user_providers?: string|list, + * catch_exceptions?: bool|Param, // Default: true + * signature_properties?: list, + * token_provider?: string|array{ + * service?: scalar|Param|null, // The service ID of a custom remember-me token provider. + * doctrine?: bool|array{ + * enabled?: bool|Param, // Default: false + * connection?: scalar|Param|null, // Default: null + * }, + * }, + * token_verifier?: scalar|Param|null, // The service ID of a custom rememberme token verifier. + * name?: scalar|Param|null, // Default: "REMEMBERME" + * lifetime?: int|Param, // Default: 31536000 + * path?: scalar|Param|null, // Default: "/" + * domain?: scalar|Param|null, // Default: null + * secure?: true|false|"auto"|Param, // Default: false + * httponly?: bool|Param, // Default: true + * samesite?: null|"lax"|"strict"|"none"|Param, // Default: null + * always_remember_me?: bool|Param, // Default: false + * remember_me_parameter?: scalar|Param|null, // Default: "_remember_me" + * }, + * two_factor?: array{ + * check_path?: scalar|Param|null, // Default: "/2fa_check" + * post_only?: bool|Param, // Default: true + * auth_form_path?: scalar|Param|null, // Default: "/2fa" + * always_use_default_target_path?: bool|Param, // Default: false + * default_target_path?: scalar|Param|null, // Default: "/" + * success_handler?: scalar|Param|null, // Default: null + * failure_handler?: scalar|Param|null, // Default: null + * authentication_required_handler?: scalar|Param|null, // Default: null + * auth_code_parameter_name?: scalar|Param|null, // Default: "_auth_code" + * trusted_parameter_name?: scalar|Param|null, // Default: "_trusted" + * remember_me_sets_trusted?: scalar|Param|null, // Default: false + * multi_factor?: bool|Param, // Default: false + * prepare_on_login?: bool|Param, // Default: false + * prepare_on_access_denied?: bool|Param, // Default: false + * enable_csrf?: scalar|Param|null, // Default: false + * csrf_parameter?: scalar|Param|null, // Default: "_csrf_token" + * csrf_token_id?: scalar|Param|null, // Default: "two_factor" + * csrf_header?: scalar|Param|null, // Default: null + * csrf_token_manager?: scalar|Param|null, // Default: "scheb_two_factor.csrf_token_manager" + * provider?: scalar|Param|null, // Default: null + * }, + * }>, + * access_control?: list, + * attributes?: array, + * route?: scalar|Param|null, // Default: null + * methods?: string|list, + * allow_if?: scalar|Param|null, // Default: null + * roles?: string|list, + * }>, + * role_hierarchy?: array>, + * } + * @psalm-type TwigConfig = array{ + * form_themes?: list, + * globals?: array, + * autoescape_service?: scalar|Param|null, // Default: null + * autoescape_service_method?: scalar|Param|null, // Default: null + * cache?: scalar|Param|null, // Default: true + * charset?: scalar|Param|null, // Default: "%kernel.charset%" + * debug?: bool|Param, // Default: "%kernel.debug%" + * strict_variables?: bool|Param, // Default: "%kernel.debug%" + * auto_reload?: scalar|Param|null, + * optimizations?: int|Param, + * default_path?: scalar|Param|null, // The default path used to load templates. // Default: "%kernel.project_dir%/templates" + * file_name_pattern?: string|list, + * paths?: array, + * date?: array{ // The default format options used by the date filter. + * format?: scalar|Param|null, // Default: "F j, Y H:i" + * interval_format?: scalar|Param|null, // Default: "%d days" + * timezone?: scalar|Param|null, // The timezone used when formatting dates, when set to null, the timezone returned by date_default_timezone_get() is used. // Default: null + * }, + * number_format?: array{ // The default format options for the number_format filter. + * decimals?: int|Param, // Default: 0 + * decimal_point?: scalar|Param|null, // Default: "." + * thousands_separator?: scalar|Param|null, // Default: "," + * }, + * mailer?: array{ + * html_to_text_converter?: scalar|Param|null, // A service implementing the "Symfony\Component\Mime\HtmlToTextConverter\HtmlToTextConverterInterface". // Default: null + * }, + * } + * @psalm-type TwigExtraConfig = array{ + * cache?: bool|array{ + * enabled?: bool|Param, // Default: false + * }, + * html?: bool|array{ + * enabled?: bool|Param, // Default: true + * }, + * markdown?: bool|array{ + * enabled?: bool|Param, // Default: false + * }, + * intl?: bool|array{ + * enabled?: bool|Param, // Default: false + * }, + * cssinliner?: bool|array{ + * enabled?: bool|Param, // Default: false + * }, + * inky?: bool|array{ + * enabled?: bool|Param, // Default: false + * }, + * string?: bool|array{ + * enabled?: bool|Param, // Default: false + * }, + * commonmark?: array{ + * renderer?: array{ // Array of options for rendering HTML. + * block_separator?: scalar|Param|null, + * inner_separator?: scalar|Param|null, + * soft_break?: scalar|Param|null, + * }, + * html_input?: "strip"|"allow"|"escape"|Param, // How to handle HTML input. + * allow_unsafe_links?: bool|Param, // Remove risky link and image URLs by setting this to false. // Default: true + * max_nesting_level?: int|Param, // The maximum nesting level for blocks. // Default: 9223372036854775807 + * max_delimiters_per_line?: int|Param, // The maximum number of strong/emphasis delimiters per line. // Default: 9223372036854775807 + * slug_normalizer?: array{ // Array of options for configuring how URL-safe slugs are created. + * instance?: mixed, + * max_length?: int|Param, // Default: 255 + * unique?: mixed, + * }, + * commonmark?: array{ // Array of options for configuring the CommonMark core extension. + * enable_em?: bool|Param, // Default: true + * enable_strong?: bool|Param, // Default: true + * use_asterisk?: bool|Param, // Default: true + * use_underscore?: bool|Param, // Default: true + * unordered_list_markers?: list, + * }, + * ... + * }, + * } + * @psalm-type TwigComponentConfig = array{ + * defaults?: array, + * anonymous_template_directory?: scalar|Param|null, // Defaults to `components` + * profiler?: bool|array{ // Enables the profiler for Twig Component + * enabled?: bool|Param, // Default: "%kernel.debug%" + * collect_components?: bool|Param, // Collect components instances // Default: true + * }, + * } + * @psalm-type EndroidQrCodeConfig = array{ + * route_prefix?: scalar|Param|null, // Default: "/qr-code" + * route_enabled?: bool|Param, // Default: true + * builders?: array>, + * } + * @psalm-type SchebTwoFactorConfig = array{ + * persister?: scalar|Param|null, // Default: "scheb_two_factor.persister.doctrine" + * model_manager_name?: scalar|Param|null, // Default: null + * security_tokens?: list, + * ip_whitelist?: list, + * ip_whitelist_provider?: scalar|Param|null, // Default: "scheb_two_factor.default_ip_whitelist_provider" + * two_factor_token_factory?: scalar|Param|null, // Default: "scheb_two_factor.default_token_factory" + * two_factor_provider_decider?: scalar|Param|null, // Default: "scheb_two_factor.default_provider_decider" + * two_factor_condition?: scalar|Param|null, // Default: null + * code_reuse_cache?: scalar|Param|null, // Default: null + * code_reuse_cache_duration?: int|Param, // Default: 60 + * code_reuse_default_handler?: scalar|Param|null, // Default: null + * backup_codes?: bool|array{ + * enabled?: scalar|Param|null, // Default: false + * manager?: scalar|Param|null, // Default: "scheb_two_factor.default_backup_code_manager" + * }, + * totp?: bool|array{ + * enabled?: scalar|Param|null, // Default: false + * form_renderer?: scalar|Param|null, // Default: null + * issuer?: scalar|Param|null, // Default: null + * server_name?: scalar|Param|null, // Default: null + * leeway?: int|Param, // Default: 0 + * parameters?: list, + * template?: scalar|Param|null, // Default: "@SchebTwoFactor/Authentication/form.html.twig" + * }, + * } + * @psalm-type MonologConfig = array{ + * use_microseconds?: scalar|Param|null, // Default: true + * channels?: list, + * handlers?: array, + * }>, + * accepted_levels?: list, + * min_level?: scalar|Param|null, // Default: "DEBUG" + * max_level?: scalar|Param|null, // Default: "EMERGENCY" + * buffer_size?: scalar|Param|null, // Default: 0 + * flush_on_overflow?: bool|Param, // Default: false + * handler?: scalar|Param|null, + * url?: scalar|Param|null, + * exchange?: scalar|Param|null, + * exchange_name?: scalar|Param|null, // Default: "log" + * channel?: scalar|Param|null, // Default: null + * bot_name?: scalar|Param|null, // Default: "Monolog" + * use_attachment?: scalar|Param|null, // Default: true + * use_short_attachment?: scalar|Param|null, // Default: false + * include_extra?: scalar|Param|null, // Default: false + * icon_emoji?: scalar|Param|null, // Default: null + * webhook_url?: scalar|Param|null, + * exclude_fields?: list, + * token?: scalar|Param|null, + * region?: scalar|Param|null, + * source?: scalar|Param|null, + * use_ssl?: bool|Param, // Default: true + * user?: mixed, + * title?: scalar|Param|null, // Default: null + * host?: scalar|Param|null, // Default: null + * port?: scalar|Param|null, // Default: 514 + * config?: list, + * members?: list, + * connection_string?: scalar|Param|null, + * timeout?: scalar|Param|null, + * time?: scalar|Param|null, // Default: 60 + * deduplication_level?: scalar|Param|null, // Default: 400 + * store?: scalar|Param|null, // Default: null + * connection_timeout?: scalar|Param|null, + * persistent?: bool|Param, + * message_type?: scalar|Param|null, // Default: 0 + * parse_mode?: scalar|Param|null, // Default: null + * disable_webpage_preview?: bool|Param|null, // Default: null + * disable_notification?: bool|Param|null, // Default: null + * split_long_messages?: bool|Param, // Default: false + * delay_between_messages?: bool|Param, // Default: false + * topic?: int|Param, // Default: null + * factor?: int|Param, // Default: 1 + * tags?: string|list, + * console_formatter_options?: mixed, // Default: [] + * formatter?: scalar|Param|null, + * nested?: bool|Param, // Default: false + * publisher?: string|array{ + * id?: scalar|Param|null, + * hostname?: scalar|Param|null, + * port?: scalar|Param|null, // Default: 12201 + * chunk_size?: scalar|Param|null, // Default: 1420 + * encoder?: "json"|"compressed_json"|Param, + * }, + * mongodb?: string|array{ + * id?: scalar|Param|null, // ID of a MongoDB\Client service + * uri?: scalar|Param|null, + * username?: scalar|Param|null, + * password?: scalar|Param|null, + * database?: scalar|Param|null, // Default: "monolog" + * collection?: scalar|Param|null, // Default: "logs" + * }, + * elasticsearch?: string|array{ + * id?: scalar|Param|null, + * hosts?: list, + * host?: scalar|Param|null, + * port?: scalar|Param|null, // Default: 9200 + * transport?: scalar|Param|null, // Default: "Http" + * user?: scalar|Param|null, // Default: null + * password?: scalar|Param|null, // Default: null + * }, + * index?: scalar|Param|null, // Default: "monolog" + * document_type?: scalar|Param|null, // Default: "logs" + * ignore_error?: scalar|Param|null, // Default: false + * redis?: string|array{ + * id?: scalar|Param|null, + * host?: scalar|Param|null, + * password?: scalar|Param|null, // Default: null + * port?: scalar|Param|null, // Default: 6379 + * database?: scalar|Param|null, // Default: 0 + * key_name?: scalar|Param|null, // Default: "monolog_redis" + * }, + * predis?: string|array{ + * id?: scalar|Param|null, + * host?: scalar|Param|null, + * }, + * from_email?: scalar|Param|null, + * to_email?: string|list, + * subject?: scalar|Param|null, + * content_type?: scalar|Param|null, // Default: null + * headers?: list, + * mailer?: scalar|Param|null, // Default: null + * email_prototype?: string|array{ + * id?: scalar|Param|null, + * method?: scalar|Param|null, // Default: null + * }, + * verbosity_levels?: array{ + * VERBOSITY_QUIET?: scalar|Param|null, // Default: "ERROR" + * VERBOSITY_NORMAL?: scalar|Param|null, // Default: "WARNING" + * VERBOSITY_VERBOSE?: scalar|Param|null, // Default: "NOTICE" + * VERBOSITY_VERY_VERBOSE?: scalar|Param|null, // Default: "INFO" + * VERBOSITY_DEBUG?: scalar|Param|null, // Default: "DEBUG" + * }, + * channels?: string|array{ + * type?: scalar|Param|null, + * elements?: list, + * }, + * }>, + * } + * @psalm-type ConfigType = array{ + * imports?: ImportsConfig, + * parameters?: ParametersConfig, + * services?: ServicesConfig, + * framework?: FrameworkConfig, + * doctrine?: DoctrineConfig, + * doctrine_migrations?: DoctrineMigrationsConfig, + * security?: SecurityConfig, + * twig?: TwigConfig, + * twig_extra?: TwigExtraConfig, + * twig_component?: TwigComponentConfig, + * endroid_qr_code?: EndroidQrCodeConfig, + * scheb_two_factor?: SchebTwoFactorConfig, + * monolog?: MonologConfig, + * "when@dev"?: array{ + * imports?: ImportsConfig, + * parameters?: ParametersConfig, + * services?: ServicesConfig, + * framework?: FrameworkConfig, + * doctrine?: DoctrineConfig, + * doctrine_migrations?: DoctrineMigrationsConfig, + * security?: SecurityConfig, + * twig?: TwigConfig, + * twig_extra?: TwigExtraConfig, + * twig_component?: TwigComponentConfig, + * endroid_qr_code?: EndroidQrCodeConfig, + * scheb_two_factor?: SchebTwoFactorConfig, + * monolog?: MonologConfig, + * }, + * "when@prod"?: array{ + * imports?: ImportsConfig, + * parameters?: ParametersConfig, + * services?: ServicesConfig, + * framework?: FrameworkConfig, + * doctrine?: DoctrineConfig, + * doctrine_migrations?: DoctrineMigrationsConfig, + * security?: SecurityConfig, + * twig?: TwigConfig, + * twig_extra?: TwigExtraConfig, + * twig_component?: TwigComponentConfig, + * endroid_qr_code?: EndroidQrCodeConfig, + * scheb_two_factor?: SchebTwoFactorConfig, + * monolog?: MonologConfig, + * }, + * "when@test"?: array{ + * imports?: ImportsConfig, + * parameters?: ParametersConfig, + * services?: ServicesConfig, + * framework?: FrameworkConfig, + * doctrine?: DoctrineConfig, + * doctrine_migrations?: DoctrineMigrationsConfig, + * security?: SecurityConfig, + * twig?: TwigConfig, + * twig_extra?: TwigExtraConfig, + * twig_component?: TwigComponentConfig, + * endroid_qr_code?: EndroidQrCodeConfig, + * scheb_two_factor?: SchebTwoFactorConfig, + * monolog?: MonologConfig, + * }, + * ..., + * }> + * } + */ +final class App +{ + /** + * @param ConfigType $config + * + * @psalm-return ConfigType + */ + public static function config(array $config): array + { + /** @var ConfigType $config */ + $config = AppReference::config($config); + + return $config; + } +} + +namespace Symfony\Component\Routing\Loader\Configurator; + +/** + * This class provides array-shapes for configuring the routes of an application. + * + * Example: + * + * ```php + * // config/routes.php + * namespace Symfony\Component\Routing\Loader\Configurator; + * + * return Routes::config([ + * 'controllers' => [ + * 'resource' => 'routing.controllers', + * ], + * ]); + * ``` + * + * @psalm-type RouteConfig = array{ + * path: string|array, + * controller?: string, + * methods?: string|list, + * requirements?: array, + * defaults?: array, + * options?: array, + * host?: string|array, + * schemes?: string|list, + * condition?: string, + * locale?: string, + * format?: string, + * utf8?: bool, + * stateless?: bool, + * } + * @psalm-type ImportConfig = array{ + * resource: string, + * type?: string, + * exclude?: string|list, + * prefix?: string|array, + * name_prefix?: string, + * trailing_slash_on_root?: bool, + * controller?: string, + * methods?: string|list, + * requirements?: array, + * defaults?: array, + * options?: array, + * host?: string|array, + * schemes?: string|list, + * condition?: string, + * locale?: string, + * format?: string, + * utf8?: bool, + * stateless?: bool, + * } + * @psalm-type AliasConfig = array{ + * alias: string, + * deprecated?: array{package:string, version:string, message?:string}, + * } + * @psalm-type RoutesConfig = array{ + * "when@dev"?: array, + * "when@prod"?: array, + * "when@test"?: array, + * ... + * } + */ +final class Routes +{ + /** + * @param RoutesConfig $config + * + * @psalm-return RoutesConfig + */ + public static function config(array $config): array + { + return $config; + } +} diff --git a/config/routes/api.yaml b/config/routes/api.yaml deleted file mode 100644 index e09c54d..0000000 --- a/config/routes/api.yaml +++ /dev/null @@ -1,4 +0,0 @@ -api: - resource: '../src/Infrastructure/Http/Controller/Api/' - type: attribute - prefix: /api diff --git a/docker/app/www.conf b/docker/app/www.conf deleted file mode 100644 index 2e9ba03..0000000 --- a/docker/app/www.conf +++ /dev/null @@ -1,3 +0,0 @@ -[www] -listen = /var/run/php/php-fpm.sock -listen.mode = 0660 diff --git a/migrations/Version20260514053908.php b/migrations/Version20260514053908.php new file mode 100644 index 0000000..9048c79 --- /dev/null +++ b/migrations/Version20260514053908.php @@ -0,0 +1,30 @@ +addSql("ALTER TABLE app.api_keys ADD key_prefix VARCHAR(8) NOT NULL DEFAULT ''"); + $this->addSql('ALTER TABLE app.api_keys ALTER COLUMN key_prefix DROP DEFAULT'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE app.api_keys DROP key_prefix'); + } +} diff --git a/migrations/Version20260514054123.php b/migrations/Version20260514054123.php new file mode 100644 index 0000000..f3628c0 --- /dev/null +++ b/migrations/Version20260514054123.php @@ -0,0 +1,39 @@ +addSql('CREATE SCHEMA IF NOT EXISTS logs'); + $this->addSql('CREATE SCHEMA IF NOT EXISTS logs_archive'); + $this->addSql('CREATE SEQUENCE logs.log_entries_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE TABLE logs.log_entries (id BIGINT NOT NULL, channel VARCHAR(50) NOT NULL, level INT NOT NULL, level_name VARCHAR(50) NOT NULL, message TEXT NOT NULL, context JSON NOT NULL, extra JSON NOT NULL, logged_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))'); + $this->addSql('COMMENT ON COLUMN logs.log_entries.logged_at IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('CREATE INDEX idx_log_entries_channel ON logs.log_entries (channel)'); + $this->addSql('CREATE INDEX idx_log_entries_level ON logs.log_entries (level)'); + $this->addSql('CREATE INDEX idx_log_entries_logged_at ON logs.log_entries (logged_at)'); + $this->addSql('ALTER SEQUENCE logs.log_entries_id_seq OWNED BY logs.log_entries.id'); + $this->addSql('ALTER TABLE logs.log_entries ALTER id SET DEFAULT nextval(\'logs.log_entries_id_seq\')'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP TABLE logs.log_entries'); + $this->addSql('DROP SEQUENCE logs.log_entries_id_seq CASCADE'); + } +} diff --git a/migrations/Version20260514054903.php b/migrations/Version20260514054903.php new file mode 100644 index 0000000..dfec839 --- /dev/null +++ b/migrations/Version20260514054903.php @@ -0,0 +1,31 @@ +addSql('ALTER TABLE app.ai_pipeline_jobs DROP CONSTRAINT IF EXISTS fk_4a59a35d7294869c'); + $this->addSql('DROP INDEX IF EXISTS app.idx_4a59a35d7294869c'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE app.ai_pipeline_jobs ADD CONSTRAINT fk_4a59a35d7294869c FOREIGN KEY (article_id) REFERENCES app.articles (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('CREATE INDEX idx_4a59a35d7294869c ON app.ai_pipeline_jobs (article_id)'); + } +} diff --git a/src/Application/Article/ArticleService.php b/src/Application/Article/ArticleService.php index 8240789..7eb9366 100644 --- a/src/Application/Article/ArticleService.php +++ b/src/Application/Article/ArticleService.php @@ -10,7 +10,9 @@ use App\Domain\Article\ArticleStatus; use App\Domain\Article\AttributeValue; use App\Domain\Article\Repository\ArticleRepositoryInterface; use App\Domain\Article\Repository\ArticleTypeRepositoryInterface; +use App\Infrastructure\Messenger\Message\PublishToChannelMessage; use Doctrine\DBAL\Connection; +use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Component\Uid\Uuid; final class ArticleService @@ -20,6 +22,7 @@ final class ArticleService private readonly ArticleTypeRepositoryInterface $articleTypeRepository, private readonly ArticleValidator $validator, private readonly Connection $connection, + private readonly MessageBusInterface $bus, ) { } @@ -28,11 +31,12 @@ final class ArticleService ArticleCondition $condition, int $stock = 1, ?string $conditionNotes = null, + ?string $inventoryNumber = null, ): Article { $articleType = $this->articleTypeRepository->findById($articleTypeId) ?? throw new \DomainException("ArticleType {$articleTypeId->toRfc4122()} not found"); - $inventoryNumber = $this->nextInventoryNumber(); + $inventoryNumber = $inventoryNumber ?? $this->nextInventoryNumber(); $sku = 'ART-'.mb_strtoupper(substr(str_replace('-', '', Uuid::v7()->toRfc4122()), 0, 8)); $article = new Article($articleType, $sku, $inventoryNumber, $stock, $condition); @@ -126,6 +130,8 @@ final class ArticleService $article->transitionTo(ArticleStatus::Active); $this->articleRepository->save($article); + $this->bus->dispatch(new PublishToChannelMessage($article->getId()->toRfc4122())); + return ['article' => $article, 'missing' => []]; } diff --git a/src/Application/Article/PhotoService.php b/src/Application/Article/PhotoService.php index 74ba6b6..6a1cdd5 100644 --- a/src/Application/Article/PhotoService.php +++ b/src/Application/Article/PhotoService.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Application\Article; use App\Application\Storage\StorageManagerInterface; +use App\Application\Storage\StoredFile; use App\Domain\Article\ArticlePhoto; use App\Domain\Article\Repository\ArticlePhotoRepositoryInterface; use App\Domain\Article\Repository\ArticleRepositoryInterface; @@ -19,6 +20,11 @@ final class PhotoService ) { } + public function uploadRaw(string $tempPath, string $originalFilename): StoredFile + { + return $this->storageManager->store($tempPath, $originalFilename); + } + public function upload(Uuid $articleId, string $tempPath, string $originalFilename): ArticlePhoto { $article = $this->articleRepository->findById($articleId) diff --git a/src/Domain/Article/Repository/ArticleRepositoryInterface.php b/src/Domain/Article/Repository/ArticleRepositoryInterface.php index 634d1b4..4c09bc8 100644 --- a/src/Domain/Article/Repository/ArticleRepositoryInterface.php +++ b/src/Domain/Article/Repository/ArticleRepositoryInterface.php @@ -16,6 +16,8 @@ interface ArticleRepositoryInterface public function findByInventoryNumber(string $inventoryNumber): ?Article; + public function findByEbayListingId(string $ebayListingId): ?Article; + /** @return list
*/ public function findByStatus(ArticleStatus $status): array; diff --git a/src/Domain/Pipeline/AIPipelineJob.php b/src/Domain/Pipeline/AIPipelineJob.php index 5d2a043..c7f507c 100644 --- a/src/Domain/Pipeline/AIPipelineJob.php +++ b/src/Domain/Pipeline/AIPipelineJob.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace App\Domain\Pipeline; -use App\Domain\Article\Article; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Uid\Uuid; @@ -19,9 +18,8 @@ class AIPipelineJob #[ORM\Column(type: 'string', enumType: AIPipelineJobType::class)] private AIPipelineJobType $type; - #[ORM\ManyToOne(targetEntity: Article::class)] - #[ORM\JoinColumn(nullable: true)] - private ?Article $article = null; + #[ORM\Column(type: 'uuid', nullable: true)] + private ?Uuid $articleId = null; #[ORM\Column(type: 'string', enumType: AIPipelineJobStatus::class)] private AIPipelineJobStatus $status; @@ -39,7 +37,7 @@ class AIPipelineJob /** @var list|null */ #[ORM\Column(type: 'simple_array', nullable: true)] - private ?array $missingFields = []; + private ?array $missingFields = null; #[ORM\Column(type: 'text', nullable: true)] private ?string $errorMessage = null; @@ -50,9 +48,7 @@ class AIPipelineJob #[ORM\Column(type: 'datetime_immutable', nullable: true)] private ?\DateTimeImmutable $completedAt = null; - /** - * @param array $inputData - */ + /** @param array $inputData */ public function __construct(AIPipelineJobType $type, array $inputData) { $this->id = Uuid::v7(); @@ -62,29 +58,6 @@ class AIPipelineJob $this->createdAt = new \DateTimeImmutable(); } - public function incrementAttempt(): void - { - ++$this->attemptCount; - } - - public function markCompleted(): void - { - $this->status = AIPipelineJobStatus::Completed; - $this->completedAt = new \DateTimeImmutable(); - } - - public function markFailed(string $errorMessage): void - { - $this->status = AIPipelineJobStatus::Failed; - $this->errorMessage = $errorMessage; - } - - public function markNeedsReview(string $reason): void - { - $this->status = AIPipelineJobStatus::NeedsReview; - $this->errorMessage = $reason; - } - public function getId(): Uuid { return $this->id; @@ -95,14 +68,14 @@ class AIPipelineJob return $this->type; } - public function getArticle(): ?Article + public function getArticleId(): ?Uuid { - return $this->article; + return $this->articleId; } - public function setArticle(Article $article): void + public function setArticleId(?Uuid $id): void { - $this->article = $article; + $this->articleId = $id; } public function getStatus(): AIPipelineJobStatus @@ -110,11 +83,6 @@ class AIPipelineJob return $this->status; } - public function setStatus(AIPipelineJobStatus $status): void - { - $this->status = $status; - } - public function getAttemptCount(): int { return $this->attemptCount; @@ -132,24 +100,12 @@ class AIPipelineJob return $this->outputData; } - /** @param array $outputData */ - public function setOutputData(array $outputData): void - { - $this->outputData = $outputData; - } - /** @return list */ public function getMissingFields(): array { return $this->missingFields ?? []; } - /** @param list $fields */ - public function setMissingFields(array $fields): void - { - $this->missingFields = $fields; - } - public function getErrorMessage(): ?string { return $this->errorMessage; @@ -164,4 +120,38 @@ class AIPipelineJob { return $this->completedAt; } + + public function markProcessing(): void + { + $this->status = AIPipelineJobStatus::Processing; + } + + /** @param array $outputData */ + public function markCompleted(array $outputData = []): void + { + $this->status = AIPipelineJobStatus::Completed; + $this->outputData = $outputData; + $this->completedAt = new \DateTimeImmutable(); + } + + public function markFailed(string $reason): void + { + $this->status = AIPipelineJobStatus::Failed; + $this->errorMessage = $reason; + $this->completedAt = new \DateTimeImmutable(); + } + + public function markNeedsReview(string $reason): void + { + $this->status = AIPipelineJobStatus::NeedsReview; + $this->errorMessage = $reason; + $this->completedAt = new \DateTimeImmutable(); + } + + /** @param list $missing */ + public function incrementAttempt(array $missing = []): void + { + ++$this->attemptCount; + $this->missingFields = $missing; + } } diff --git a/src/Infrastructure/Console/BackupCommand.php b/src/Infrastructure/Console/BackupCommand.php new file mode 100644 index 0000000..fd66e75 --- /dev/null +++ b/src/Infrastructure/Console/BackupCommand.php @@ -0,0 +1,102 @@ +backupDir) && !mkdir($this->backupDir, 0750, true) && !is_dir($this->backupDir)) { + $io->error(\sprintf('Cannot create backup directory: %s', $this->backupDir)); + + return Command::FAILURE; + } + + $timestamp = (new \DateTimeImmutable())->format('Ymd_His'); + $filename = \sprintf('%s/db_backup_%s.sql.gz', rtrim($this->backupDir, '/'), $timestamp); + + $parsed = parse_url($this->databaseUrl); + if (false === $parsed) { + $io->error('Invalid DATABASE_URL'); + + return Command::FAILURE; + } + + $host = $parsed['host'] ?? 'localhost'; + $port = $parsed['port'] ?? 5432; + $user = $parsed['user'] ?? 'postgres'; + $pass = $parsed['pass'] ?? ''; + $db = ltrim($parsed['path'] ?? '/postgres', '/'); + + $env = ['PGPASSWORD' => $pass]; + $cmd = \sprintf( + 'pg_dump -h %s -p %d -U %s %s | gzip > %s', + escapeshellarg($host), + (int) $port, + escapeshellarg($user), + escapeshellarg($db), + escapeshellarg($filename), + ); + + $descriptorSpec = [['pipe', 'r'], ['pipe', 'w'], ['pipe', 'w']]; + $envVars = array_merge($_ENV, $env); + $proc = proc_open($cmd, $descriptorSpec, $pipes, null, $envVars); + + if (!\is_resource($proc)) { + $io->error('Failed to launch pg_dump.'); + + return Command::FAILURE; + } + + fclose($pipes[0]); + fclose($pipes[1]); + $stderr = stream_get_contents($pipes[2]); + fclose($pipes[2]); + $exitCode = proc_close($proc); + + if (0 !== $exitCode) { + $io->error(\sprintf('pg_dump failed: %s', $stderr)); + + return Command::FAILURE; + } + + $io->success(\sprintf('Database backed up to %s', $filename)); + + $this->pruneOldBackups($io); + + return Command::SUCCESS; + } + + private function pruneOldBackups(SymfonyStyle $io): void + { + $cutoff = time() - (self::RETENTION_DAYS * 86400); + $files = glob($this->backupDir.'/db_backup_*.sql.gz') ?: []; + + foreach ($files as $file) { + if (filemtime($file) < $cutoff) { + unlink($file); + $io->writeln(\sprintf('Pruned old backup: %s', basename($file))); + } + } + } +} diff --git a/src/Infrastructure/Console/CreateUserCommand.php b/src/Infrastructure/Console/CreateUserCommand.php new file mode 100644 index 0000000..eb76b5e --- /dev/null +++ b/src/Infrastructure/Console/CreateUserCommand.php @@ -0,0 +1,77 @@ +ask('Email address'); + if (!\is_string($email) || '' === $email || !filter_var($email, \FILTER_VALIDATE_EMAIL)) { + $io->error('A valid email address is required.'); + + return Command::FAILURE; + } + + if (null !== $this->userRepository->findByEmail($email)) { + $io->error(\sprintf('A user with email "%s" already exists.', $email)); + + return Command::FAILURE; + } + + $password = $io->askHidden('Password (min 12 characters)'); + if (!\is_string($password) || \strlen($password) < 12) { + $io->error('Password must be at least 12 characters.'); + + return Command::FAILURE; + } + + $confirm = $io->askHidden('Confirm password'); + if ($password !== $confirm) { + $io->error('Passwords do not match.'); + + return Command::FAILURE; + } + + $placeholder = new User($email, ''); + $hash = $this->passwordHasher->hashPassword($placeholder, $password); + + $user = new User($email, $hash); + + $permissions = $io->ask('Comma-separated permissions to grant (leave empty for none)', ''); + if (\is_string($permissions) && '' !== $permissions) { + foreach (array_map('trim', explode(',', $permissions)) as $perm) { + if ('' !== $perm) { + $user->grantPermission($perm); + } + } + } + + $this->userRepository->save($user); + + $io->success(\sprintf('User "%s" created successfully (ID: %s).', $email, $user->getId()->toRfc4122())); + + return Command::SUCCESS; + } +} diff --git a/src/Infrastructure/Console/RotateLogsCommand.php b/src/Infrastructure/Console/RotateLogsCommand.php new file mode 100644 index 0000000..723d0a0 --- /dev/null +++ b/src/Infrastructure/Console/RotateLogsCommand.php @@ -0,0 +1,41 @@ +format('Y-m-d H:i:s'); + + $this->connection->executeStatement( + 'CREATE TABLE IF NOT EXISTS logs_archive.log_entries (LIKE logs.log_entries INCLUDING ALL)', + ); + + $moved = $this->connection->executeStatement( + 'WITH moved AS (DELETE FROM logs.log_entries WHERE logged_at < :cutoff RETURNING *) + INSERT INTO logs_archive.log_entries SELECT * FROM moved', + ['cutoff' => $cutoff], + ); + + $io->success(\sprintf('Archived %d log entries older than 90 days.', $moved)); + + return Command::SUCCESS; + } +} diff --git a/src/Infrastructure/Http/Controller/Api/ArticleController.php b/src/Infrastructure/Http/Controller/Api/ArticleController.php index 70a54fc..a757966 100644 --- a/src/Infrastructure/Http/Controller/Api/ArticleController.php +++ b/src/Infrastructure/Http/Controller/Api/ArticleController.php @@ -14,7 +14,7 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Uid\Uuid; -#[Route('/articles', name: 'api_articles_')] +#[Route('/api/articles', name: 'api_articles_')] final class ArticleController extends AbstractController { public function __construct(private readonly ArticleService $service) diff --git a/src/Infrastructure/Http/Controller/Api/ArticleTypeController.php b/src/Infrastructure/Http/Controller/Api/ArticleTypeController.php index 4cbe8ef..e04973e 100644 --- a/src/Infrastructure/Http/Controller/Api/ArticleTypeController.php +++ b/src/Infrastructure/Http/Controller/Api/ArticleTypeController.php @@ -13,7 +13,7 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Uid\Uuid; -#[Route('/article-types', name: 'api_article_types_')] +#[Route('/api/article-types', name: 'api_article_types_')] final class ArticleTypeController extends AbstractController { public function __construct(private readonly ArticleTypeService $service) diff --git a/src/Infrastructure/Http/Controller/Api/MappingController.php b/src/Infrastructure/Http/Controller/Api/MappingController.php index 5359d23..861577f 100644 --- a/src/Infrastructure/Http/Controller/Api/MappingController.php +++ b/src/Infrastructure/Http/Controller/Api/MappingController.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Infrastructure\Http\Controller\Api; use App\Application\Channel\MappingService; +use App\Infrastructure\Channel\Ebay\EbayTaxonomyService; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; @@ -12,7 +13,7 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Uid\Uuid; -#[Route('/article-types/{typeId}/platform-configs', name: 'api_mapping_')] +#[Route('/api/article-types/{typeId}/platform-configs', name: 'api_mapping_')] final class MappingController extends AbstractController { public function __construct(private readonly MappingService $service) @@ -87,4 +88,16 @@ final class MappingController extends AbstractController return $this->json(null, Response::HTTP_NO_CONTENT); } + + #[Route('/ebay-category-aspects/{categoryId}', name: 'ebay_aspects', methods: ['GET'])] + public function ebayAspects(string $typeId, string $categoryId, EbayTaxonomyService $taxonomy): JsonResponse + { + try { + $aspects = $taxonomy->getCategoryAspects($categoryId); + } catch (\Throwable $e) { + return $this->json(['error' => 'eBay API error: '.$e->getMessage()], Response::HTTP_SERVICE_UNAVAILABLE); + } + + return $this->json($aspects); + } } diff --git a/src/Infrastructure/Http/Controller/Api/PhotoController.php b/src/Infrastructure/Http/Controller/Api/PhotoController.php index f6e5e28..8f35877 100644 --- a/src/Infrastructure/Http/Controller/Api/PhotoController.php +++ b/src/Infrastructure/Http/Controller/Api/PhotoController.php @@ -13,7 +13,7 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Uid\Uuid; -#[Route('/articles/{articleId}/photos', name: 'api_photos_')] +#[Route('/api/articles/{articleId}/photos', name: 'api_photos_')] final class PhotoController extends AbstractController { public function __construct(private readonly PhotoService $service) diff --git a/src/Infrastructure/Http/Controller/Api/PlatformController.php b/src/Infrastructure/Http/Controller/Api/PlatformController.php index e9aa8e0..0f57193 100644 --- a/src/Infrastructure/Http/Controller/Api/PlatformController.php +++ b/src/Infrastructure/Http/Controller/Api/PlatformController.php @@ -12,7 +12,7 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Uid\Uuid; -#[Route('/platforms', name: 'api_platforms_')] +#[Route('/api/platforms', name: 'api_platforms_')] final class PlatformController extends AbstractController { public function __construct(private readonly PlatformService $service) diff --git a/src/Infrastructure/Persistence/Repository/DoctrineArticleRepository.php b/src/Infrastructure/Persistence/Repository/DoctrineArticleRepository.php index d4ce6de..87ffc58 100644 --- a/src/Infrastructure/Persistence/Repository/DoctrineArticleRepository.php +++ b/src/Infrastructure/Persistence/Repository/DoctrineArticleRepository.php @@ -31,6 +31,11 @@ final class DoctrineArticleRepository implements ArticleRepositoryInterface return $this->em->getRepository(Article::class)->findOneBy(['inventoryNumber' => $inventoryNumber]); } + public function findByEbayListingId(string $ebayListingId): ?Article + { + return $this->em->getRepository(Article::class)->findOneBy(['ebayListingId' => $ebayListingId]); + } + /** @return list
*/ public function findByStatus(ArticleStatus $status): array { diff --git a/src/Kernel.php b/src/Kernel.php index 779cd1f..ad0fb48 100644 --- a/src/Kernel.php +++ b/src/Kernel.php @@ -1,5 +1,7 @@