diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml
index ca503f0a2..5bf14ab4a 100644
--- a/.github/workflows/pr.yaml
+++ b/.github/workflows/pr.yaml
@@ -104,6 +104,70 @@ jobs:
- name: Validate Doctrine schema
run: APP_ENV=prod php bin/console doctrine:schema:validate
+ validate-doctrine-schema-postgres:
+ runs-on: ubuntu-latest
+ env:
+ DATABASE_URL: postgresql://db:db@127.0.0.1:5432/db?serverVersion=16&charset=utf8
+ strategy:
+ fail-fast: false
+ matrix:
+ php: ["8.3"]
+ name: Validate Doctrine Schema on Postgres (PHP ${{ matrix.php }})
+ services:
+ postgres:
+ image: postgres:16-alpine
+ env:
+ POSTGRES_USER: db
+ POSTGRES_PASSWORD: db
+ POSTGRES_DB: db
+ ports:
+ - 5432:5432
+ options: >-
+ --health-cmd "pg_isready -U db -d db"
+ --health-interval=5s
+ --health-timeout=3s
+ --health-retries=10
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup PHP, with composer and extensions
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php}}
+ # pgsql/pdo_pgsql added on top of the standard extension list so
+ # Doctrine can connect to Postgres for the portability gate.
+ extensions: apcu, ctype, iconv, imagick, json, pdo_pgsql, pgsql, redis, soap, xmlreader, zip
+ coverage: none
+
+ - name: Get composer cache directory
+ id: composer-cache
+ run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
+
+ - name: Cache composer dependencies
+ uses: actions/cache@v4
+ with:
+ path: ${{ steps.composer-cache.outputs.dir }}
+ key: ${{ matrix.php }}-composer-${{ hashFiles('**/composer.lock') }}
+ restore-keys: ${{ matrix.php }}-composer-
+
+ - name: 'Composer install with exported .env variables'
+ run: |
+ set -a && source .env && set +a
+ APP_ENV=prod composer install --no-dev -o
+
+ # The 2.x historical migrations use raw MariaDB SQL and can't run on
+ # Postgres — that's the whole reason this PR exists. We instead apply
+ # the entity layer directly via schema:update, which tests the
+ # load-bearing claim that the *metadata* (after the rename) is
+ # platform-portable. doctrine:schema:validate then catches any drift
+ # between Postgres' generated DDL and what Doctrine expects.
+ - name: Apply entity metadata to Postgres (schema:update)
+ run: APP_ENV=prod php bin/console doctrine:schema:update --force --complete --no-interaction
+
+ - name: Validate Doctrine schema (Postgres)
+ run: APP_ENV=prod php bin/console doctrine:schema:validate
+
php-cs-fixer:
runs-on: ubuntu-latest
strategy:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 62104634f..5d16b2314 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,23 @@ All notable changes to this project will be documented in this file.
## [Unreleased]
+## [2.8.0] - 2026-06-23
+
+- [#495](https://github.com/os2display/display-api-service/pull/495)
+ - Added `app:utils:convert-env-to-3x` command that converts the loaded configuration of a running 2.x
+ installation (env vars + admin/client config.json) to 3.x environment configuration, with screen,
+ dotenv and docker compose output formats.
+- [#486](https://github.com/os2display/display-api-service/pull/486)
+ - Changed BRND feed area and facility filters to match on `områdeId` and `facilitetsId` instead of area/facility names.
+ - Mapped area and facility IDs centrally in `parseBrndBooking()`.
+ - ID filtering is only supported for BRND API v2.0; area/facility fields are hidden in admin for v1.0 feed sources.
+- [#444](https://github.com/os2display/display-api-service/pull/444)
+ - Renamed 15 `changed_idx` indexes to `
_changed_idx` for cross-platform portability
+ (Postgres scopes index names schema-wide).
+ - Quoted `user` table identifier in entity metadata so Doctrine emits the platform-native quote on every reference.
+ - Added Postgres CI gate that runs `doctrine:schema:update --force --complete` +
+ `doctrine:schema:validate` against a Postgres 16 service container.
+
## [2.7.1] - 2026-05-26
- [#460](https://github.com/os2display/display-api-service/pull/460)
diff --git a/README.md b/README.md
index e2d82128b..957afa157 100644
--- a/README.md
+++ b/README.md
@@ -308,6 +308,48 @@ variable](https://symfony.com/doc/current/components/phpunit_bridge.html#configu
docker compose exec --env SYMFONY_DEPRECATIONS_HELPER=disabled phpfpm composer tests
```
+## Preparing an upgrade to 3.x
+
+The `app:utils:convert-env-to-3x` command converts the configuration of this
+(2.x) installation to 3.x environment configuration. The values _loaded_ in
+the running application are treated as canonical — whether they come from
+real environment variables, a docker compose `environment` block or
+`.env`/`.env.local` files — and every variable the application reads is
+written out under its 3.x name.
+
+Unless `--skip-config-json` is given, the command also fetches the canonical
+admin and client configuration from `/admin/config.json` and
+`/client/config.json` and converts them to the 3.x `ADMIN_*` and
+`CLIENT_*` variables. The base URL is inferred from the loaded OIDC redirect
+URIs (or `COMPOSE_DOMAIN`) and can be overridden with `--app-url`.
+
+```shell
+# Review on screen (default):
+docker compose exec phpfpm bin/console app:utils:convert-env-to-3x
+
+# Write a dotenv file ON THE HOST, the starting point for the 3.x .env.local.
+# With --output=env (and --output=compose) the document goes to stdout and all
+# notes/warnings go to stderr, so the result can be redirected from outside
+# the container (-T: no TTY, keeps stdout byte-clean):
+docker compose exec -T phpfpm bin/console app:utils:convert-env-to-3x \
+ --output=env --app-url=https://display.example.com > env.3x
+
+# Or a docker compose environment block:
+docker compose exec -T phpfpm bin/console app:utils:convert-env-to-3x --output=compose > env.3x.yml
+```
+
+Note that `--file` writes inside the container, so it only makes sense for
+paths on a mounted volume; prefer the host-side redirect above in dockerised
+setups.
+
+Loaded variables the Symfony application cannot read (`COMPOSE_*`, `PHP_*`,
+`NGINX_*`, `MARIADB_*`/`MYSQL_*`) are listed in a trailing advisory with a
+note on where each belongs in a 3.x deployment: compose orchestration values
+go in the docker host `.env`, and the container-tuning values go on the
+respective containers (`.env.php`, `.env.nginx`, `.env.mariadb` in the
+`os2display-docker-server` file layout). They are never part of the
+application env output.
+
## CI
Github Actions are used to run the test suite and code style checks on all PRs.
diff --git a/migrations/Version20260507120000.php b/migrations/Version20260507120000.php
new file mode 100644
index 000000000..b073e0b7a
--- /dev/null
+++ b/migrations/Version20260507120000.php
@@ -0,0 +1,59 @@
+addSql(sprintf('ALTER TABLE `%s` RENAME INDEX `changed_idx` TO `%s_changed_idx`', $table, $table));
+ }
+ }
+
+ public function down(Schema $schema): void
+ {
+ foreach (self::CHANGED_IDX_TABLES as $table) {
+ $this->addSql(sprintf('ALTER TABLE `%s` RENAME INDEX `%s_changed_idx` TO `changed_idx`', $table, $table));
+ }
+ }
+}
diff --git a/src/Command/Utils/ConvertEnvTo3xCommand.php b/src/Command/Utils/ConvertEnvTo3xCommand.php
new file mode 100644
index 000000000..059f3469e
--- /dev/null
+++ b/src/Command/Utils/ConvertEnvTo3xCommand.php
@@ -0,0 +1,591 @@
+ 'APP_ENV',
+ 'APP_DEBUG' => 'APP_DEBUG',
+ 'APP_SECRET' => 'APP_SECRET',
+ 'TRUSTED_PROXIES' => 'TRUSTED_PROXIES',
+ // doctrine/doctrine-bundle
+ 'DATABASE_URL' => 'DATABASE_URL',
+ // nelmio/cors-bundle
+ 'CORS_ALLOW_ORIGIN' => 'CORS_ALLOW_ORIGIN',
+ // App
+ 'APP_DEFAULT_DATE_FORMAT' => 'DEFAULT_DATE_FORMAT',
+ 'APP_ACTIVATION_CODE_EXPIRE_INTERNAL' => 'ACTIVATION_CODE_EXPIRE_INTERVAL',
+ 'APP_KEY_VAULT_SOURCE' => 'KEY_VAULT_SOURCE',
+ 'APP_KEY_VAULT_JSON' => 'KEY_VAULT_JSON',
+ // lexik/jwt-authentication-bundle
+ 'JWT_SECRET_KEY' => 'JWT_SECRET_KEY',
+ 'JWT_PUBLIC_KEY' => 'JWT_PUBLIC_KEY',
+ 'JWT_PASSPHRASE' => 'JWT_PASSPHRASE',
+ 'JWT_TOKEN_TTL' => 'JWT_TOKEN_TTL',
+ 'JWT_SCREEN_TOKEN_TTL' => 'JWT_SCREEN_TOKEN_TTL',
+ // gesdinet/jwt-refresh-token-bundle
+ 'JWT_REFRESH_TOKEN_TTL' => 'JWT_REFRESH_TOKEN_TTL',
+ 'JWT_SCREEN_REFRESH_TOKEN_TTL' => 'JWT_SCREEN_REFRESH_TOKEN_TTL',
+ // itk-dev/openid-connect-bundle, internal provider
+ 'INTERNAL_OIDC_METADATA_URL' => 'INTERNAL_OIDC_METADATA_URL',
+ 'INTERNAL_OIDC_CLIENT_ID' => 'INTERNAL_OIDC_CLIENT_ID',
+ 'INTERNAL_OIDC_CLIENT_SECRET' => 'INTERNAL_OIDC_CLIENT_SECRET',
+ 'INTERNAL_OIDC_REDIRECT_URI' => 'INTERNAL_OIDC_REDIRECT_URI',
+ 'INTERNAL_OIDC_LEEWAY' => 'INTERNAL_OIDC_LEEWAY',
+ 'INTERNAL_OIDC_CLAIM_NAME' => 'INTERNAL_OIDC_CLAIM_NAME',
+ 'INTERNAL_OIDC_CLAIM_EMAIL' => 'INTERNAL_OIDC_CLAIM_EMAIL',
+ 'INTERNAL_OIDC_CLAIM_GROUPS' => 'INTERNAL_OIDC_CLAIM_GROUPS',
+ // itk-dev/openid-connect-bundle, external provider
+ 'EXTERNAL_OIDC_METADATA_URL' => 'EXTERNAL_OIDC_METADATA_URL',
+ 'EXTERNAL_OIDC_CLIENT_ID' => 'EXTERNAL_OIDC_CLIENT_ID',
+ 'EXTERNAL_OIDC_CLIENT_SECRET' => 'EXTERNAL_OIDC_CLIENT_SECRET',
+ 'EXTERNAL_OIDC_REDIRECT_URI' => 'EXTERNAL_OIDC_REDIRECT_URI',
+ 'EXTERNAL_OIDC_LEEWAY' => 'EXTERNAL_OIDC_LEEWAY',
+ 'EXTERNAL_OIDC_HASH_SALT' => 'EXTERNAL_OIDC_HASH_SALT',
+ 'EXTERNAL_OIDC_CLAIM_ID' => 'EXTERNAL_OIDC_CLAIM_ID',
+ 'OIDC_CLI_REDIRECT' => 'OIDC_CLI_REDIRECT',
+ // redis
+ 'REDIS_CACHE_PREFIX' => 'REDIS_CACHE_PREFIX',
+ 'REDIS_CACHE_DSN' => 'REDIS_CACHE_DSN',
+ // Calendar Api Feed Source
+ 'CALENDAR_API_FEED_SOURCE_LOCATION_ENDPOINT' => 'CALENDAR_API_FEED_SOURCE_LOCATION_ENDPOINT',
+ 'CALENDAR_API_FEED_SOURCE_RESOURCE_ENDPOINT' => 'CALENDAR_API_FEED_SOURCE_RESOURCE_ENDPOINT',
+ 'CALENDAR_API_FEED_SOURCE_EVENT_ENDPOINT' => 'CALENDAR_API_FEED_SOURCE_EVENT_ENDPOINT',
+ 'CALENDAR_API_FEED_SOURCE_CUSTOM_MAPPINGS' => 'CALENDAR_API_FEED_SOURCE_CUSTOM_MAPPINGS',
+ 'CALENDAR_API_FEED_SOURCE_EVENT_MODIFIERS' => 'CALENDAR_API_FEED_SOURCE_EVENT_MODIFIERS',
+ 'CALENDAR_API_FEED_SOURCE_DATE_FORMAT' => 'CALENDAR_API_FEED_SOURCE_DATE_FORMAT',
+ 'CALENDAR_API_FEED_SOURCE_DATE_TIMEZONE' => 'CALENDAR_API_FEED_SOURCE_DATE_TIMEZONE',
+ 'CALENDAR_API_FEED_SOURCE_CACHE_EXPIRE_SECONDS' => 'CALENDAR_API_FEED_SOURCE_CACHE_EXPIRE_SECONDS',
+ 'EVENTDATABASE_API_V2_CACHE_EXPIRE_SECONDS' => 'EVENTDATABASE_API_V2_CACHE_EXPIRE_SECONDS',
+ // Http Client
+ 'HTTP_CLIENT_TIMEOUT' => 'HTTP_CLIENT_TIMEOUT',
+ 'HTTP_CLIENT_MAX_DURATION' => 'HTTP_CLIENT_MAX_DURATION',
+ 'HTTP_CLIENT_LOG_LEVEL' => 'LOG_LEVEL_OUTBOUND_HTTP',
+ // Screen info tracking
+ 'TRACK_SCREEN_INFO' => 'TRACK_SCREEN_INFO',
+ 'TRACK_SCREEN_INFO_UPDATE_INTERVAL_SECONDS' => 'TRACK_SCREEN_INFO_UPDATE_INTERVAL_SECONDS',
+ ];
+
+ /**
+ * Variables present in the 2.x .env that are docker compose
+ * orchestration config, not application config. They are deliberately
+ * not part of ENV_MAP and are reported through the infrastructure
+ * advisory instead.
+ */
+ public const array NON_APP_ENV = [
+ 'COMPOSE_PROJECT_NAME',
+ 'COMPOSE_DOMAIN',
+ ];
+
+ /**
+ * Environment prefixes the Symfony application cannot read. Where each
+ * belongs in a 3.x deployment (os2display-docker-server file layout).
+ */
+ private const array INFRA_PREFIXES = [
+ 'COMPOSE_' => 'compose orchestration — belongs in the docker host .env, never in the application env',
+ 'PHP_' => 'php-fpm container config — set on the phpfpm container (.env.php in os2display-docker-server)',
+ 'NGINX_' => 'nginx container config — set on the nginx container (.env.nginx in os2display-docker-server)',
+ 'MARIADB_' => 'database container config — set on the mariadb container (.env.mariadb in os2display-docker-server)',
+ 'MYSQL_' => 'database container config — set on the mariadb container (.env.mariadb in os2display-docker-server)',
+ ];
+
+ /**
+ * PHP_*-named values that are CGI artefacts or php docker image build
+ * constants — never operator configuration, so excluded from the
+ * infrastructure advisory.
+ */
+ private const array INFRA_NOISE = [
+ 'PHP_SELF',
+ 'PHP_AUTH_USER',
+ 'PHP_AUTH_PW',
+ 'PHP_AUTH_DIGEST',
+ 'PHP_VERSION',
+ 'PHP_INI_DIR',
+ 'PHP_CFLAGS',
+ 'PHP_CPPFLAGS',
+ 'PHP_LDFLAGS',
+ 'PHP_SHA256',
+ 'PHP_URL',
+ 'PHP_ASC_URL',
+ 'PHP_EXTRA_CONFIGURE_ARGS',
+ 'PHP_EXTRA_BUILD_DEPS',
+ 'PHPIZE_DEPS',
+ ];
+
+ public function __construct(
+ private readonly HttpClientInterface $httpClient,
+ ) {
+ parent::__construct();
+ }
+
+ protected function configure(): void
+ {
+ $this
+ ->addOption('output', 'o', InputOption::VALUE_REQUIRED, 'Output format (screen, env or compose)', self::OUTPUT_SCREEN)
+ ->addOption('file', 'f', InputOption::VALUE_REQUIRED, 'Write the result to this file instead of stdout (env and compose output only)')
+ ->addOption('app-url', 'u', InputOption::VALUE_REQUIRED, 'Base URL of the 2.x installation, e.g. https://display.example.com. Inferred from the loaded environment when omitted.')
+ ->addOption('skip-config-json', null, InputOption::VALUE_NONE, 'Do not fetch admin/config.json and client/config.json')
+ ->setHelp(<<<'HELP'
+ Converts the configuration of a running 2.x installation to 3.x environment
+ configuration. The values loaded in the running application — whether they
+ come from real environment variables, a docker compose environment block or
+ .env/.env.local files — are treated as canonical, and every variable the 2.x
+ application reads is written out under its 3.x name.
+
+ Unless --skip-config-json is given, the command also fetches the canonical
+ admin and client configuration from /admin/config.json and
+ /client/config.json and converts them to the 3.x ADMIN_* and
+ CLIENT_* variables. The base URL is inferred from the loaded OIDC redirect
+ URIs (or COMPOSE_DOMAIN) and can be overridden with --app-url.
+ HELP);
+ }
+
+ final protected function execute(InputInterface $input, OutputInterface $output): int
+ {
+ $io = new SymfonyStyle($input, $output);
+ // Keep stdout clean when the document itself goes there, so the
+ // result can be piped or redirected; notes and warnings go to stderr.
+ $errIo = $io->getErrorStyle();
+
+ $format = $input->getOption('output');
+ if (!in_array($format, [self::OUTPUT_SCREEN, self::OUTPUT_ENV, self::OUTPUT_COMPOSE], true)) {
+ $errIo->error(sprintf('Invalid output format "%s". Valid formats: %s.', $format, implode(', ', [self::OUTPUT_SCREEN, self::OUTPUT_ENV, self::OUTPUT_COMPOSE])));
+
+ return Command::INVALID;
+ }
+
+ $file = $input->getOption('file');
+ if (null !== $file && self::OUTPUT_SCREEN === $format) {
+ $errIo->error('--file requires --output=env or --output=compose.');
+
+ return Command::INVALID;
+ }
+
+ $sections = [
+ [
+ 'title' => 'Converted from the loaded 2.x environment',
+ 'lines' => $this->convertLoadedEnv(),
+ ],
+ ];
+
+ if (!$input->getOption('skip-config-json')) {
+ $appUrl = $input->getOption('app-url');
+ $baseUrl = is_string($appUrl) && '' !== $appUrl ? rtrim($appUrl, '/') : $this->inferAppUrl();
+
+ if (null === $baseUrl) {
+ $errIo->warning('Could not infer the 2.x app URL from the loaded environment. Pass --app-url=https://... to include the admin/client config.json conversion, or --skip-config-json to silence this warning.');
+ } else {
+ foreach (['admin', 'client'] as $type) {
+ $url = sprintf('%s/%s/config.json', $baseUrl, $type);
+ $config = $this->fetchConfig($url, $errIo);
+ if (null !== $config) {
+ $sections[] = [
+ 'title' => sprintf('%s configuration (from %s)', ucfirst($type), $url),
+ 'lines' => 'admin' === $type ? $this->convertAdminConfig($config) : $this->convertClientConfig($config),
+ ];
+ }
+ }
+ }
+ }
+
+ $infra = $this->collectInfraEnv();
+ $document = self::OUTPUT_COMPOSE === $format
+ ? $this->renderCompose($sections, $infra)
+ : $this->renderEnv($sections, $infra);
+
+ if (null !== $file) {
+ if (false === file_put_contents($file, $document)) {
+ $errIo->error(sprintf('Could not write to "%s".', $file));
+
+ return Command::FAILURE;
+ }
+ $errIo->success(sprintf('Wrote %s configuration to %s. Review the result before use.', $format, $file));
+
+ return Command::SUCCESS;
+ }
+
+ if (self::OUTPUT_SCREEN === $format) {
+ $io->title('2.x configuration converted to 3.x environment configuration');
+ $errIo->warning('This output contains secrets (e.g. APP_SECRET, JWT_PASSPHRASE, OIDC client secrets, the database password). Treat it accordingly — do not paste it into logs, issues or chat.');
+ $io->writeln($document);
+ $io->note('Review the result, then save it with --output=env --file= (or --output=compose) and use it as the starting point for the 3.x .env.local.');
+
+ return Command::SUCCESS;
+ }
+
+ // write(), not writeln(): the document is newline-terminated, and
+ // byte-exact stdout matters when redirecting to a file on the host.
+ $output->write($document);
+
+ return Command::SUCCESS;
+ }
+
+ /**
+ * Converts every loaded 2.x application variable to its 3.x name.
+ *
+ * @return list
+ */
+ private function convertLoadedEnv(): array
+ {
+ $lines = [];
+
+ foreach (self::ENV_MAP as $name2x => $name3x) {
+ $value = $this->lookupEnv($name2x);
+ if (null !== $value) {
+ $lines[] = [$name3x, $value];
+ }
+ }
+
+ return $lines;
+ }
+
+ /**
+ * Looks up a loaded value the way Symfony's env processor does:
+ * $_ENV first, then $_SERVER, then the process environment.
+ */
+ private function lookupEnv(string $name): ?string
+ {
+ $value = $_ENV[$name] ?? $_SERVER[$name] ?? getenv($name);
+
+ return is_string($value) ? $value : null;
+ }
+
+ /**
+ * Infers the public base URL of the 2.x installation from the loaded
+ * environment. The OIDC redirect URIs point at the public domain on
+ * a configured installation; COMPOSE_DOMAIN covers itk-dev
+ * development setups.
+ */
+ private function inferAppUrl(): ?string
+ {
+ foreach (['INTERNAL_OIDC_REDIRECT_URI', 'EXTERNAL_OIDC_REDIRECT_URI', 'OIDC_CLI_REDIRECT'] as $name) {
+ $value = $this->lookupEnv($name);
+ if (null === $value) {
+ continue;
+ }
+
+ $parts = parse_url($value);
+ if (!is_array($parts)) {
+ continue;
+ }
+
+ $scheme = $parts['scheme'] ?? '';
+ $host = $parts['host'] ?? '';
+ if (in_array($scheme, ['http', 'https'], true) && '' !== $host) {
+ $url = $scheme.'://'.$host;
+ if (isset($parts['port'])) {
+ $url .= ':'.$parts['port'];
+ }
+
+ return $url;
+ }
+ }
+
+ $domain = $this->lookupEnv('COMPOSE_DOMAIN');
+ if (null !== $domain && '' !== $domain) {
+ return 'https://'.$domain;
+ }
+
+ return null;
+ }
+
+ /**
+ * @return array|null
+ */
+ private function fetchConfig(string $url, SymfonyStyle $errIo): ?array
+ {
+ try {
+ return $this->httpClient->request('GET', $url, [
+ // Cap the wait so an unreachable host fails predictably
+ // rather than hanging on the default connect/response timeout.
+ 'timeout' => 10,
+ 'max_duration' => 10,
+ ])->toArray();
+ } catch (\Throwable $throwable) {
+ $errIo->warning(sprintf('Could not fetch %s (%s). The corresponding section is omitted — pass --app-url to point at the public 2.x URL, or use app:utils:convert-config-json-to-env in 3.x with a config.json file.', $url, $throwable->getMessage()));
+
+ return null;
+ }
+ }
+
+ /**
+ * Converts a 2.x admin config.json to the 3.x ADMIN_* variables.
+ *
+ * The 2.x file is generated by confd, which renders booleans and null
+ * as the strings "true"/"false"/"null" — both the JSON and string
+ * representations are accepted here.
+ *
+ * @param array $config
+ *
+ * @return list
+ */
+ private function convertAdminConfig(array $config): array
+ {
+ $lines = [];
+
+ $rejseplanenApiKey = $config['rejseplanenApiKey'] ?? null;
+ if (!$this->isNullish($rejseplanenApiKey) && is_scalar($rejseplanenApiKey)) {
+ $lines[] = ['ADMIN_REJSEPLANEN_APIKEY', (string) $rejseplanenApiKey];
+ }
+
+ $showScreenStatus = $this->toBool($config['showScreenStatus'] ?? null);
+ if (null !== $showScreenStatus) {
+ $lines[] = ['ADMIN_SHOW_SCREEN_STATUS', $showScreenStatus ? 'true' : 'false'];
+ }
+
+ $touchButtonRegions = $this->toBool($config['touchButtonRegions'] ?? null);
+ if (null !== $touchButtonRegions) {
+ $lines[] = ['ADMIN_TOUCH_BUTTON_REGIONS', $touchButtonRegions ? 'true' : 'false'];
+ }
+
+ $loginMethods = $config['loginMethods'] ?? null;
+ if (is_array($loginMethods)) {
+ $converted = [];
+ foreach ($loginMethods as $method) {
+ if (!is_array($method)) {
+ continue;
+ }
+ // In 2.x a method with enabled=false is hidden; in 3.x
+ // presence in ADMIN_LOGIN_METHODS means enabled, so disabled
+ // methods are dropped and the obsolete key stripped.
+ if (array_key_exists('enabled', $method) && false === $this->toBool($method['enabled'])) {
+ continue;
+ }
+ unset($method['enabled']);
+ $converted[] = $method;
+ }
+ $lines[] = ['ADMIN_LOGIN_METHODS', json_encode($converted, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)];
+ }
+
+ // 2.x configured a preview client URL; in 3.x this is a boolean
+ // toggle for the built-in preview.
+ if (!$this->isNullish($config['previewClient'] ?? null)) {
+ $lines[] = ['ADMIN_ENHANCED_PREVIEW', 'true'];
+ }
+
+ return $lines;
+ }
+
+ /**
+ * Converts a 2.x client config.json to the 3.x CLIENT_* variables.
+ *
+ * @param array $config
+ *
+ * @return list
+ */
+ private function convertClientConfig(array $config): array
+ {
+ $lines = [];
+
+ $scalars = [
+ 'loginCheckTimeout' => 'CLIENT_LOGIN_CHECK_TIMEOUT',
+ 'refreshTokenTimeout' => 'CLIENT_REFRESH_TOKEN_TIMEOUT',
+ 'releaseTimestampIntervalTimeout' => 'CLIENT_RELEASE_TIMESTAMP_INTERVAL_TIMEOUT',
+ 'schedulingInterval' => 'CLIENT_SCHEDULING_INTERVAL',
+ ];
+ foreach ($scalars as $key => $name3x) {
+ $value = $config[$key] ?? null;
+ if (!$this->isNullish($value) && is_scalar($value)) {
+ $lines[] = [$name3x, (string) $value];
+ }
+ }
+
+ $dataStrategy = $config['dataStrategy'] ?? null;
+ $dataStrategyConfig = is_array($dataStrategy) ? ($dataStrategy['config'] ?? null) : null;
+ $pullInterval = is_array($dataStrategyConfig) ? ($dataStrategyConfig['interval'] ?? null) : null;
+ if (!$this->isNullish($pullInterval) && is_scalar($pullInterval)) {
+ $lines[] = ['CLIENT_PULL_STRATEGY_INTERVAL', (string) $pullInterval];
+ }
+
+ $colorScheme = $config['colorScheme'] ?? null;
+ if (is_array($colorScheme)) {
+ $lines[] = ['CLIENT_COLOR_SCHEME', json_encode($colorScheme, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)];
+ }
+
+ $debug = $this->toBool($config['debug'] ?? null);
+ if (null !== $debug) {
+ $lines[] = ['CLIENT_DEBUG', $debug ? 'true' : 'false'];
+ }
+
+ return $lines;
+ }
+
+ /**
+ * Collects loaded variables the Symfony application cannot read
+ * (container/orchestration config), keyed by name, with the advice on
+ * where they belong in a 3.x deployment.
+ *
+ * @return array name => [value, advice]
+ */
+ private function collectInfraEnv(): array
+ {
+ $found = [];
+
+ // Real process env (compose environment blocks land here) merged
+ // with the dotenv-loaded vars in $_ENV.
+ $candidates = array_merge(getenv(), $_ENV);
+
+ /** @var mixed $value */
+ foreach ($candidates as $name => $value) {
+ if (!is_string($value) || in_array($name, self::INFRA_NOISE, true)) {
+ continue;
+ }
+ foreach (self::INFRA_PREFIXES as $prefix => $advice) {
+ if (str_starts_with($name, $prefix)) {
+ $found[$name] = [$value, $advice];
+ break;
+ }
+ }
+ }
+
+ ksort($found);
+
+ return $found;
+ }
+
+ /**
+ * @param list}> $sections
+ * @param array $infra
+ */
+ private function renderEnv(array $sections, array $infra): string
+ {
+ $out = [];
+
+ foreach ($sections as $section) {
+ $out[] = sprintf('###> %s ###', $section['title']);
+ foreach ($section['lines'] as [$name, $value]) {
+ $out[] = $name.'='.$this->quoteEnvValue($value);
+ }
+ $out[] = sprintf('###< %s ###', $section['title']);
+ $out[] = '';
+ }
+
+ foreach ($this->renderInfraAdvisory($infra) as $line) {
+ $out[] = '' === $line ? '' : '# '.$line;
+ }
+
+ return rtrim(implode(PHP_EOL, $out)).PHP_EOL;
+ }
+
+ /**
+ * @param list}> $sections
+ * @param array $infra
+ */
+ private function renderCompose(array $sections, array $infra): string
+ {
+ $out = [];
+ $out[] = '# Merge into the api service in your compose file.';
+ $out[] = 'environment:';
+
+ foreach ($sections as $section) {
+ $out[] = ' # '.$section['title'];
+ foreach ($section['lines'] as [$name, $value]) {
+ // YAML single-quoted scalar; embedded single quotes are
+ // escaped by doubling.
+ $out[] = sprintf(" - '%s=%s'", $name, str_replace("'", "''", $value));
+ }
+ }
+
+ $out[] = '';
+ foreach ($this->renderInfraAdvisory($infra) as $line) {
+ $out[] = '' === $line ? '' : '# '.$line;
+ }
+
+ return rtrim(implode(PHP_EOL, $out)).PHP_EOL;
+ }
+
+ /**
+ * @param array $infra
+ *
+ * @return list
+ */
+ private function renderInfraAdvisory(array $infra): array
+ {
+ if ([] === $infra) {
+ return [];
+ }
+
+ $lines = [
+ 'The following loaded variables are NOT read by the Symfony application',
+ 'and must not go in the application env. They configure the container',
+ 'stack — put them where noted:',
+ ];
+
+ $grouped = [];
+ foreach ($infra as $name => [$value, $advice]) {
+ $grouped[$advice][] = sprintf('%s=%s', $name, $value);
+ }
+
+ foreach ($grouped as $advice => $vars) {
+ $lines[] = '';
+ $lines[] = $advice.':';
+ foreach ($vars as $var) {
+ $lines[] = ' '.$var;
+ }
+ }
+
+ return $lines;
+ }
+
+ /**
+ * Quotes a value for use in a dotenv file when needed.
+ */
+ private function quoteEnvValue(string $value): string
+ {
+ if (1 === preg_match('#^[A-Za-z0-9_./:%@+,=~^?&-]*$#', $value)) {
+ return $value;
+ }
+
+ if (!str_contains($value, "'")) {
+ return "'".$value."'";
+ }
+
+ return '"'.str_replace(['\\', '"', '$'], ['\\\\', '\\"', '\\$'], $value).'"';
+ }
+
+ private function isNullish(mixed $value): bool
+ {
+ return null === $value || '' === $value || 'null' === $value;
+ }
+
+ /**
+ * Interprets JSON and confd-rendered ("true"/"false") booleans.
+ */
+ private function toBool(mixed $value): ?bool
+ {
+ if (is_bool($value)) {
+ return $value;
+ }
+
+ if (is_string($value)) {
+ return filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
+ }
+
+ return null;
+ }
+}
diff --git a/src/Entity/ScreenLayout.php b/src/Entity/ScreenLayout.php
index 89b66e5af..fea344753 100644
--- a/src/Entity/ScreenLayout.php
+++ b/src/Entity/ScreenLayout.php
@@ -17,7 +17,7 @@
#[ORM\Entity(repositoryClass: ScreenLayoutRepository::class)]
#[ORM\EntityListeners([\App\EventListener\ScreenLayoutDoctrineEventListener::class])]
-#[ORM\Index(fields: ['changed'], name: 'changed_idx')]
+#[ORM\Index(fields: ['changed'], name: 'screen_layout_changed_idx')]
class ScreenLayout extends AbstractBaseEntity implements MultiTenantInterface, RelationsChecksumInterface
{
use MultiTenantTrait;
diff --git a/src/Entity/ScreenLayoutRegions.php b/src/Entity/ScreenLayoutRegions.php
index 0c5466511..e27a7cfda 100644
--- a/src/Entity/ScreenLayoutRegions.php
+++ b/src/Entity/ScreenLayoutRegions.php
@@ -17,7 +17,7 @@
#[ORM\Entity(repositoryClass: ScreenLayoutRegionsRepository::class)]
#[ORM\EntityListeners([\App\EventListener\ScreenLayoutRegionsDoctrineEventListener::class])]
-#[ORM\Index(fields: ['changed'], name: 'changed_idx')]
+#[ORM\Index(fields: ['changed'], name: 'screen_layout_regions_changed_idx')]
class ScreenLayoutRegions extends AbstractBaseEntity implements MultiTenantInterface, RelationsChecksumInterface
{
use MultiTenantTrait;
diff --git a/src/Entity/Template.php b/src/Entity/Template.php
index b85245388..1539ed124 100644
--- a/src/Entity/Template.php
+++ b/src/Entity/Template.php
@@ -17,7 +17,7 @@
#[ORM\Entity(repositoryClass: TemplateRepository::class)]
#[ORM\EntityListeners([\App\EventListener\TemplateDoctrineEventListener::class])]
-#[ORM\Index(fields: ['changed'], name: 'changed_idx')]
+#[ORM\Index(fields: ['changed'], name: 'template_changed_idx')]
class Template extends AbstractBaseEntity implements MultiTenantInterface, RelationsChecksumInterface
{
use MultiTenantTrait;
diff --git a/src/Entity/Tenant/Feed.php b/src/Entity/Tenant/Feed.php
index 4c7ed2cd6..70cc438e5 100644
--- a/src/Entity/Tenant/Feed.php
+++ b/src/Entity/Tenant/Feed.php
@@ -11,7 +11,7 @@
#[ORM\Entity(repositoryClass: FeedRepository::class)]
#[ORM\EntityListeners([\App\EventListener\FeedDoctrineEventListener::class])]
-#[ORM\Index(fields: ['changed'], name: 'changed_idx')]
+#[ORM\Index(fields: ['changed'], name: 'feed_changed_idx')]
class Feed extends AbstractTenantScopedEntity implements RelationsChecksumInterface
{
use RelationsChecksumTrait;
diff --git a/src/Entity/Tenant/FeedSource.php b/src/Entity/Tenant/FeedSource.php
index b580bfe7d..26edbb3ca 100644
--- a/src/Entity/Tenant/FeedSource.php
+++ b/src/Entity/Tenant/FeedSource.php
@@ -14,7 +14,7 @@
#[ORM\Entity(repositoryClass: FeedSourceRepository::class)]
#[ORM\EntityListeners([\App\EventListener\FeedSourceDoctrineEventListener::class])]
-#[ORM\Index(fields: ['changed'], name: 'changed_idx')]
+#[ORM\Index(fields: ['changed'], name: 'feed_source_changed_idx')]
class FeedSource extends AbstractTenantScopedEntity implements RelationsChecksumInterface
{
use EntityTitleDescriptionTrait;
diff --git a/src/Entity/Tenant/Media.php b/src/Entity/Tenant/Media.php
index 1b285648e..dff8894c0 100644
--- a/src/Entity/Tenant/Media.php
+++ b/src/Entity/Tenant/Media.php
@@ -18,7 +18,7 @@
#[Vich\Uploadable]
#[ORM\Entity(repositoryClass: MediaRepository::class)]
#[ORM\EntityListeners([\App\EventListener\MediaDoctrineEventListener::class])]
-#[ORM\Index(fields: ['changed'], name: 'changed_idx')]
+#[ORM\Index(fields: ['changed'], name: 'media_changed_idx')]
class Media extends AbstractTenantScopedEntity implements RelationsChecksumInterface
{
use EntityTitleDescriptionTrait;
diff --git a/src/Entity/Tenant/Playlist.php b/src/Entity/Tenant/Playlist.php
index d565ff84a..f81f19731 100644
--- a/src/Entity/Tenant/Playlist.php
+++ b/src/Entity/Tenant/Playlist.php
@@ -17,7 +17,7 @@
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: PlaylistRepository::class)]
-#[ORM\Index(fields: ['changed'], name: 'changed_idx')]
+#[ORM\Index(fields: ['changed'], name: 'playlist_changed_idx')]
class Playlist extends AbstractTenantScopedEntity implements MultiTenantInterface, RelationsChecksumInterface
{
use EntityPublishedTrait;
diff --git a/src/Entity/Tenant/PlaylistScreenRegion.php b/src/Entity/Tenant/PlaylistScreenRegion.php
index 97a7a92b8..59cc1282c 100644
--- a/src/Entity/Tenant/PlaylistScreenRegion.php
+++ b/src/Entity/Tenant/PlaylistScreenRegion.php
@@ -12,7 +12,7 @@
#[ORM\UniqueConstraint(name: 'unique_playlist_screen_region', columns: ['playlist_id', 'screen_id', 'region_id'])]
#[ORM\Entity(repositoryClass: PlaylistScreenRegionRepository::class)]
-#[ORM\Index(fields: ['changed'], name: 'changed_idx')]
+#[ORM\Index(fields: ['changed'], name: 'playlist_screen_region_changed_idx')]
class PlaylistScreenRegion extends AbstractTenantScopedEntity implements RelationsChecksumInterface
{
use RelationsChecksumTrait;
diff --git a/src/Entity/Tenant/PlaylistSlide.php b/src/Entity/Tenant/PlaylistSlide.php
index ae8f0a1da..a3a444909 100644
--- a/src/Entity/Tenant/PlaylistSlide.php
+++ b/src/Entity/Tenant/PlaylistSlide.php
@@ -10,7 +10,7 @@
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: PlaylistSlideRepository::class)]
-#[ORM\Index(fields: ['changed'], name: 'changed_idx')]
+#[ORM\Index(fields: ['changed'], name: 'playlist_slide_changed_idx')]
class PlaylistSlide extends AbstractTenantScopedEntity implements RelationsChecksumInterface
{
use RelationsChecksumTrait;
diff --git a/src/Entity/Tenant/Screen.php b/src/Entity/Tenant/Screen.php
index 9fde5553e..d735c09c1 100644
--- a/src/Entity/Tenant/Screen.php
+++ b/src/Entity/Tenant/Screen.php
@@ -15,7 +15,7 @@
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: ScreenRepository::class)]
-#[ORM\Index(fields: ['changed'], name: 'changed_idx')]
+#[ORM\Index(fields: ['changed'], name: 'screen_changed_idx')]
class Screen extends AbstractTenantScopedEntity implements RelationsChecksumInterface
{
use EntityTitleDescriptionTrait;
diff --git a/src/Entity/Tenant/ScreenCampaign.php b/src/Entity/Tenant/ScreenCampaign.php
index 1101cbf31..599f8ac91 100644
--- a/src/Entity/Tenant/ScreenCampaign.php
+++ b/src/Entity/Tenant/ScreenCampaign.php
@@ -10,7 +10,7 @@
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: ScreenCampaignRepository::class)]
-#[ORM\Index(fields: ['changed'], name: 'changed_idx')]
+#[ORM\Index(fields: ['changed'], name: 'screen_campaign_changed_idx')]
class ScreenCampaign extends AbstractTenantScopedEntity implements RelationsChecksumInterface
{
use RelationsChecksumTrait;
diff --git a/src/Entity/Tenant/ScreenGroup.php b/src/Entity/Tenant/ScreenGroup.php
index 228ebe82d..f36047614 100644
--- a/src/Entity/Tenant/ScreenGroup.php
+++ b/src/Entity/Tenant/ScreenGroup.php
@@ -13,7 +13,7 @@
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: ScreenGroupRepository::class)]
-#[ORM\Index(fields: ['changed'], name: 'changed_idx')]
+#[ORM\Index(fields: ['changed'], name: 'screen_group_changed_idx')]
class ScreenGroup extends AbstractTenantScopedEntity implements RelationsChecksumInterface
{
use EntityTitleDescriptionTrait;
diff --git a/src/Entity/Tenant/ScreenGroupCampaign.php b/src/Entity/Tenant/ScreenGroupCampaign.php
index 8337d3795..8d530575b 100644
--- a/src/Entity/Tenant/ScreenGroupCampaign.php
+++ b/src/Entity/Tenant/ScreenGroupCampaign.php
@@ -10,7 +10,7 @@
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: ScreenGroupCampaignRepository::class)]
-#[ORM\Index(fields: ['changed'], name: 'changed_idx')]
+#[ORM\Index(fields: ['changed'], name: 'screen_group_campaign_changed_idx')]
class ScreenGroupCampaign extends AbstractTenantScopedEntity implements RelationsChecksumInterface
{
use RelationsChecksumTrait;
diff --git a/src/Entity/Tenant/Slide.php b/src/Entity/Tenant/Slide.php
index 82e1fbd7f..43e5e933f 100644
--- a/src/Entity/Tenant/Slide.php
+++ b/src/Entity/Tenant/Slide.php
@@ -15,7 +15,7 @@
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: SlideRepository::class)]
-#[ORM\Index(fields: ['changed'], name: 'changed_idx')]
+#[ORM\Index(fields: ['changed'], name: 'slide_changed_idx')]
class Slide extends AbstractTenantScopedEntity implements RelationsChecksumInterface
{
use EntityPublishedTrait;
diff --git a/src/Entity/Tenant/Theme.php b/src/Entity/Tenant/Theme.php
index 915e48c4d..25af364b7 100644
--- a/src/Entity/Tenant/Theme.php
+++ b/src/Entity/Tenant/Theme.php
@@ -14,7 +14,7 @@
#[ORM\Entity(repositoryClass: ThemeRepository::class)]
#[ORM\EntityListeners([\App\EventListener\ThemeDoctrineEventListener::class])]
-#[ORM\Index(fields: ['changed'], name: 'changed_idx')]
+#[ORM\Index(fields: ['changed'], name: 'theme_changed_idx')]
class Theme extends AbstractTenantScopedEntity implements RelationsChecksumInterface
{
use EntityTitleDescriptionTrait;
diff --git a/src/Entity/User.php b/src/Entity/User.php
index 6f5b191a9..8e3a1932b 100644
--- a/src/Entity/User.php
+++ b/src/Entity/User.php
@@ -18,6 +18,7 @@
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: UserRepository::class)]
+#[ORM\Table(name: '`user`')]
class User extends AbstractBaseEntity implements UserInterface, PasswordAuthenticatedUserInterface, \JsonSerializable, TenantScopedUserInterface
{
#[Assert\NotBlank]
diff --git a/src/Feed/BrndFeedType.php b/src/Feed/BrndFeedType.php
index 66b9e6fec..06818ef3b 100644
--- a/src/Feed/BrndFeedType.php
+++ b/src/Feed/BrndFeedType.php
@@ -29,6 +29,8 @@ class BrndFeedType implements FeedTypeInterface
*/
private const string BRND_API_TIMEZONE = 'Europe/Copenhagen';
+ private const string BRND_API_VERSION_WITH_ID_FILTERING = '2.0';
+
public function __construct(
private readonly FeedService $feedService,
private readonly ApiClient $apiClient,
@@ -40,7 +42,7 @@ public function getAdminFormOptions(FeedSource $feedSource): array
{
$feedEntryRecipients = $this->feedService->getFeedSourceConfigUrl($feedSource, 'sport-center');
- return [
+ $options = [
[
'key' => 'brnd-sport-center-id',
'input' => 'input',
@@ -49,23 +51,28 @@ public function getAdminFormOptions(FeedSource $feedSource): array
'label' => 'Sportcenter ID',
'formGroupClasses' => 'mb-3',
],
- [
+ ];
+
+ if ($this->supportsIdFiltering($feedSource)) {
+ $options[] = [
'key' => 'brnd-area',
'input' => 'input',
'type' => 'text',
'name' => 'area',
- 'label' => 'Område',
+ 'label' => 'Område ID',
'formGroupClasses' => 'mb-3',
- ],
- [
+ ];
+ $options[] = [
'key' => 'brnd-facility',
'input' => 'input',
'type' => 'text',
'name' => 'facility',
- 'label' => 'Facilitet',
+ 'label' => 'Facilitet ID',
'formGroupClasses' => 'mb-3',
- ],
- ];
+ ];
+ }
+
+ return $options;
}
public function getData(Feed $feed): array
@@ -94,12 +101,13 @@ public function getData(Feed $feed): array
return $result;
}
+ $supportsIdFiltering = $this->supportsIdFiltering($feedSource);
$areaFilterNormalized = self::normalizeFilterValue($areaFilter);
$facilityFilterNormalized = self::normalizeFilterValue($facilityFilter);
$bookings = $this->apiClient->getInfomonitorBookingsDetails($feedSource, $sportCenterId);
- $result['bookings'] = array_reduce($bookings, function (array $carry, mixed $booking) use ($areaFilterNormalized, $facilityFilterNormalized): array {
+ $result['bookings'] = array_reduce($bookings, function (array $carry, mixed $booking) use ($areaFilterNormalized, $facilityFilterNormalized, $supportsIdFiltering): array {
if (!is_array($booking)) {
return $carry;
}
@@ -111,18 +119,18 @@ public function getData(Feed $feed): array
return $carry;
}
- // Bail out if area filter applies and booking area does not match.
- if ('' !== $areaFilterNormalized) {
- $bookingArea = self::normalizeFilterValue($parsedBooking['area'] ?? '');
- if ($bookingArea !== $areaFilterNormalized) {
+ // Bail out if area filter applies and booking area ID does not match.
+ if ($supportsIdFiltering && '' !== $areaFilterNormalized) {
+ $bookingAreaId = self::normalizeFilterValue($parsedBooking['areaId'] ?? '');
+ if ($bookingAreaId !== $areaFilterNormalized) {
return $carry;
}
}
- // Bail out if facility filter applies and booking facility does not match.
- if ('' !== $facilityFilterNormalized) {
- $bookingFacility = self::normalizeFilterValue($parsedBooking['facility'] ?? '');
- if ($bookingFacility !== $facilityFilterNormalized) {
+ // Bail out if facility filter applies and booking facility ID does not match.
+ if ($supportsIdFiltering && '' !== $facilityFilterNormalized) {
+ $bookingFacilityId = self::normalizeFilterValue($parsedBooking['facilityId'] ?? '');
+ if ($bookingFacilityId !== $facilityFilterNormalized) {
return $carry;
}
}
@@ -142,13 +150,15 @@ public function getData(Feed $feed): array
private static function normalizeFilterValue(mixed $value): string
{
+ if (is_int($value) || is_float($value)) {
+ return (string) $value;
+ }
+
if (!is_string($value)) {
return '';
}
- $value = trim($value);
-
- return strtolower($value);
+ return trim($value);
}
private function parseBrndBooking(array $booking): array
@@ -195,6 +205,8 @@ private function parseBrndBooking(array $booking): array
'complex' => $booking['anlæg'] ?? '',
'area' => $booking['område'] ?? '',
'facility' => $booking['facilitet'] ?? '',
+ 'areaId' => $booking['områdeId'] ?? '',
+ 'facilityId' => $booking['facilitetsId'] ?? '',
'activity' => $booking['aktivitet'] ?? '',
'team' => $booking['hold'] ?? '',
'status' => $booking['status'] ?? '',
@@ -204,6 +216,17 @@ private function parseBrndBooking(array $booking): array
];
}
+ private function supportsIdFiltering(FeedSource $feedSource): bool
+ {
+ $secrets = $feedSource->getSecrets();
+
+ if (!is_array($secrets)) {
+ return false;
+ }
+
+ return self::BRND_API_VERSION_WITH_ID_FILTERING === ($secrets['api_version'] ?? '1.0');
+ }
+
public function getConfigOptions(Request $request, FeedSource $feedSource, string $name): ?array
{
return null;
diff --git a/tests/Command/ConvertEnvTo3xCommandTest.php b/tests/Command/ConvertEnvTo3xCommandTest.php
new file mode 100644
index 000000000..2800e541c
--- /dev/null
+++ b/tests/Command/ConvertEnvTo3xCommandTest.php
@@ -0,0 +1,271 @@
+ */
+ private array $envBackup = [];
+
+ protected function tearDown(): void
+ {
+ foreach ($this->envBackup as $name => $original) {
+ if (false === $original) {
+ unset($_ENV[$name]);
+ } else {
+ $_ENV[$name] = $original;
+ }
+ }
+ $this->envBackup = [];
+
+ parent::tearDown();
+ }
+
+ public function testEveryEnvVarInDotEnvIsCovered(): void
+ {
+ $dotEnv = file_get_contents(dirname(__DIR__, 2).'/.env');
+ $this->assertNotFalse($dotEnv);
+
+ $this->assertGreaterThan(0, preg_match_all('/^([A-Z][A-Z0-9_]*)=/m', $dotEnv, $matches));
+
+ $covered = array_merge(array_keys(ConvertEnvTo3xCommand::ENV_MAP), ConvertEnvTo3xCommand::NON_APP_ENV);
+ foreach ($matches[1] as $name) {
+ $this->assertContains($name, $covered, sprintf('Env var "%s" from .env is not covered by %s::ENV_MAP — add it with its 3.x name (or to NON_APP_ENV if it is not application config).', $name, ConvertEnvTo3xCommand::class));
+ }
+ }
+
+ public function testRenamesLoadedEnvVarsTo3xNames(): void
+ {
+ $this->setEnv('APP_ENV', 'prod');
+ $this->setEnv('APP_DEFAULT_DATE_FORMAT', 'Y-m-d');
+ $this->setEnv('APP_ACTIVATION_CODE_EXPIRE_INTERNAL', 'P2D');
+ $this->setEnv('APP_KEY_VAULT_SOURCE', 'ENVIRONMENT');
+ $this->setEnv('APP_KEY_VAULT_JSON', '{}');
+ $this->setEnv('HTTP_CLIENT_LOG_LEVEL', 'error');
+ $this->setEnv('DATABASE_URL', 'mysql://db:db@mariadb:3306/db?serverVersion=10.11.5-MariaDB');
+
+ $tester = $this->createCommandTester(new MockHttpClient());
+ $exitCode = $tester->execute(['--output' => 'env', '--skip-config-json' => true]);
+
+ $this->assertSame(Command::SUCCESS, $exitCode);
+ $display = $tester->getDisplay();
+
+ $this->assertStringContainsString('APP_ENV=prod', $display);
+ $this->assertStringContainsString('DEFAULT_DATE_FORMAT=Y-m-d', $display);
+ $this->assertStringContainsString('ACTIVATION_CODE_EXPIRE_INTERVAL=P2D', $display);
+ $this->assertStringContainsString('KEY_VAULT_SOURCE=ENVIRONMENT', $display);
+ $this->assertStringContainsString('KEY_VAULT_JSON=', $display);
+ $this->assertStringContainsString('LOG_LEVEL_OUTBOUND_HTTP=error', $display);
+ $this->assertStringContainsString('DATABASE_URL=mysql://db:db@mariadb:3306/db?serverVersion=10.11.5-MariaDB', $display);
+
+ $this->assertStringNotContainsString('APP_DEFAULT_DATE_FORMAT=', $display);
+ $this->assertStringNotContainsString('APP_ACTIVATION_CODE_EXPIRE_INTERNAL=', $display);
+ $this->assertStringNotContainsString('HTTP_CLIENT_LOG_LEVEL=', $display);
+ }
+
+ public function testConvertsAdminAndClientConfigJson(): void
+ {
+ $requestedUrls = [];
+ $client = new MockHttpClient(function (string $method, string $url) use (&$requestedUrls): MockResponse {
+ $requestedUrls[] = $url;
+
+ // The bodies mirror what confd renders in 2.x: booleans and
+ // null as strings.
+ if (str_contains($url, '/admin/config.json')) {
+ return new MockResponse((string) json_encode([
+ 'api' => '/',
+ 'touchButtonRegions' => 'true',
+ 'previewClient' => 'https://preview.example.com',
+ 'showScreenStatus' => 'false',
+ 'rejseplanenApiKey' => 'null',
+ 'loginMethods' => [
+ ['type' => 'oidc', 'enabled' => 'true', 'provider' => 'internal', 'label' => null, 'icon' => 'faCity'],
+ ['type' => 'oidc', 'enabled' => 'false', 'provider' => 'external', 'label' => '', 'icon' => 'mitID'],
+ ['type' => 'username-password', 'enabled' => 'true', 'provider' => 'username-password', 'label' => null],
+ ],
+ ]), ['response_headers' => ['content-type' => 'application/json']]);
+ }
+
+ return new MockResponse((string) json_encode([
+ 'apiEndpoint' => '/',
+ 'loginCheckTimeout' => 20000,
+ 'configFetchInterval' => 600000,
+ 'refreshTokenTimeout' => 60000,
+ 'releaseTimestampIntervalTimeout' => 600000,
+ 'dataStrategy' => ['type' => 'pull', 'config' => ['interval' => 30000]],
+ 'colorScheme' => ['type' => 'library', 'lat' => 56.0, 'lng' => 10.0],
+ 'schedulingInterval' => 60000,
+ 'debug' => false,
+ ]), ['response_headers' => ['content-type' => 'application/json']]);
+ });
+
+ $tester = $this->createCommandTester($client);
+ $exitCode = $tester->execute(['--output' => 'env', '--app-url' => 'https://display.example.com']);
+
+ $this->assertSame(Command::SUCCESS, $exitCode);
+ $this->assertSame([
+ 'https://display.example.com/admin/config.json',
+ 'https://display.example.com/client/config.json',
+ ], $requestedUrls);
+
+ $display = $tester->getDisplay();
+
+ $this->assertStringContainsString('ADMIN_TOUCH_BUTTON_REGIONS=true', $display);
+ $this->assertStringContainsString('ADMIN_SHOW_SCREEN_STATUS=false', $display);
+ $this->assertStringContainsString('ADMIN_ENHANCED_PREVIEW=true', $display);
+ // "null" means not configured.
+ $this->assertStringNotContainsString('ADMIN_REJSEPLANEN_APIKEY', $display);
+ // The disabled external method is dropped, and the obsolete
+ // "enabled" key stripped from the remaining methods.
+ $this->assertStringContainsString('ADMIN_LOGIN_METHODS=', $display);
+ $this->assertStringNotContainsString('"provider":"external"', $display);
+ $this->assertStringNotContainsString('"enabled"', $display);
+ $this->assertStringContainsString('"provider":"internal"', $display);
+ $this->assertStringContainsString('"provider":"username-password"', $display);
+
+ $this->assertStringContainsString('CLIENT_LOGIN_CHECK_TIMEOUT=20000', $display);
+ $this->assertStringContainsString('CLIENT_REFRESH_TOKEN_TIMEOUT=60000', $display);
+ $this->assertStringContainsString('CLIENT_RELEASE_TIMESTAMP_INTERVAL_TIMEOUT=600000', $display);
+ $this->assertStringContainsString('CLIENT_SCHEDULING_INTERVAL=60000', $display);
+ $this->assertStringContainsString('CLIENT_PULL_STRATEGY_INTERVAL=30000', $display);
+ $this->assertStringContainsString('"type":"library"', $display);
+ $this->assertStringContainsString('CLIENT_DEBUG=false', $display);
+ }
+
+ public function testInfersAppUrlFromOidcRedirectUri(): void
+ {
+ $this->setEnv('INTERNAL_OIDC_REDIRECT_URI', 'https://display.example.com/admin/redirect');
+
+ $requestedUrls = [];
+ $client = new MockHttpClient(function (string $method, string $url) use (&$requestedUrls): MockResponse {
+ $requestedUrls[] = $url;
+
+ return new MockResponse('{}', ['response_headers' => ['content-type' => 'application/json']]);
+ });
+
+ $tester = $this->createCommandTester($client);
+ $exitCode = $tester->execute(['--output' => 'env']);
+
+ $this->assertSame(Command::SUCCESS, $exitCode);
+ $this->assertSame([
+ 'https://display.example.com/admin/config.json',
+ 'https://display.example.com/client/config.json',
+ ], $requestedUrls);
+ }
+
+ public function testWarnsWhenAppUrlCannotBeInferred(): void
+ {
+ // Placeholder values (the committed .env defaults) must not be
+ // mistaken for a URL.
+ $this->setEnv('INTERNAL_OIDC_REDIRECT_URI', 'INTERNAL_OIDC_REDIRECT_URI');
+ $this->setEnv('EXTERNAL_OIDC_REDIRECT_URI', 'EXTERNAL_OIDC_REDIRECT_URI');
+ $this->setEnv('OIDC_CLI_REDIRECT', 'APP_CLI_REDIRECT_URI');
+ $this->setEnv('COMPOSE_DOMAIN', '');
+
+ $tester = $this->createCommandTester(new MockHttpClient());
+ $exitCode = $tester->execute(['--output' => 'env']);
+
+ $this->assertSame(Command::SUCCESS, $exitCode);
+ $this->assertStringContainsString('Could not infer the 2.x app URL', $tester->getDisplay(true));
+ }
+
+ public function testStdoutStaysCleanWhenFetchFails(): void
+ {
+ $this->setEnv('APP_ENV', 'prod');
+
+ // Every request fails — the warning must go to stderr so stdout can
+ // be redirected to a file on the host from outside the container.
+ $client = new MockHttpClient(fn () => new MockResponse('Service Unavailable', ['http_code' => 503]));
+
+ $tester = $this->createCommandTester($client);
+ $exitCode = $tester->execute(
+ ['--output' => 'env', '--app-url' => 'https://display.example.com'],
+ ['capture_stderr_separately' => true],
+ );
+
+ $this->assertSame(Command::SUCCESS, $exitCode);
+ $this->assertStringContainsString('Could not fetch', $tester->getErrorOutput());
+
+ $display = $tester->getDisplay();
+ $this->assertStringContainsString('APP_ENV=prod', $display);
+ $this->assertStringNotContainsString('Could not fetch', $display);
+ // Parseable as dotenv: nothing but KEY=value, comments and blanks.
+ foreach (explode("\n", rtrim($display)) as $line) {
+ $this->assertMatchesRegularExpression('/^$|^#|^[A-Z][A-Z0-9_]*=/', $line);
+ }
+ }
+
+ public function testComposeOutput(): void
+ {
+ $this->setEnv('APP_ENV', 'prod');
+
+ $tester = $this->createCommandTester(new MockHttpClient());
+ $exitCode = $tester->execute(['--output' => 'compose', '--skip-config-json' => true]);
+
+ $this->assertSame(Command::SUCCESS, $exitCode);
+ $display = $tester->getDisplay();
+
+ $this->assertStringContainsString('environment:', $display);
+ $this->assertStringContainsString(" - 'APP_ENV=prod'", $display);
+ }
+
+ public function testWritesToFile(): void
+ {
+ $this->setEnv('APP_ENV', 'prod');
+
+ $file = tempnam(sys_get_temp_dir(), 'env3x');
+ $this->assertNotFalse($file);
+
+ try {
+ $tester = $this->createCommandTester(new MockHttpClient());
+ $exitCode = $tester->execute(['--output' => 'env', '--file' => $file, '--skip-config-json' => true]);
+
+ $this->assertSame(Command::SUCCESS, $exitCode);
+
+ $written = file_get_contents($file);
+ $this->assertNotFalse($written);
+ $this->assertStringContainsString('APP_ENV=prod', $written);
+ } finally {
+ unlink($file);
+ }
+ }
+
+ public function testRejectsInvalidOutputFormat(): void
+ {
+ $tester = $this->createCommandTester(new MockHttpClient());
+
+ $this->assertSame(Command::INVALID, $tester->execute(['--output' => 'json']));
+ }
+
+ public function testRejectsFileWithScreenOutput(): void
+ {
+ $tester = $this->createCommandTester(new MockHttpClient());
+
+ $this->assertSame(Command::INVALID, $tester->execute(['--file' => '/tmp/foo']));
+ }
+
+ private function createCommandTester(HttpClientInterface $httpClient): CommandTester
+ {
+ return new CommandTester(new ConvertEnvTo3xCommand($httpClient));
+ }
+
+ private function setEnv(string $name, string $value): void
+ {
+ if (!array_key_exists($name, $this->envBackup)) {
+ $original = array_key_exists($name, $_ENV) && is_string($_ENV[$name]) ? $_ENV[$name] : false;
+ $this->envBackup[$name] = $original;
+ }
+
+ $_ENV[$name] = $value;
+ }
+}