diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 00000000..2f8e0750
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,36 @@
+# https://editorconfig.org
+
+# SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+# SPDX-License-Identifier: AGPL-3.0-or-later
+
+root = true
+
+[*]
+charset = utf-8
+end_of_line = lf
+indent_size = 4
+indent_style = tab
+insert_final_newline = true
+trim_trailing_whitespace = true
+
+[*.yml]
+indent_size = 2
+indent_style = space
+
+[*.md]
+trim_trailing_whitespace = false
+
+[*.svg]
+insert_final_newline = false
+
+[package*.json]
+indent_size = 2
+indent_style = space
+
+[build/psalm-baseline.xml]
+indent_size = 2
+indent_style = space
+
+[config/*config.php]
+indent_size = 2
+indent_style = space
\ No newline at end of file
diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs
index 41ba401f..7cc99a5d 100644
--- a/.git-blame-ignore-revs
+++ b/.git-blame-ignore-revs
@@ -1,2 +1,16 @@
-# Retrofit annotation commit (opsx-annotate, 2026-05-24)
-21a8c1f6ecf3a3b2346ed631bb469a8cd3b65e01
+# Revisions to skip in `git blame`.
+#
+# Enable locally, once:
+# git config blame.ignoreRevsFile .git-blame-ignore-revs
+#
+# GitHub reads this file automatically. Your terminal does not, until you run
+# the line above.
+#
+# Only ever add commits that change formatting and NOTHING else. A commit listed
+# here becomes invisible to blame, so a behaviour change hidden inside one would
+# be very hard to find later.
+
+# style: reformat with nextcloud/coding-standard — whitespace only
+# The fleet-wide move from a PEAR-derived PHPCS ruleset (4 spaces, next-line
+# braces) to Nextcloud's own standard (tabs, same-line braces).
+46e028e7046485d8e28cbc3bc786b7d577c83f51
diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml
index bf5bd156..edc81a64 100644
--- a/.github/workflows/code-quality.yml
+++ b/.github/workflows/code-quality.yml
@@ -77,11 +77,13 @@ jobs:
# their single server, so with the inherited default those three jobs ran
# entirely on the version openregister cannot load.
#
- # launchpad's own appinfo/info.xml floor stays at 29: src/manifest.json
- # declares no required app dependency, and launchpad's OpenRegister use is
- # via AppHost, probed at runtime. The NC32 constraint here is a property of
- # the CI fixture, not of launchpad's code.
- nextcloud-test-refs: '["stable32"]'
+ # THE LIST IS THE WHOLE DECLARED RANGE. This comment previously said
+ # "launchpad's own appinfo/info.xml floor stays at 29 … the NC32 constraint
+ # here is a property of the CI fixture, not of launchpad's code" — that is
+ # no longer true of the file it describes. info.xml on this branch declares
+ # , so 32 is the app's own
+ # floor, not a fixture artefact, and 32, 33 and 34 each get a leg.
+ nextcloud-test-refs: '["stable34", "stable32", "stable33"]'
enable-psalm: true
enable-phpstan: true
enable-phpmetrics: true
diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php
new file mode 100644
index 00000000..db584532
--- /dev/null
+++ b/.php-cs-fixer.dist.php
@@ -0,0 +1,20 @@
+getFinder()
+ ->notPath('vendor')
+ ->notPath('node_modules')
+ ->notPath('build')
+ ->in(__DIR__ . '/lib')
+ ->in(__DIR__ . '/tests');
+
+return $config;
diff --git a/appinfo/info.xml b/appinfo/info.xml
index fb06fe3f..ccb1e9a5 100644
--- a/appinfo/info.xml
+++ b/appinfo/info.xml
@@ -83,10 +83,13 @@ Vrij en open source onder de EUPL-1.2-licentie.
-
-
-
-
+
OCA\LaunchPad\BackgroundJob\OrphanedDataCleanupJob
@@ -96,22 +99,6 @@ Vrij en open source onder de EUPL-1.2-licentie.
-
- OCA\LaunchPad\Repair\InitializeActions
-
- OCA\LaunchPad\Repair\ApplyActionBaseline
-
- OCA\LaunchPad\Repair\SeedRolePermissions
-
- OCA\LaunchPad\Repair\RegisterBackgroundJobs
-
- OCA\LaunchPad\Repair\ImportLaunchpadRegister
-
OCA\LaunchPad\Repair\InitializeActions
OCA\LaunchPad\Repair\ImportLaunchpadRegister
+
+ OCA\LaunchPad\Repair\InitializeActions
+
+ OCA\LaunchPad\Repair\ApplyActionBaseline
+
+ OCA\LaunchPad\Repair\SeedRolePermissions
+
+ OCA\LaunchPad\Repair\RegisterBackgroundJobs
+
+ OCA\LaunchPad\Repair\ImportLaunchpadRegister
+
-
- OCA\LaunchPad\Settings\LaunchPadAdmin
- OCA\LaunchPad\Settings\LaunchPadAdminSection
-
-
OCA\LaunchPad\Command\ExportCommand
@@ -159,6 +157,23 @@ Vrij en open source onder de EUPL-1.2-licentie.
OCA\LaunchPad\Command\SetupCommand
+
+ OCA\LaunchPad\Settings\LaunchPadAdmin
+ OCA\LaunchPad\Settings\LaunchPadAdminSection
+
+
+
+
+
+ OCA\LaunchPad\Activity\Extension
+
+
+
LaunchPad
@@ -167,11 +182,4 @@ Vrij en open source onder de EUPL-1.2-licentie.
-5
-
-
-
- OCA\LaunchPad\Activity\Extension
-
diff --git a/composer.json b/composer.json
index 04982ff3..e20e84f8 100644
--- a/composer.json
+++ b/composer.json
@@ -1,103 +1,104 @@
{
- "name": "conductionnl/launchpad",
- "description": "Enhanced dashboard with grid layout and admin controls for Nextcloud",
- "type": "project",
- "license": "EUPL-1.2",
- "authors": [
- {
- "name": "LaunchPad Contributors"
- }
- ],
- "require": {
- "php": "^8.3"
- },
- "require-dev": {
- "cyclonedx/cyclonedx-php-composer": "^6.2",
- "edgedesign/phpqa": "^1.27",
- "nextcloud/coding-standard": "^1.4",
- "nextcloud/ocp": "dev-master",
- "phpcsstandards/phpcsextra": "^1.4",
- "phpmd/phpmd": "^2.15",
- "phpmetrics/phpmetrics": "^2.8",
- "phpstan/phpstan": "^1.10",
- "phpunit/phpunit": "^10",
- "roave/security-advisories": "dev-latest",
- "squizlabs/php_codesniffer": "^3.9",
- "twig/twig": "^3.27.0",
- "vimeo/psalm": "^5.26"
- },
- "autoload": {
- "psr-4": {
- "OCA\\LaunchPad\\": "lib/"
- }
- },
- "autoload-dev": {
- "psr-4": {
- "Unit\\": "tests/Unit/"
- }
- },
- "scripts": {
- "lint": "find . -name \\*.php -not -path './vendor/*' -not -path './build/*' -print0 | xargs -0 -n1 php -l",
- "lint:initial-state": "php scripts/lint-initial-state.php",
- "lint:spec-annotations": "php tools/check-spec-annotations.php",
- "lint:licenses": "bash scripts/check-license-headers.sh",
- "cs:check": "./vendor/bin/phpcs --standard=phpcs.xml",
- "cs:fix": "./vendor/bin/phpcbf --standard=phpcs.xml",
- "phpcs": "./vendor/bin/phpcs --standard=phpcs.xml",
- "phpcs:fix": "./vendor/bin/phpcbf --standard=phpcs.xml",
- "phpcs:output": "./vendor/bin/phpcs --standard=phpcs.xml --report=json lib/ 2>/dev/null | tail -1 > phpcs-output.json",
- "phpmd": "E=0; ./vendor/bin/phpmd lib text phpmd.xml || E=$?; ./vendor/bin/phpmd lib text phpmd-unusedparams.xml || E=$?; exit $E",
- "phpmetrics": "./vendor/bin/phpmetrics --report-html=phpmetrics lib/",
- "phpmetrics:violations": "./vendor/bin/phpmetrics --violations-xml=phpmetrics/violations.xml lib/",
- "psalm": "./vendor/bin/psalm --threads=1 --no-cache",
- "phpstan": "./vendor/bin/phpstan analyse --memory-limit=1G",
- "test": "phpunit --configuration phpunit.xml",
- "test:unit": "./vendor/bin/phpunit --configuration phpunit.xml --colors=always",
- "test:all": "./vendor/bin/phpunit --configuration phpunit.xml --colors=always",
- "test:integration": "if command -v newman >/dev/null 2>&1; then newman run tests/integration/launchpad.postman_collection.json --environment tests/integration/local.env.json; else npx --yes newman run tests/integration/launchpad.postman_collection.json --environment tests/integration/local.env.json; fi",
- "newman": "@test:integration",
- "newman:coverage": "node tests/integration/.coverage-check.js",
- "check": "E=0; for CMD in lint phpcs psalm test:unit; do echo; echo \"=== $CMD ===\"; composer $CMD || E=1; done; echo; if [ $E -eq 0 ]; then echo \"ALL CHECKS PASSED\"; else echo \"SOME CHECKS FAILED (see above)\"; fi; exit $E",
- "check:full": "E=0; for CMD in lint phpcs psalm phpstan test:all; do echo; echo \"=== $CMD ===\"; composer $CMD || E=1; done; echo; if [ $E -eq 0 ]; then echo \"ALL CHECKS PASSED\"; else echo \"SOME CHECKS FAILED (see above)\"; fi; exit $E",
- "check:strict": "E=0; for CMD in lint lint:initial-state lint:spec-annotations lint:licenses phpcs phpmd psalm phpstan test:all; do echo; echo \"=== $CMD ===\"; composer $CMD || E=1; done; echo; if [ $E -eq 0 ]; then echo \"ALL CHECKS PASSED\"; else echo \"SOME CHECKS FAILED (see above)\"; fi; exit $E",
- "fix": [
- "@cs:fix"
- ],
- "phpqa": "./vendor/bin/phpqa --report --analyzedDirs lib --buildDir phpqa",
- "phpqa:full": "./vendor/bin/phpqa --report --analyzedDirs lib --buildDir phpqa --tools phpcs:0,phpmd:0,phploc:0,phpmetrics,phpcpd:0,parallel-lint:0",
- "phpqa:ci": "./vendor/bin/phpqa --report --analyzedDirs lib --buildDir phpqa --tools phpcs,phpmd,phploc,phpmetrics,phpcpd,parallel-lint",
- "qa:check": [
- "@phpqa"
- ],
- "qa:full": [
- "@phpqa:full"
- ],
- "test:coverage": "./vendor/bin/phpunit --configuration phpunit.xml --coverage-html=coverage/html --coverage-clover=coverage/clover.xml --colors=always",
- "coverage:check": "php -r \"\\$xml = simplexml_load_file('coverage/clover.xml'); \\$metrics = \\$xml->project->metrics; \\$statements = (int)\\$metrics['statements']; \\$covered = (int)\\$metrics['coveredstatements']; \\$percentage = \\$statements > 0 ? round((\\$covered / \\$statements) * 100, 2) : 0; echo 'Coverage: ' . \\$percentage . '%' . PHP_EOL; exit(\\$percentage < 75 ? 1 : 0);\"",
- "quality:phpcs-score": "./vendor/bin/phpcs --standard=phpcs.xml --report=json lib/ | php -r \"\\$json = json_decode(file_get_contents('php://stdin'), true); \\$errors = \\$json['totals']['errors'] ?? 0; \\$warnings = \\$json['totals']['warnings'] ?? 0; \\$score = 1000 - \\$errors - (\\$warnings / 2); echo 'PHPCS Score: ' . \\$score . ' (Errors: ' . \\$errors . ', Warnings: ' . \\$warnings . ')' . PHP_EOL;\"",
- "quality:phpmd-score": "phpmd lib/ json phpmd.xml | php -r \"\\$input = file_get_contents('php://stdin'); \\$json = json_decode(\\$input, true); \\$violations = count(\\$json['files'] ?? []); \\$score = 1000 - (\\$violations * 10); echo 'PHPMD Score: ' . \\$score . ' (Violations: ' . \\$violations . ')' . PHP_EOL;\" || echo 'PHPMD not available'",
- "quality:psalm-score": "./vendor/bin/psalm --output-format=json --no-cache | php -r \"\\$input = file_get_contents('php://stdin'); \\$json = json_decode(\\$input, true); \\$errors = count(\\$json ?? []); \\$score = 1000 - (\\$errors * 5); echo 'Psalm Score: ' . \\$score . ' (Errors: ' . \\$errors . ')' . PHP_EOL;\" || echo 'Psalm not available'",
- "quality:phpstan-score": "./vendor/bin/phpstan analyse --memory-limit=1G --error-format=json --no-progress | php -r \"\\$input = file_get_contents('php://stdin'); \\$json = json_decode(\\$input, true); \\$errors = \\$json['totals']['file_errors'] ?? 0; \\$score = 1000 - (\\$errors * 5); echo 'PHPStan Score: ' . \\$score . ' (Errors: ' . \\$errors . ')' . PHP_EOL;\" || echo 'PHPStan not available'",
- "quality:score": [
- "@quality:phpcs-score",
- "@quality:phpmd-score",
- "@quality:psalm-score",
- "@quality:phpstan-score"
- ],
- "post-install-cmd": [
- "git config core.hooksPath .githooks || true"
- ]
- },
- "config": {
- "allow-plugins": {
- "composer/package-versions-deprecated": true,
- "dealerdirect/phpcodesniffer-composer-installer": true,
- "cyclonedx/cyclonedx-php-composer": true
- },
- "optimize-autoloader": true,
- "sort-packages": true,
- "platform": {
- "php": "8.3"
- }
- }
+ "name": "conductionnl/launchpad",
+ "description": "Enhanced dashboard with grid layout and admin controls for Nextcloud",
+ "type": "project",
+ "license": "EUPL-1.2",
+ "authors": [
+ {
+ "name": "LaunchPad Contributors"
+ }
+ ],
+ "require": {
+ "php": "^8.3"
+ },
+ "require-dev": {
+ "conduction/coding-standard": "^1.0",
+ "conduction/hydra-gates": "^1.0",
+ "cyclonedx/cyclonedx-php-composer": "^6.2",
+ "edgedesign/phpqa": "^1.27",
+ "nextcloud/ocp": "^34.0",
+ "phpcsstandards/phpcsextra": "^1.4",
+ "phpmd/phpmd": "^2.15",
+ "phpmetrics/phpmetrics": "^2.8",
+ "phpstan/phpstan": "^1.10",
+ "phpunit/phpunit": "^10",
+ "roave/security-advisories": "dev-latest",
+ "squizlabs/php_codesniffer": "^3.9",
+ "twig/twig": "^3.27.0",
+ "vimeo/psalm": "^5.26"
+ },
+ "autoload": {
+ "psr-4": {
+ "OCA\\LaunchPad\\": "lib/"
+ }
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "Unit\\": "tests/Unit/"
+ }
+ },
+ "scripts": {
+ "lint": "find . -name \\*.php -not -path './vendor/*' -not -path './build/*' -print0 | xargs -0 -n1 php -l",
+ "lint:initial-state": "php scripts/lint-initial-state.php",
+ "lint:spec-annotations": "php tools/check-spec-annotations.php",
+ "lint:licenses": "bash scripts/check-license-headers.sh",
+ "cs:check": "php-cs-fixer fix --dry-run --diff",
+ "cs:fix": "php-cs-fixer fix",
+ "phpcs": "./vendor/bin/phpcs --standard=phpcs.xml",
+ "phpcs:fix": "./vendor/bin/phpcbf --standard=phpcs.xml",
+ "phpcs:output": "./vendor/bin/phpcs --standard=phpcs.xml --report=json lib/ 2>/dev/null | tail -1 > phpcs-output.json",
+ "phpmd": "E=0; ./vendor/bin/phpmd lib text phpmd.xml || E=$?; ./vendor/bin/phpmd lib text phpmd-unusedparams.xml || E=$?; exit $E",
+ "phpmetrics": "./vendor/bin/phpmetrics --report-html=phpmetrics lib/",
+ "phpmetrics:violations": "./vendor/bin/phpmetrics --violations-xml=phpmetrics/violations.xml lib/",
+ "psalm": "./vendor/bin/psalm --threads=1 --no-cache",
+ "phpstan": "./vendor/bin/phpstan analyse --memory-limit=1G",
+ "test": "phpunit --configuration phpunit.xml",
+ "test:unit": "./vendor/bin/phpunit --configuration phpunit.xml --colors=always",
+ "test:all": "./vendor/bin/phpunit --configuration phpunit.xml --colors=always",
+ "test:integration": "if command -v newman >/dev/null 2>&1; then newman run tests/integration/launchpad.postman_collection.json --environment tests/integration/local.env.json; else npx --yes newman run tests/integration/launchpad.postman_collection.json --environment tests/integration/local.env.json; fi",
+ "newman": "@test:integration",
+ "newman:coverage": "node tests/integration/.coverage-check.js",
+ "check": "E=0; for CMD in lint phpcs psalm test:unit; do echo; echo \"=== $CMD ===\"; composer $CMD || E=1; done; echo; if [ $E -eq 0 ]; then echo \"ALL CHECKS PASSED\"; else echo \"SOME CHECKS FAILED (see above)\"; fi; exit $E",
+ "check:full": "E=0; for CMD in lint phpcs psalm phpstan test:all; do echo; echo \"=== $CMD ===\"; composer $CMD || E=1; done; echo; if [ $E -eq 0 ]; then echo \"ALL CHECKS PASSED\"; else echo \"SOME CHECKS FAILED (see above)\"; fi; exit $E",
+ "check:strict": "E=0; for CMD in lint lint:initial-state lint:spec-annotations lint:licenses phpcs phpmd psalm phpstan test:all; do echo; echo \"=== $CMD ===\"; composer $CMD || E=1; done; echo; if [ $E -eq 0 ]; then echo \"ALL CHECKS PASSED\"; else echo \"SOME CHECKS FAILED (see above)\"; fi; exit $E",
+ "fix": [
+ "@cs:fix"
+ ],
+ "phpqa": "./vendor/bin/phpqa --report --analyzedDirs lib --buildDir phpqa",
+ "phpqa:full": "./vendor/bin/phpqa --report --analyzedDirs lib --buildDir phpqa --tools phpcs:0,phpmd:0,phploc:0,phpmetrics,phpcpd:0,parallel-lint:0",
+ "phpqa:ci": "./vendor/bin/phpqa --report --analyzedDirs lib --buildDir phpqa --tools phpcs,phpmd,phploc,phpmetrics,phpcpd,parallel-lint",
+ "qa:check": [
+ "@phpqa"
+ ],
+ "qa:full": [
+ "@phpqa:full"
+ ],
+ "test:coverage": "./vendor/bin/phpunit --configuration phpunit.xml --coverage-html=coverage/html --coverage-clover=coverage/clover.xml --colors=always",
+ "coverage:check": "php -r \"\\$xml = simplexml_load_file('coverage/clover.xml'); \\$metrics = \\$xml->project->metrics; \\$statements = (int)\\$metrics['statements']; \\$covered = (int)\\$metrics['coveredstatements']; \\$percentage = \\$statements > 0 ? round((\\$covered / \\$statements) * 100, 2) : 0; echo 'Coverage: ' . \\$percentage . '%' . PHP_EOL; exit(\\$percentage < 75 ? 1 : 0);\"",
+ "quality:phpcs-score": "./vendor/bin/phpcs --standard=phpcs.xml --report=json lib/ | php -r \"\\$json = json_decode(file_get_contents('php://stdin'), true); \\$errors = \\$json['totals']['errors'] ?? 0; \\$warnings = \\$json['totals']['warnings'] ?? 0; \\$score = 1000 - \\$errors - (\\$warnings / 2); echo 'PHPCS Score: ' . \\$score . ' (Errors: ' . \\$errors . ', Warnings: ' . \\$warnings . ')' . PHP_EOL;\"",
+ "quality:phpmd-score": "phpmd lib/ json phpmd.xml | php -r \"\\$input = file_get_contents('php://stdin'); \\$json = json_decode(\\$input, true); \\$violations = count(\\$json['files'] ?? []); \\$score = 1000 - (\\$violations * 10); echo 'PHPMD Score: ' . \\$score . ' (Violations: ' . \\$violations . ')' . PHP_EOL;\" || echo 'PHPMD not available'",
+ "quality:psalm-score": "./vendor/bin/psalm --output-format=json --no-cache | php -r \"\\$input = file_get_contents('php://stdin'); \\$json = json_decode(\\$input, true); \\$errors = count(\\$json ?? []); \\$score = 1000 - (\\$errors * 5); echo 'Psalm Score: ' . \\$score . ' (Errors: ' . \\$errors . ')' . PHP_EOL;\" || echo 'Psalm not available'",
+ "quality:phpstan-score": "./vendor/bin/phpstan analyse --memory-limit=1G --error-format=json --no-progress | php -r \"\\$input = file_get_contents('php://stdin'); \\$json = json_decode(\\$input, true); \\$errors = \\$json['totals']['file_errors'] ?? 0; \\$score = 1000 - (\\$errors * 5); echo 'PHPStan Score: ' . \\$score . ' (Errors: ' . \\$errors . ')' . PHP_EOL;\" || echo 'PHPStan not available'",
+ "quality:score": [
+ "@quality:phpcs-score",
+ "@quality:phpmd-score",
+ "@quality:psalm-score",
+ "@quality:phpstan-score"
+ ],
+ "post-install-cmd": [
+ "git config core.hooksPath .githooks || true"
+ ]
+ },
+ "config": {
+ "allow-plugins": {
+ "composer/package-versions-deprecated": true,
+ "dealerdirect/phpcodesniffer-composer-installer": true,
+ "cyclonedx/cyclonedx-php-composer": true
+ },
+ "optimize-autoloader": true,
+ "sort-packages": true,
+ "platform": {
+ "php": "8.3"
+ }
+ }
}
diff --git a/composer.lock b/composer.lock
index 9fc97b2b..5b534673 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "52bb6fcc1120064de3c3a52f0a39a12e",
+ "content-hash": "edc4c14f2d591cac0bb3cc2013991e7c",
"packages": [],
"packages-dev": [
{
@@ -460,6 +460,110 @@
],
"time": "2024-05-06T16:37:16+00:00"
},
+ {
+ "name": "conduction/coding-standard",
+ "version": "v1.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/ConductionNL/coding-standard.git",
+ "reference": "a1854f13cb735e46ecd010767593b4d7bc90d974"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/ConductionNL/coding-standard/zipball/a1854f13cb735e46ecd010767593b4d7bc90d974",
+ "reference": "a1854f13cb735e46ecd010767593b4d7bc90d974",
+ "shasum": ""
+ },
+ "require": {
+ "nextcloud/coding-standard": "^1.4",
+ "php": "^8.1"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Conduction\\CodingStandard\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "EUPL-1.2"
+ ],
+ "authors": [
+ {
+ "name": "Conduction",
+ "homepage": "https://conduction.nl"
+ }
+ ],
+ "description": "Conduction coding standards for the PHP CS Fixer. Extends nextcloud/coding-standard — adds rules, never overrides them.",
+ "homepage": "https://github.com/ConductionNL/coding-standard",
+ "keywords": [
+ "coding-standard",
+ "conduction",
+ "dev",
+ "nextcloud",
+ "php-cs-fixer"
+ ],
+ "support": {
+ "docs": "https://docs.conduction.nl/WayOfWork/ci-cd/",
+ "issues": "https://github.com/ConductionNL/coding-standard/issues",
+ "source": "https://github.com/ConductionNL/coding-standard/tree/v1.0.0"
+ },
+ "time": "2026-08-12T08:27:21+00:00"
+ },
+ {
+ "name": "conduction/hydra-gates",
+ "version": "v1.7.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/ConductionNL/.github.git",
+ "reference": "b9c6520a648468ab2055ba4ce498348b31ab1f57"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/ConductionNL/.github/zipball/b9c6520a648468ab2055ba4ce498348b31ab1f57",
+ "reference": "b9c6520a648468ab2055ba4ce498348b31ab1f57",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1"
+ },
+ "bin": [
+ "hydra-gates/bin/hydra-gates"
+ ],
+ "type": "library",
+ "extra": {
+ "hydra-gates": {
+ "runner": "hydra-gates/scripts/run-hydra-gates.sh",
+ "helpers": "hydra-gates/scripts/lib",
+ "schemas": "hydra-gates/scripts/schemas"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "EUPL-1.2"
+ ],
+ "authors": [
+ {
+ "name": "Conduction",
+ "homepage": "https://conduction.nl"
+ }
+ ],
+ "description": "Hydra's mechanical quality gates, packaged so any repo can run them against its own diff. The exit code is the failure COUNT.",
+ "homepage": "https://github.com/ConductionNL/.github/tree/main/hydra-gates",
+ "keywords": [
+ "conduction",
+ "gates",
+ "nextcloud",
+ "quality",
+ "static-analysis"
+ ],
+ "support": {
+ "docs": "https://github.com/ConductionNL/.github/blob/main/hydra-gates/README.md",
+ "issues": "https://github.com/ConductionNL/.github/issues",
+ "source": "https://github.com/ConductionNL/.github/tree/v1.7.0"
+ },
+ "time": "2026-08-12T09:38:06+00:00"
+ },
{
"name": "consolidation/annotated-command",
"version": "4.10.5",
@@ -1531,16 +1635,16 @@
},
{
"name": "kubawerlos/php-cs-fixer-custom-fixers",
- "version": "v3.37.1",
+ "version": "v3.37.2",
"source": {
"type": "git",
"url": "https://github.com/kubawerlos/php-cs-fixer-custom-fixers.git",
- "reference": "e0ec1f602a1d0836909e9079262dbaf58eaf3804"
+ "reference": "678df979ce743466b42ddb6eea46b3f4c9a7bade"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/kubawerlos/php-cs-fixer-custom-fixers/zipball/e0ec1f602a1d0836909e9079262dbaf58eaf3804",
- "reference": "e0ec1f602a1d0836909e9079262dbaf58eaf3804",
+ "url": "https://api.github.com/repos/kubawerlos/php-cs-fixer-custom-fixers/zipball/678df979ce743466b42ddb6eea46b3f4c9a7bade",
+ "reference": "678df979ce743466b42ddb6eea46b3f4c9a7bade",
"shasum": ""
},
"require": {
@@ -1571,7 +1675,7 @@
"description": "A set of custom fixers for PHP CS Fixer",
"support": {
"issues": "https://github.com/kubawerlos/php-cs-fixer-custom-fixers/issues",
- "source": "https://github.com/kubawerlos/php-cs-fixer-custom-fixers/tree/v3.37.1"
+ "source": "https://github.com/kubawerlos/php-cs-fixer-custom-fixers/tree/v3.37.2"
},
"funding": [
{
@@ -1579,7 +1683,7 @@
"type": "github"
}
],
- "time": "2026-04-28T16:41:56+00:00"
+ "time": "2026-05-12T16:22:19+00:00"
},
{
"name": "league/container",
@@ -1776,16 +1880,16 @@
},
{
"name": "nextcloud/coding-standard",
- "version": "v1.4.0",
+ "version": "v1.5.0",
"source": {
"type": "git",
"url": "https://github.com/nextcloud/coding-standard.git",
- "reference": "8e06808c1423e9208d63d1bd205b9a38bd400011"
+ "reference": "80547a93236fbb9c783e05f0f0899043851b0dba"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/nextcloud/coding-standard/zipball/8e06808c1423e9208d63d1bd205b9a38bd400011",
- "reference": "8e06808c1423e9208d63d1bd205b9a38bd400011",
+ "url": "https://api.github.com/repos/nextcloud/coding-standard/zipball/80547a93236fbb9c783e05f0f0899043851b0dba",
+ "reference": "80547a93236fbb9c783e05f0f0899043851b0dba",
"shasum": ""
},
"require": {
@@ -1815,37 +1919,36 @@
],
"support": {
"issues": "https://github.com/nextcloud/coding-standard/issues",
- "source": "https://github.com/nextcloud/coding-standard/tree/v1.4.0"
+ "source": "https://github.com/nextcloud/coding-standard/tree/v1.5.0"
},
- "time": "2025-06-19T12:27:27+00:00"
+ "time": "2026-05-19T18:30:09+00:00"
},
{
"name": "nextcloud/ocp",
- "version": "dev-master",
+ "version": "v34.0.2",
"source": {
"type": "git",
"url": "https://github.com/nextcloud-deps/ocp.git",
- "reference": "51b4669e894d49830273d637174a2f1a48118eaa"
+ "reference": "81cbb2c594afe0fa978885bf7accd0440199520a"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/nextcloud-deps/ocp/zipball/51b4669e894d49830273d637174a2f1a48118eaa",
- "reference": "51b4669e894d49830273d637174a2f1a48118eaa",
+ "url": "https://api.github.com/repos/nextcloud-deps/ocp/zipball/81cbb2c594afe0fa978885bf7accd0440199520a",
+ "reference": "81cbb2c594afe0fa978885bf7accd0440199520a",
"shasum": ""
},
"require": {
- "php": "~8.3 || ~8.4 || ~8.5",
+ "php": "~8.2 || ~8.3 || ~8.4 || ~8.5",
"psr/clock": "^1.0",
"psr/container": "^2.0.2",
"psr/event-dispatcher": "^1.0",
"psr/http-client": "^1.0.3",
"psr/log": "^3.0.2"
},
- "default-branch": true,
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "35.0.0-dev"
+ "dev-stable34": "34.0.0-dev"
}
},
"notification-url": "https://packagist.org/downloads/",
@@ -1865,9 +1968,9 @@
"description": "Composer package containing Nextcloud's public OCP API and the unstable NCU API",
"support": {
"issues": "https://github.com/nextcloud-deps/ocp/issues",
- "source": "https://github.com/nextcloud-deps/ocp/tree/master"
+ "source": "https://github.com/nextcloud-deps/ocp/tree/v34.0.2"
},
- "time": "2026-06-26T02:11:08+00:00"
+ "time": "2026-07-16T01:28:13+00:00"
},
{
"name": "nikic/php-parser",
@@ -2463,16 +2566,16 @@
},
{
"name": "php-cs-fixer/shim",
- "version": "v3.95.1",
+ "version": "v3.95.18",
"source": {
"type": "git",
"url": "https://github.com/PHP-CS-Fixer/shim.git",
- "reference": "f81ccf51ca60cc9dd21358ffba0e79ebd2ebb78a"
+ "reference": "9b815f2ba5c581faaaec1386dcda4c16d511e6bb"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/PHP-CS-Fixer/shim/zipball/f81ccf51ca60cc9dd21358ffba0e79ebd2ebb78a",
- "reference": "f81ccf51ca60cc9dd21358ffba0e79ebd2ebb78a",
+ "url": "https://api.github.com/repos/PHP-CS-Fixer/shim/zipball/9b815f2ba5c581faaaec1386dcda4c16d511e6bb",
+ "reference": "9b815f2ba5c581faaaec1386dcda4c16d511e6bb",
"shasum": ""
},
"require": {
@@ -2509,9 +2612,9 @@
"description": "A tool to automatically fix PHP code style",
"support": {
"issues": "https://github.com/PHP-CS-Fixer/shim/issues",
- "source": "https://github.com/PHP-CS-Fixer/shim/tree/v3.95.1"
+ "source": "https://github.com/PHP-CS-Fixer/shim/tree/v3.95.18"
},
- "time": "2026-04-12T17:00:34+00:00"
+ "time": "2026-07-30T15:46:28+00:00"
},
{
"name": "phpcsstandards/phpcsextra",
@@ -6211,16 +6314,16 @@
},
{
"name": "symfony/console",
- "version": "v6.4.36",
+ "version": "v6.4.43",
"source": {
"type": "git",
"url": "https://github.com/symfony/console.git",
- "reference": "9f481cfb580db8bcecc9b2d4c63f3e13df022ad5"
+ "reference": "3b643aa587acbc42f967a429af088a56ed8f046d"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/console/zipball/9f481cfb580db8bcecc9b2d4c63f3e13df022ad5",
- "reference": "9f481cfb580db8bcecc9b2d4c63f3e13df022ad5",
+ "url": "https://api.github.com/repos/symfony/console/zipball/3b643aa587acbc42f967a429af088a56ed8f046d",
+ "reference": "3b643aa587acbc42f967a429af088a56ed8f046d",
"shasum": ""
},
"require": {
@@ -6285,7 +6388,7 @@
"terminal"
],
"support": {
- "source": "https://github.com/symfony/console/tree/v6.4.36"
+ "source": "https://github.com/symfony/console/tree/v6.4.43"
},
"funding": [
{
@@ -6305,7 +6408,7 @@
"type": "tidelift"
}
],
- "time": "2026-03-27T15:30:51+00:00"
+ "time": "2026-07-26T14:44:19+00:00"
},
{
"name": "symfony/dependency-injection",
@@ -6394,16 +6497,16 @@
},
{
"name": "symfony/deprecation-contracts",
- "version": "v3.7.0",
+ "version": "v3.7.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/deprecation-contracts.git",
- "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b"
+ "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b",
- "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b",
+ "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d",
+ "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d",
"shasum": ""
},
"require": {
@@ -6441,7 +6544,7 @@
"description": "A generic function and convention to trigger deprecation notices",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.0"
+ "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1"
},
"funding": [
{
@@ -6461,20 +6564,20 @@
"type": "tidelift"
}
],
- "time": "2026-04-13T15:52:40+00:00"
+ "time": "2026-06-05T06:23:12+00:00"
},
{
"name": "symfony/event-dispatcher",
- "version": "v6.4.36",
+ "version": "v6.4.43",
"source": {
"type": "git",
"url": "https://github.com/symfony/event-dispatcher.git",
- "reference": "fc828863e26ceec86e2513b5e46aa0b149d76b69"
+ "reference": "ac405d324c10ebbbde6a6e58379bf81db10f1dbf"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/fc828863e26ceec86e2513b5e46aa0b149d76b69",
- "reference": "fc828863e26ceec86e2513b5e46aa0b149d76b69",
+ "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/ac405d324c10ebbbde6a6e58379bf81db10f1dbf",
+ "reference": "ac405d324c10ebbbde6a6e58379bf81db10f1dbf",
"shasum": ""
},
"require": {
@@ -6525,7 +6628,7 @@
"description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/event-dispatcher/tree/v6.4.36"
+ "source": "https://github.com/symfony/event-dispatcher/tree/v6.4.43"
},
"funding": [
{
@@ -6545,20 +6648,20 @@
"type": "tidelift"
}
],
- "time": "2026-03-30T11:18:01+00:00"
+ "time": "2026-07-21T14:00:19+00:00"
},
{
"name": "symfony/event-dispatcher-contracts",
- "version": "v3.6.0",
+ "version": "v3.7.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/event-dispatcher-contracts.git",
- "reference": "59eb412e93815df44f05f342958efa9f46b1e586"
+ "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/59eb412e93815df44f05f342958efa9f46b1e586",
- "reference": "59eb412e93815df44f05f342958efa9f46b1e586",
+ "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/c7de7a00ffb67842132da02ea92988a39ccd9f4e",
+ "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e",
"shasum": ""
},
"require": {
@@ -6572,7 +6675,7 @@
"name": "symfony/contracts"
},
"branch-alias": {
- "dev-main": "3.6-dev"
+ "dev-main": "3.7-dev"
}
},
"autoload": {
@@ -6605,7 +6708,7 @@
"standards"
],
"support": {
- "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.6.0"
+ "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.1"
},
"funding": [
{
@@ -6616,25 +6719,29 @@
"url": "https://github.com/fabpot",
"type": "github"
},
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
- "time": "2024-09-25T14:21:43+00:00"
+ "time": "2026-06-05T06:23:12+00:00"
},
{
"name": "symfony/filesystem",
- "version": "v6.4.34",
+ "version": "v6.4.43",
"source": {
"type": "git",
"url": "https://github.com/symfony/filesystem.git",
- "reference": "01ffe0411b842f93c571e5c391f289c3fdd498c3"
+ "reference": "9ff03da12d67649fbd1f34ca95951554624d0a16"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/filesystem/zipball/01ffe0411b842f93c571e5c391f289c3fdd498c3",
- "reference": "01ffe0411b842f93c571e5c391f289c3fdd498c3",
+ "url": "https://api.github.com/repos/symfony/filesystem/zipball/9ff03da12d67649fbd1f34ca95951554624d0a16",
+ "reference": "9ff03da12d67649fbd1f34ca95951554624d0a16",
"shasum": ""
},
"require": {
@@ -6671,7 +6778,7 @@
"description": "Provides basic utilities for the filesystem",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/filesystem/tree/v6.4.34"
+ "source": "https://github.com/symfony/filesystem/tree/v6.4.43"
},
"funding": [
{
@@ -6691,20 +6798,20 @@
"type": "tidelift"
}
],
- "time": "2026-02-24T17:51:06+00:00"
+ "time": "2026-06-27T10:13:35+00:00"
},
{
"name": "symfony/finder",
- "version": "v6.4.34",
+ "version": "v6.4.42",
"source": {
"type": "git",
"url": "https://github.com/symfony/finder.git",
- "reference": "9590e86be1d1c57bfbb16d0dd040345378c20896"
+ "reference": "0b73dac42493acbadbba644207a715b254e9b029"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/finder/zipball/9590e86be1d1c57bfbb16d0dd040345378c20896",
- "reference": "9590e86be1d1c57bfbb16d0dd040345378c20896",
+ "url": "https://api.github.com/repos/symfony/finder/zipball/0b73dac42493acbadbba644207a715b254e9b029",
+ "reference": "0b73dac42493acbadbba644207a715b254e9b029",
"shasum": ""
},
"require": {
@@ -6739,7 +6846,7 @@
"description": "Finds files and directories via an intuitive fluent interface",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/finder/tree/v6.4.34"
+ "source": "https://github.com/symfony/finder/tree/v6.4.42"
},
"funding": [
{
@@ -6759,7 +6866,7 @@
"type": "tidelift"
}
],
- "time": "2026-01-28T15:16:37+00:00"
+ "time": "2026-06-26T15:18:24+00:00"
},
{
"name": "symfony/polyfill-ctype",
@@ -6846,16 +6953,16 @@
},
{
"name": "symfony/polyfill-intl-grapheme",
- "version": "v1.37.0",
+ "version": "v1.41.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-intl-grapheme.git",
- "reference": "4864388bfbd3001ce88e234fab652acd91fdc57e"
+ "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/4864388bfbd3001ce88e234fab652acd91fdc57e",
- "reference": "4864388bfbd3001ce88e234fab652acd91fdc57e",
+ "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/bb899c1db0aa8127dc3afe8cda4a67eb24915f8d",
+ "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d",
"shasum": ""
},
"require": {
@@ -6904,7 +7011,7 @@
"shim"
],
"support": {
- "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.37.0"
+ "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.41.0"
},
"funding": [
{
@@ -6924,20 +7031,20 @@
"type": "tidelift"
}
],
- "time": "2026-04-26T13:13:48+00:00"
+ "time": "2026-07-28T08:25:59+00:00"
},
{
"name": "symfony/polyfill-intl-normalizer",
- "version": "v1.37.0",
+ "version": "v1.38.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-intl-normalizer.git",
- "reference": "3833d7255cc303546435cb650316bff708a1c75c"
+ "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/3833d7255cc303546435cb650316bff708a1c75c",
- "reference": "3833d7255cc303546435cb650316bff708a1c75c",
+ "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/2d446c214bdbe5b71bde5011b060a05fece3ae6b",
+ "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b",
"shasum": ""
},
"require": {
@@ -6989,7 +7096,7 @@
"shim"
],
"support": {
- "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.37.0"
+ "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.38.0"
},
"funding": [
{
@@ -7009,20 +7116,20 @@
"type": "tidelift"
}
],
- "time": "2024-09-09T11:45:10+00:00"
+ "time": "2026-05-25T13:48:31+00:00"
},
{
"name": "symfony/polyfill-mbstring",
- "version": "v1.38.1",
+ "version": "v1.38.2",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-mbstring.git",
- "reference": "14c5439eec4ccff081ac14eca2dc57feb2a66d92"
+ "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/14c5439eec4ccff081ac14eca2dc57feb2a66d92",
- "reference": "14c5439eec4ccff081ac14eca2dc57feb2a66d92",
+ "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6",
+ "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6",
"shasum": ""
},
"require": {
@@ -7074,7 +7181,7 @@
"shim"
],
"support": {
- "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.1"
+ "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2"
},
"funding": [
{
@@ -7094,20 +7201,20 @@
"type": "tidelift"
}
],
- "time": "2026-05-26T12:51:13+00:00"
+ "time": "2026-05-27T06:59:30+00:00"
},
{
"name": "symfony/polyfill-php81",
- "version": "v1.37.0",
+ "version": "v1.38.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php81.git",
- "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c"
+ "reference": "6bfb9c766cacffbc8e118cb87217d08ed84e5cd7"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c",
- "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c",
+ "url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/6bfb9c766cacffbc8e118cb87217d08ed84e5cd7",
+ "reference": "6bfb9c766cacffbc8e118cb87217d08ed84e5cd7",
"shasum": ""
},
"require": {
@@ -7154,7 +7261,7 @@
"shim"
],
"support": {
- "source": "https://github.com/symfony/polyfill-php81/tree/v1.37.0"
+ "source": "https://github.com/symfony/polyfill-php81/tree/v1.38.1"
},
"funding": [
{
@@ -7174,20 +7281,20 @@
"type": "tidelift"
}
],
- "time": "2024-09-09T11:45:10+00:00"
+ "time": "2026-05-26T12:45:58+00:00"
},
{
"name": "symfony/process",
- "version": "v6.4.33",
+ "version": "v6.4.41",
"source": {
"type": "git",
"url": "https://github.com/symfony/process.git",
- "reference": "c46e854e79b52d07666e43924a20cb6dc546644e"
+ "reference": "c8fc09bdfe9fde9aaa89b415a4477feaccec16a7"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/process/zipball/c46e854e79b52d07666e43924a20cb6dc546644e",
- "reference": "c46e854e79b52d07666e43924a20cb6dc546644e",
+ "url": "https://api.github.com/repos/symfony/process/zipball/c8fc09bdfe9fde9aaa89b415a4477feaccec16a7",
+ "reference": "c8fc09bdfe9fde9aaa89b415a4477feaccec16a7",
"shasum": ""
},
"require": {
@@ -7219,7 +7326,7 @@
"description": "Executes commands in sub-processes",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/process/tree/v6.4.33"
+ "source": "https://github.com/symfony/process/tree/v6.4.41"
},
"funding": [
{
@@ -7239,20 +7346,20 @@
"type": "tidelift"
}
],
- "time": "2026-01-23T16:02:12+00:00"
+ "time": "2026-05-23T13:47:21+00:00"
},
{
"name": "symfony/service-contracts",
- "version": "v3.6.1",
+ "version": "v3.7.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/service-contracts.git",
- "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43"
+ "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/service-contracts/zipball/45112560a3ba2d715666a509a0bc9521d10b6c43",
- "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43",
+ "url": "https://api.github.com/repos/symfony/service-contracts/zipball/c0a284bab1ed8aa0417e3d69250ab437739563a0",
+ "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0",
"shasum": ""
},
"require": {
@@ -7270,7 +7377,7 @@
"name": "symfony/contracts"
},
"branch-alias": {
- "dev-main": "3.6-dev"
+ "dev-main": "3.7-dev"
}
},
"autoload": {
@@ -7306,7 +7413,7 @@
"standards"
],
"support": {
- "source": "https://github.com/symfony/service-contracts/tree/v3.6.1"
+ "source": "https://github.com/symfony/service-contracts/tree/v3.7.1"
},
"funding": [
{
@@ -7326,26 +7433,27 @@
"type": "tidelift"
}
],
- "time": "2025-07-15T11:30:57+00:00"
+ "time": "2026-06-16T09:55:08+00:00"
},
{
"name": "symfony/string",
- "version": "v6.4.34",
+ "version": "v7.4.15",
"source": {
"type": "git",
"url": "https://github.com/symfony/string.git",
- "reference": "2adaf4106f2ef4c67271971bde6d3fe0a6936432"
+ "reference": "e394af32256bf9e7bf80849d95e589167c10097b"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/string/zipball/2adaf4106f2ef4c67271971bde6d3fe0a6936432",
- "reference": "2adaf4106f2ef4c67271971bde6d3fe0a6936432",
+ "url": "https://api.github.com/repos/symfony/string/zipball/e394af32256bf9e7bf80849d95e589167c10097b",
+ "reference": "e394af32256bf9e7bf80849d95e589167c10097b",
"shasum": ""
},
"require": {
- "php": ">=8.1",
+ "php": ">=8.2",
+ "symfony/deprecation-contracts": "^2.5|^3.0",
"symfony/polyfill-ctype": "~1.8",
- "symfony/polyfill-intl-grapheme": "~1.0",
+ "symfony/polyfill-intl-grapheme": "~1.33",
"symfony/polyfill-intl-normalizer": "~1.0",
"symfony/polyfill-mbstring": "~1.0"
},
@@ -7353,10 +7461,11 @@
"symfony/translation-contracts": "<2.5"
},
"require-dev": {
- "symfony/http-client": "^5.4|^6.0|^7.0",
- "symfony/intl": "^6.2|^7.0",
+ "symfony/emoji": "^7.1|^8.0",
+ "symfony/http-client": "^6.4|^7.0|^8.0",
+ "symfony/intl": "^6.4|^7.0|^8.0",
"symfony/translation-contracts": "^2.5|^3.0",
- "symfony/var-exporter": "^5.4|^6.0|^7.0"
+ "symfony/var-exporter": "^6.4|^7.0|^8.0"
},
"type": "library",
"autoload": {
@@ -7395,7 +7504,7 @@
"utf8"
],
"support": {
- "source": "https://github.com/symfony/string/tree/v6.4.34"
+ "source": "https://github.com/symfony/string/tree/v7.4.15"
},
"funding": [
{
@@ -7415,7 +7524,7 @@
"type": "tidelift"
}
],
- "time": "2026-02-08T20:44:54+00:00"
+ "time": "2026-07-28T07:33:02+00:00"
},
{
"name": "symfony/var-exporter",
@@ -7876,7 +7985,6 @@
"aliases": [],
"minimum-stability": "stable",
"stability-flags": {
- "nextcloud/ocp": 20,
"roave/security-advisories": 20
},
"prefer-stable": false,
diff --git a/lib/Activity/ActivityPublisher.php b/lib/Activity/ActivityPublisher.php
index 9ce0d4aa..fb39b0ff 100644
--- a/lib/Activity/ActivityPublisher.php
+++ b/lib/Activity/ActivityPublisher.php
@@ -40,329 +40,328 @@
/**
* Thin Activity emission service for LaunchPad.
*/
-class ActivityPublisher
-{
- /**
- * Constructor.
- *
- * @param IManager $manager The NC Activity manager.
- * @param IGroupManager $groupManager The NC group manager.
- * @param IUserManager $userManager The NC user manager.
- * @param DebounceHelper $debounce The debounce guard.
- * @param LoggerInterface $logger The logger.
- */
- public function __construct(
- private readonly IManager $manager,
- private readonly IGroupManager $groupManager,
- private readonly IUserManager $userManager,
- private readonly DebounceHelper $debounce,
- private readonly LoggerInterface $logger,
- ) {
- }//end __construct()
+class ActivityPublisher {
+ /**
+ * Constructor.
+ *
+ * @param IManager $manager The NC Activity manager.
+ * @param IGroupManager $groupManager The NC group manager.
+ * @param IUserManager $userManager The NC user manager.
+ * @param DebounceHelper $debounce The debounce guard.
+ * @param LoggerInterface $logger The logger.
+ */
+ public function __construct(
+ private readonly IManager $manager,
+ private readonly IGroupManager $groupManager,
+ private readonly IUserManager $userManager,
+ private readonly DebounceHelper $debounce,
+ private readonly LoggerInterface $logger,
+ ) {
+ }//end __construct()
- /**
- * Emit a single activity row to `$recipientUserId` for the given
- * dashboard.
- *
- * Unknown event types are silently dropped after a warning log entry
- * (REQ-ACT-002 contract). Reaction events go through the per-actor
- * debounce (REQ-ACT-007). Every NC `IManager` call is wrapped in a
- * try/catch so an Activity failure never propagates back into the
- * owning capability's HTTP handler (REQ-ACT-011 scenario).
- *
- * @param string $type The event-type constant value.
- * @param string $actorUserId The acting NC user ID.
- * @param string $recipientUserId The recipient NC user ID.
- * @param string $dashboardUuid The dashboard UUID.
- * @param string $dashboardName The human-readable dashboard name.
- * @param string $dashboardLink The absolute deep-link URL.
- * @param array $extraParams Optional extra params (e.g. `recipient`, `role`, `target`, `message`).
- *
- * @return bool True when the event was successfully published; false when suppressed or dropped.
- */
- public function publish(
- string $type,
- string $actorUserId,
- string $recipientUserId,
- string $dashboardUuid,
- string $dashboardName,
- string $dashboardLink,
- array $extraParams=[]
- ): bool {
- if (in_array(needle: $type, haystack: Extension::ALL_EVENTS, strict: true) === false) {
- $this->logger->warning(
- message: 'Unknown LaunchPad activity type rejected',
- context: [
- 'type' => $type,
- 'dashboard' => $dashboardUuid,
- ]
- );
- return false;
- }
+ /**
+ * Emit a single activity row to `$recipientUserId` for the given
+ * dashboard.
+ *
+ * Unknown event types are silently dropped after a warning log entry
+ * (REQ-ACT-002 contract). Reaction events go through the per-actor
+ * debounce (REQ-ACT-007). Every NC `IManager` call is wrapped in a
+ * try/catch so an Activity failure never propagates back into the
+ * owning capability's HTTP handler (REQ-ACT-011 scenario).
+ *
+ * @param string $type The event-type constant value.
+ * @param string $actorUserId The acting NC user ID.
+ * @param string $recipientUserId The recipient NC user ID.
+ * @param string $dashboardUuid The dashboard UUID.
+ * @param string $dashboardName The human-readable dashboard name.
+ * @param string $dashboardLink The absolute deep-link URL.
+ * @param array $extraParams Optional extra params (e.g. `recipient`, `role`, `target`, `message`).
+ *
+ * @return bool True when the event was successfully published; false when suppressed or dropped.
+ */
+ public function publish(
+ string $type,
+ string $actorUserId,
+ string $recipientUserId,
+ string $dashboardUuid,
+ string $dashboardName,
+ string $dashboardLink,
+ array $extraParams = [],
+ ): bool {
+ if (in_array(needle: $type, haystack: Extension::ALL_EVENTS, strict: true) === false) {
+ $this->logger->warning(
+ message: 'Unknown LaunchPad activity type rejected',
+ context: [
+ 'type' => $type,
+ 'dashboard' => $dashboardUuid,
+ ]
+ );
+ return false;
+ }
- if ($type === Extension::EVENT_REACTED
- && $this->debounce->allowReaction(
- actorUserId: $actorUserId,
- dashboardUuid: $dashboardUuid
- ) === false
- ) {
- return false;
- }
+ if ($type === Extension::EVENT_REACTED
+ && $this->debounce->allowReaction(
+ actorUserId: $actorUserId,
+ dashboardUuid: $dashboardUuid
+ ) === false
+ ) {
+ return false;
+ }
- try {
- $event = $this->buildEvent(
- type: $type,
- actorUserId: $actorUserId,
- recipientUserId: $recipientUserId,
- dashboardUuid: $dashboardUuid,
- dashboardName: $dashboardName,
- dashboardLink: $dashboardLink,
- extraParams: $extraParams
- );
- $this->manager->publish(event: $event);
- } catch (Throwable $e) {
- $this->logger->error(
- message: 'LaunchPad Activity publish failed',
- context: [
- 'type' => $type,
- 'dashboard' => $dashboardUuid,
- 'recipient' => $recipientUserId,
- 'exception' => $e,
- ]
- );
- return false;
- }//end try
+ try {
+ $event = $this->buildEvent(
+ type: $type,
+ actorUserId: $actorUserId,
+ recipientUserId: $recipientUserId,
+ dashboardUuid: $dashboardUuid,
+ dashboardName: $dashboardName,
+ dashboardLink: $dashboardLink,
+ extraParams: $extraParams
+ );
+ $this->manager->publish(event: $event);
+ } catch (Throwable $e) {
+ $this->logger->error(
+ message: 'LaunchPad Activity publish failed',
+ context: [
+ 'type' => $type,
+ 'dashboard' => $dashboardUuid,
+ 'recipient' => $recipientUserId,
+ 'exception' => $e,
+ ]
+ );
+ return false;
+ }//end try
- return true;
- }//end publish()
+ return true;
+ }//end publish()
- /**
- * Emit one activity row per recipient in `$recipientUserIds`, plus
- * one row to the actor (REQ-ACT-005). Recipients are de-duplicated
- * to prevent double-emission when the actor is also a recipient.
- *
- * @param string $type The event-type constant value.
- * @param string $actorUserId The acting NC user ID.
- * @param string $dashboardUuid The dashboard UUID.
- * @param string $dashboardName The dashboard name.
- * @param string $dashboardLink The dashboard link.
- * @param string[] $recipientUserIds Recipient NC user IDs.
- * @param array $extraParams Optional extra params.
- *
- * @return int The number of rows successfully written.
- */
- public function publishToRecipients(
- string $type,
- string $actorUserId,
- string $dashboardUuid,
- string $dashboardName,
- string $dashboardLink,
- array $recipientUserIds,
- array $extraParams=[]
- ): int {
- $unique = array_values(
- array: array_unique(
- array: array_merge([$actorUserId], $recipientUserIds)
- )
- );
+ /**
+ * Emit one activity row per recipient in `$recipientUserIds`, plus
+ * one row to the actor (REQ-ACT-005). Recipients are de-duplicated
+ * to prevent double-emission when the actor is also a recipient.
+ *
+ * @param string $type The event-type constant value.
+ * @param string $actorUserId The acting NC user ID.
+ * @param string $dashboardUuid The dashboard UUID.
+ * @param string $dashboardName The dashboard name.
+ * @param string $dashboardLink The dashboard link.
+ * @param string[] $recipientUserIds Recipient NC user IDs.
+ * @param array $extraParams Optional extra params.
+ *
+ * @return int The number of rows successfully written.
+ */
+ public function publishToRecipients(
+ string $type,
+ string $actorUserId,
+ string $dashboardUuid,
+ string $dashboardName,
+ string $dashboardLink,
+ array $recipientUserIds,
+ array $extraParams = [],
+ ): int {
+ $unique = array_values(
+ array: array_unique(
+ array: array_merge([$actorUserId], $recipientUserIds)
+ )
+ );
- $count = 0;
- foreach ($unique as $userId) {
- $params = $extraParams;
- $params['self'] = ($userId === $actorUserId);
- $published = $this->publish(
- type: $type,
- actorUserId: $actorUserId,
- recipientUserId: $userId,
- dashboardUuid: $dashboardUuid,
- dashboardName: $dashboardName,
- dashboardLink: $dashboardLink,
- extraParams: $params
- );
- if ($published === true) {
- $count++;
- }
- }
+ $count = 0;
+ foreach ($unique as $userId) {
+ $params = $extraParams;
+ $params['self'] = ($userId === $actorUserId);
+ $published = $this->publish(
+ type: $type,
+ actorUserId: $actorUserId,
+ recipientUserId: $userId,
+ dashboardUuid: $dashboardUuid,
+ dashboardName: $dashboardName,
+ dashboardLink: $dashboardLink,
+ extraParams: $params
+ );
+ if ($published === true) {
+ $count++;
+ }
+ }
- return $count;
- }//end publishToRecipients()
+ return $count;
+ }//end publishToRecipients()
- /**
- * Emit activity rows to every member of `$groupId` (REQ-ACT-006).
- *
- * Returns 0 (without raising) when the group is unknown or empty.
- * The actor is included exactly once even when they are also a
- * member of the group.
- *
- * @param string $type The event-type constant value.
- * @param string $actorUserId The acting NC user ID.
- * @param string $groupId The target group ID.
- * @param string $dashboardUuid The dashboard UUID.
- * @param string $dashboardName The dashboard name.
- * @param string $dashboardLink The dashboard link.
- * @param array $extraParams Optional extra params.
- *
- * @return int The number of rows successfully written.
- */
- public function publishToGroup(
- string $type,
- string $actorUserId,
- string $groupId,
- string $dashboardUuid,
- string $dashboardName,
- string $dashboardLink,
- array $extraParams=[]
- ): int {
- $group = $this->groupManager->get(gid: $groupId);
- if ($group === null) {
- return 0;
- }
+ /**
+ * Emit activity rows to every member of `$groupId` (REQ-ACT-006).
+ *
+ * Returns 0 (without raising) when the group is unknown or empty.
+ * The actor is included exactly once even when they are also a
+ * member of the group.
+ *
+ * @param string $type The event-type constant value.
+ * @param string $actorUserId The acting NC user ID.
+ * @param string $groupId The target group ID.
+ * @param string $dashboardUuid The dashboard UUID.
+ * @param string $dashboardName The dashboard name.
+ * @param string $dashboardLink The dashboard link.
+ * @param array $extraParams Optional extra params.
+ *
+ * @return int The number of rows successfully written.
+ */
+ public function publishToGroup(
+ string $type,
+ string $actorUserId,
+ string $groupId,
+ string $dashboardUuid,
+ string $dashboardName,
+ string $dashboardLink,
+ array $extraParams = [],
+ ): int {
+ $group = $this->groupManager->get(gid: $groupId);
+ if ($group === null) {
+ return 0;
+ }
- $userIds = [];
- foreach ($group->getUsers() as $user) {
- $userIds[] = $user->getUID();
- }
+ $userIds = [];
+ foreach ($group->getUsers() as $user) {
+ $userIds[] = $user->getUID();
+ }
- return $this->publishToRecipients(
- type: $type,
- actorUserId: $actorUserId,
- dashboardUuid: $dashboardUuid,
- dashboardName: $dashboardName,
- dashboardLink: $dashboardLink,
- recipientUserIds: $userIds,
- extraParams: $extraParams
- );
- }//end publishToGroup()
+ return $this->publishToRecipients(
+ type: $type,
+ actorUserId: $actorUserId,
+ dashboardUuid: $dashboardUuid,
+ dashboardName: $dashboardName,
+ dashboardLink: $dashboardLink,
+ recipientUserIds: $userIds,
+ extraParams: $extraParams
+ );
+ }//end publishToGroup()
- /**
- * Emit activity rows to every authenticated NC user (REQ-ACT-008).
- *
- * The full fan-out is gated by
- * `DebounceHelper::allowGlobalFanout(dashboardUuid, type)` —
- * suppressed events are logged at DEBUG and produce zero rows.
- *
- * @param string $type The event-type constant value.
- * @param string $actorUserId The acting NC user ID.
- * @param string $dashboardUuid The dashboard UUID.
- * @param string $dashboardName The dashboard name.
- * @param string $dashboardLink The dashboard link.
- * @param array $extraParams Optional extra params.
- *
- * @return int The number of rows successfully written (0 when debounced).
- */
- public function publishGlobal(
- string $type,
- string $actorUserId,
- string $dashboardUuid,
- string $dashboardName,
- string $dashboardLink,
- array $extraParams=[]
- ): int {
- if ($this->debounce->allowGlobalFanout(
- dashboardUuid: $dashboardUuid,
- eventType: $type
- ) === false
- ) {
- $this->logger->debug(
- message: 'LaunchPad global activity fan-out debounced',
- context: [
- 'type' => $type,
- 'dashboard' => $dashboardUuid,
- ]
- );
- return 0;
- }
+ /**
+ * Emit activity rows to every authenticated NC user (REQ-ACT-008).
+ *
+ * The full fan-out is gated by
+ * `DebounceHelper::allowGlobalFanout(dashboardUuid, type)` —
+ * suppressed events are logged at DEBUG and produce zero rows.
+ *
+ * @param string $type The event-type constant value.
+ * @param string $actorUserId The acting NC user ID.
+ * @param string $dashboardUuid The dashboard UUID.
+ * @param string $dashboardName The dashboard name.
+ * @param string $dashboardLink The dashboard link.
+ * @param array $extraParams Optional extra params.
+ *
+ * @return int The number of rows successfully written (0 when debounced).
+ */
+ public function publishGlobal(
+ string $type,
+ string $actorUserId,
+ string $dashboardUuid,
+ string $dashboardName,
+ string $dashboardLink,
+ array $extraParams = [],
+ ): int {
+ if ($this->debounce->allowGlobalFanout(
+ dashboardUuid: $dashboardUuid,
+ eventType: $type
+ ) === false
+ ) {
+ $this->logger->debug(
+ message: 'LaunchPad global activity fan-out debounced',
+ context: [
+ 'type' => $type,
+ 'dashboard' => $dashboardUuid,
+ ]
+ );
+ return 0;
+ }
- $count = 0;
- $this->userManager->callForAllUsers(
- callback: function (IUser $user) use (
- $type,
- $actorUserId,
- $dashboardUuid,
- $dashboardName,
- $dashboardLink,
- $extraParams,
- &$count
- ): void {
- $params = $extraParams;
- $params['self'] = ($user->getUID() === $actorUserId);
- $ok = $this->publish(
- type: $type,
- actorUserId: $actorUserId,
- recipientUserId: $user->getUID(),
- dashboardUuid: $dashboardUuid,
- dashboardName: $dashboardName,
- dashboardLink: $dashboardLink,
- extraParams: $params
- );
- if ($ok === true) {
- $count++;
- }
- }
- );
+ $count = 0;
+ $this->userManager->callForAllUsers(
+ callback: function (IUser $user) use (
+ $type,
+ $actorUserId,
+ $dashboardUuid,
+ $dashboardName,
+ $dashboardLink,
+ $extraParams,
+ &$count
+ ): void {
+ $params = $extraParams;
+ $params['self'] = ($user->getUID() === $actorUserId);
+ $ok = $this->publish(
+ type: $type,
+ actorUserId: $actorUserId,
+ recipientUserId: $user->getUID(),
+ dashboardUuid: $dashboardUuid,
+ dashboardName: $dashboardName,
+ dashboardLink: $dashboardLink,
+ extraParams: $params
+ );
+ if ($ok === true) {
+ $count++;
+ }
+ }
+ );
- return $count;
- }//end publishGlobal()
+ return $count;
+ }//end publishGlobal()
- /**
- * Build the canonical `IEvent` object for a single activity row.
- *
- * The numeric `objectId` slot in `IEvent::setObject()` requires an
- * int per the NC interface; the dashboard UUID is stored in the
- * `objectName` slot so the activity row can be deep-linked back to
- * the canonical dashboard regardless of database renumbering. The
- * subject parameters carry every field rendered by `parse()`.
- *
- * @param string $type The event type.
- * @param string $actorUserId The acting user ID.
- * @param string $recipientUserId The recipient user ID.
- * @param string $dashboardUuid The dashboard UUID.
- * @param string $dashboardName The dashboard name.
- * @param string $dashboardLink The dashboard link.
- * @param array $extraParams Optional extra params.
- *
- * @return IEvent The fully populated event.
- */
- private function buildEvent(
- string $type,
- string $actorUserId,
- string $recipientUserId,
- string $dashboardUuid,
- string $dashboardName,
- string $dashboardLink,
- array $extraParams
- ): IEvent {
- $event = $this->manager->generateEvent();
- $isSelf = ($actorUserId === $recipientUserId);
+ /**
+ * Build the canonical `IEvent` object for a single activity row.
+ *
+ * The numeric `objectId` slot in `IEvent::setObject()` requires an
+ * int per the NC interface; the dashboard UUID is stored in the
+ * `objectName` slot so the activity row can be deep-linked back to
+ * the canonical dashboard regardless of database renumbering. The
+ * subject parameters carry every field rendered by `parse()`.
+ *
+ * @param string $type The event type.
+ * @param string $actorUserId The acting user ID.
+ * @param string $recipientUserId The recipient user ID.
+ * @param string $dashboardUuid The dashboard UUID.
+ * @param string $dashboardName The dashboard name.
+ * @param string $dashboardLink The dashboard link.
+ * @param array $extraParams Optional extra params.
+ *
+ * @return IEvent The fully populated event.
+ */
+ private function buildEvent(
+ string $type,
+ string $actorUserId,
+ string $recipientUserId,
+ string $dashboardUuid,
+ string $dashboardName,
+ string $dashboardLink,
+ array $extraParams,
+ ): IEvent {
+ $event = $this->manager->generateEvent();
+ $isSelf = ($actorUserId === $recipientUserId);
- $params = array_merge(
- [
- 'self' => $isSelf,
- 'actor' => $actorUserId,
- 'dashboard' => $dashboardName,
- ],
- $extraParams
- );
+ $params = array_merge(
+ [
+ 'self' => $isSelf,
+ 'actor' => $actorUserId,
+ 'dashboard' => $dashboardName,
+ ],
+ $extraParams
+ );
- $event
- ->setApp(app: Extension::APP_ID)
- ->setType(type: $type)
- ->setAuthor(author: $actorUserId)
- ->setAffectedUser(affectedUser: $recipientUserId)
- ->setSubject(subject: $type, parameters: $params)
- ->setObject(
- objectType: Extension::OBJECT_TYPE,
- objectId: 0,
- objectName: $dashboardUuid
- )
- ->setLink(link: $dashboardLink)
- ->setTimestamp(timestamp: time());
+ $event
+ ->setApp(app: Extension::APP_ID)
+ ->setType(type: $type)
+ ->setAuthor(author: $actorUserId)
+ ->setAffectedUser(affectedUser: $recipientUserId)
+ ->setSubject(subject: $type, parameters: $params)
+ ->setObject(
+ objectType: Extension::OBJECT_TYPE,
+ objectId: 0,
+ objectName: $dashboardUuid
+ )
+ ->setLink(link: $dashboardLink)
+ ->setTimestamp(timestamp: time());
- $message = (string) ($extraParams['message'] ?? '');
- if ($message !== '') {
- $event->setMessage(message: substr(string: $message, offset: 0, length: 200));
- }
+ $message = (string)($extraParams['message'] ?? '');
+ if ($message !== '') {
+ $event->setMessage(message: substr(string: $message, offset: 0, length: 200));
+ }
- return $event;
- }//end buildEvent()
+ return $event;
+ }//end buildEvent()
}//end class
diff --git a/lib/Activity/DebounceHelper.php b/lib/Activity/DebounceHelper.php
index 72472276..5a7e8d41 100644
--- a/lib/Activity/DebounceHelper.php
+++ b/lib/Activity/DebounceHelper.php
@@ -51,206 +51,202 @@
/**
* Per-window debounce guard for Activity emission.
*/
-class DebounceHelper
-{
- /**
- * Debounce TTL in seconds (15 minutes). REQ-ACT-007, REQ-ACT-008.
- */
- public const TTL_SECONDS = 900;
-
- /**
- * In-memory fallback store used when APCu is not available.
- *
- * Keyed by the same APCu key string; the value is the unix
- * timestamp at which the entry expires.
- *
- * @var array
- */
- private array $memory = [];
-
- /**
- * Optional clock callable returning the current unix timestamp.
- *
- * Injected by tests to advance time deterministically without
- * sleeping for 15 minutes.
- *
- * @var callable():int
- */
- private $clock;
-
- /**
- * True when the helper is using the real wall-clock and may delegate
- * to APCu. False when a test clock is injected — in that case APCu's
- * own TTL would not move with the test clock, so the in-memory
- * fallback is the only correct backend.
- *
- * @var boolean
- */
- private bool $realClock;
-
- /**
- * Distributed cache used as the cross-request fallback when APCu is
- * not usable. Nullable so existing unit-test call sites that build
- * `new DebounceHelper($clock)` keep working without a cache backend.
- *
- * @var ICache|null
- */
- private ?ICache $cache;
-
- /**
- * Constructor.
- *
- * @param (callable():int)|null $clock Optional clock callable; defaults to `time()`.
- * @param ICache|null $cache Optional distributed cache used as the
- * cross-request fallback when APCu is
- * unusable. Null falls back to the
- * in-memory array.
- */
- public function __construct(?callable $clock=null, ?ICache $cache=null)
- {
- $this->realClock = ($clock === null);
- $this->clock = ($clock ?? static fn(): int => time());
- $this->cache = $cache;
- }//end __construct()
-
- /**
- * Check whether a reaction event from `$actorUserId` on
- * `$dashboardUuid` may be emitted now.
- *
- * Returns true on the first call and again after the 900-second
- * window has elapsed; returns false for any call inside an active
- * window.
- *
- * @param string $actorUserId The acting user ID.
- * @param string $dashboardUuid The dashboard UUID.
- *
- * @return bool True when emission is allowed.
- */
- public function allowReaction(
- string $actorUserId,
- string $dashboardUuid
- ): bool {
- $key = sprintf(
- 'launchpad_act_react_%s_%s',
- $actorUserId,
- $dashboardUuid
- );
- return $this->claim(key: $key);
- }//end allowReaction()
-
- /**
- * Check whether a default-group fan-out for `(dashboardUuid, eventType)`
- * may be performed now (REQ-ACT-008).
- *
- * @param string $dashboardUuid The dashboard UUID.
- * @param string $eventType The event type constant value.
- *
- * @return bool True when fan-out is allowed.
- */
- public function allowGlobalFanout(
- string $dashboardUuid,
- string $eventType
- ): bool {
- $key = sprintf(
- 'launchpad_act_global_%s_%s',
- $dashboardUuid,
- $eventType
- );
- return $this->claim(key: $key);
- }//end allowGlobalFanout()
-
- /**
- * Claim the key for the configured TTL.
- *
- * Three-tier fallback:
- * 1. APCu (`apcu_add`) — race-free claim, TTL enforced by APCu.
- * 2. `ICache` (distributed) — cross-request fallback when APCu is
- * unusable and a cache backend is injected. Implemented as
- * `hasKey()`-then-`set()` because `ICache` exposes no atomic
- * `add()`; the resulting small race window is acceptable for a
- * 15-minute UX debounce guard (it is not a security control).
- * 3. In-memory array — only when no cache is injected (defensive)
- * or under a test clock (deterministic unit-test path).
- *
- * @param string $key The full cache key.
- *
- * @return bool True when the caller successfully claimed the window.
- */
- private function claim(string $key): bool
- {
- $now = ($this->clock)();
-
- if ($this->apcuUsable() === true) {
- // `apcu_add` returns false when the key already exists,
- // which is exactly the semantics we want for a debounce
- // claim. The TTL is enforced by APCu itself.
- return (bool) apcu_add($key, $now, self::TTL_SECONDS);
- }
-
- // APCu is unusable. When a real clock is in effect and a
- // distributed cache is injected, use it so the debounce claim
- // survives across requests and PHP-FPM workers regardless of
- // APCu availability. The test-clock path deliberately skips the
- // cache (its wall-clock TTL cannot follow a fake clock).
- if ($this->realClock === true && $this->cache !== null) {
- if ($this->cache->hasKey($key) === true) {
- return false;
- }
-
- $this->cache->set($key, $now, self::TTL_SECONDS);
- return true;
- }
-
- // Purge expired entries opportunistically to keep the in-memory
- // store from growing without bound.
- foreach ($this->memory as $existingKey => $expiresAt) {
- if ($expiresAt <= $now) {
- unset($this->memory[$existingKey]);
- }
- }
-
- if (array_key_exists(key: $key, array: $this->memory) === true) {
- return false;
- }
-
- $this->memory[$key] = ($now + self::TTL_SECONDS);
- return true;
- }//end claim()
-
- /**
- * True when APCu is actually usable for debounce claims.
- *
- * `function_exists('apcu_add')` alone is not enough — the function
- * is loaded by the extension even when APCu is disabled at runtime
- * (most notably under CLI when `apc.enable_cli=0`), in which case
- * `apcu_add()` silently returns false on every call. That breaks
- * the debounce semantics: the helper would treat every claim as
- * "already taken" and reject every emission.
- *
- * `apcu_enabled()` was introduced in APCu 4.0.5 specifically for
- * this gate; when it's available we trust it. When it isn't (very
- * old APCu builds), fall back to the existence check + an
- * `ini_get('apc.enabled')` probe.
- *
- * @return bool True when APCu is loaded AND enabled at runtime.
- */
- private function apcuUsable(): bool
- {
- // A test-injected clock cannot move APCu's wall-clock TTL, so
- // the in-memory store is the only backend that produces
- // deterministic results when the clock is fake.
- if ($this->realClock === false) {
- return false;
- }
-
- if (function_exists(function: 'apcu_add') === false || function_exists(function: 'apcu_exists') === false) {
- return false;
- }
-
- if (function_exists(function: 'apcu_enabled') === true) {
- return (bool) apcu_enabled();
- }
-
- return (bool) ini_get(option: 'apc.enabled');
- }//end apcuUsable()
+class DebounceHelper {
+ /**
+ * Debounce TTL in seconds (15 minutes). REQ-ACT-007, REQ-ACT-008.
+ */
+ public const TTL_SECONDS = 900;
+
+ /**
+ * In-memory fallback store used when APCu is not available.
+ *
+ * Keyed by the same APCu key string; the value is the unix
+ * timestamp at which the entry expires.
+ *
+ * @var array
+ */
+ private array $memory = [];
+
+ /**
+ * Optional clock callable returning the current unix timestamp.
+ *
+ * Injected by tests to advance time deterministically without
+ * sleeping for 15 minutes.
+ *
+ * @var callable():int
+ */
+ private $clock;
+
+ /**
+ * True when the helper is using the real wall-clock and may delegate
+ * to APCu. False when a test clock is injected — in that case APCu's
+ * own TTL would not move with the test clock, so the in-memory
+ * fallback is the only correct backend.
+ *
+ * @var boolean
+ */
+ private bool $realClock;
+
+ /**
+ * Distributed cache used as the cross-request fallback when APCu is
+ * not usable. Nullable so existing unit-test call sites that build
+ * `new DebounceHelper($clock)` keep working without a cache backend.
+ *
+ * @var ICache|null
+ */
+ private ?ICache $cache;
+
+ /**
+ * Constructor.
+ *
+ * @param (callable():int)|null $clock Optional clock callable; defaults to `time()`.
+ * @param ICache|null $cache Optional distributed cache used as the
+ * cross-request fallback when APCu is
+ * unusable. Null falls back to the
+ * in-memory array.
+ */
+ public function __construct(?callable $clock = null, ?ICache $cache = null) {
+ $this->realClock = ($clock === null);
+ $this->clock = ($clock ?? static fn (): int => time());
+ $this->cache = $cache;
+ }//end __construct()
+
+ /**
+ * Check whether a reaction event from `$actorUserId` on
+ * `$dashboardUuid` may be emitted now.
+ *
+ * Returns true on the first call and again after the 900-second
+ * window has elapsed; returns false for any call inside an active
+ * window.
+ *
+ * @param string $actorUserId The acting user ID.
+ * @param string $dashboardUuid The dashboard UUID.
+ *
+ * @return bool True when emission is allowed.
+ */
+ public function allowReaction(
+ string $actorUserId,
+ string $dashboardUuid,
+ ): bool {
+ $key = sprintf(
+ 'launchpad_act_react_%s_%s',
+ $actorUserId,
+ $dashboardUuid
+ );
+ return $this->claim(key: $key);
+ }//end allowReaction()
+
+ /**
+ * Check whether a default-group fan-out for `(dashboardUuid, eventType)`
+ * may be performed now (REQ-ACT-008).
+ *
+ * @param string $dashboardUuid The dashboard UUID.
+ * @param string $eventType The event type constant value.
+ *
+ * @return bool True when fan-out is allowed.
+ */
+ public function allowGlobalFanout(
+ string $dashboardUuid,
+ string $eventType,
+ ): bool {
+ $key = sprintf(
+ 'launchpad_act_global_%s_%s',
+ $dashboardUuid,
+ $eventType
+ );
+ return $this->claim(key: $key);
+ }//end allowGlobalFanout()
+
+ /**
+ * Claim the key for the configured TTL.
+ *
+ * Three-tier fallback:
+ * 1. APCu (`apcu_add`) — race-free claim, TTL enforced by APCu.
+ * 2. `ICache` (distributed) — cross-request fallback when APCu is
+ * unusable and a cache backend is injected. Implemented as
+ * `hasKey()`-then-`set()` because `ICache` exposes no atomic
+ * `add()`; the resulting small race window is acceptable for a
+ * 15-minute UX debounce guard (it is not a security control).
+ * 3. In-memory array — only when no cache is injected (defensive)
+ * or under a test clock (deterministic unit-test path).
+ *
+ * @param string $key The full cache key.
+ *
+ * @return bool True when the caller successfully claimed the window.
+ */
+ private function claim(string $key): bool {
+ $now = ($this->clock)();
+
+ if ($this->apcuUsable() === true) {
+ // `apcu_add` returns false when the key already exists,
+ // which is exactly the semantics we want for a debounce
+ // claim. The TTL is enforced by APCu itself.
+ return (bool)apcu_add($key, $now, self::TTL_SECONDS);
+ }
+
+ // APCu is unusable. When a real clock is in effect and a
+ // distributed cache is injected, use it so the debounce claim
+ // survives across requests and PHP-FPM workers regardless of
+ // APCu availability. The test-clock path deliberately skips the
+ // cache (its wall-clock TTL cannot follow a fake clock).
+ if ($this->realClock === true && $this->cache !== null) {
+ if ($this->cache->hasKey($key) === true) {
+ return false;
+ }
+
+ $this->cache->set($key, $now, self::TTL_SECONDS);
+ return true;
+ }
+
+ // Purge expired entries opportunistically to keep the in-memory
+ // store from growing without bound.
+ foreach ($this->memory as $existingKey => $expiresAt) {
+ if ($expiresAt <= $now) {
+ unset($this->memory[$existingKey]);
+ }
+ }
+
+ if (array_key_exists(key: $key, array: $this->memory) === true) {
+ return false;
+ }
+
+ $this->memory[$key] = ($now + self::TTL_SECONDS);
+ return true;
+ }//end claim()
+
+ /**
+ * True when APCu is actually usable for debounce claims.
+ *
+ * `function_exists('apcu_add')` alone is not enough — the function
+ * is loaded by the extension even when APCu is disabled at runtime
+ * (most notably under CLI when `apc.enable_cli=0`), in which case
+ * `apcu_add()` silently returns false on every call. That breaks
+ * the debounce semantics: the helper would treat every claim as
+ * "already taken" and reject every emission.
+ *
+ * `apcu_enabled()` was introduced in APCu 4.0.5 specifically for
+ * this gate; when it's available we trust it. When it isn't (very
+ * old APCu builds), fall back to the existence check + an
+ * `ini_get('apc.enabled')` probe.
+ *
+ * @return bool True when APCu is loaded AND enabled at runtime.
+ */
+ private function apcuUsable(): bool {
+ // A test-injected clock cannot move APCu's wall-clock TTL, so
+ // the in-memory store is the only backend that produces
+ // deterministic results when the clock is fake.
+ if ($this->realClock === false) {
+ return false;
+ }
+
+ if (function_exists(function: 'apcu_add') === false || function_exists(function: 'apcu_exists') === false) {
+ return false;
+ }
+
+ if (function_exists(function: 'apcu_enabled') === true) {
+ return (bool)apcu_enabled();
+ }
+
+ return (bool)ini_get(option: 'apc.enabled');
+ }//end apcuUsable()
}//end class
diff --git a/lib/Activity/Extension.php b/lib/Activity/Extension.php
index ff91ebbe..996b8212 100644
--- a/lib/Activity/Extension.php
+++ b/lib/Activity/Extension.php
@@ -61,263 +61,259 @@
* @SuppressWarnings(PHPMD.UnusedFormalParameter) — $previousEvent required by IProvider interface.
* @spec openspec/specs/activity-feed-integration/spec.md
*/
-class Extension implements IProvider
-{
- /**
- * LaunchPad application identifier in the Activity stream.
- */
- public const APP_ID = Application::APP_ID;
+class Extension implements IProvider {
+ /**
+ * LaunchPad application identifier in the Activity stream.
+ */
+ public const APP_ID = Application::APP_ID;
- /**
- * Object type stored on every emitted IEvent.
- *
- * The activity object semantics follow NC core: `objectType` is a
- * stable string LaunchPad owns; `objectName` carries the dashboard
- * UUID (the IEvent::setObject signature requires the numeric
- * primary-key int as `objectId`).
- */
- public const OBJECT_TYPE = 'launchpad_dashboard';
+ /**
+ * Object type stored on every emitted IEvent.
+ *
+ * The activity object semantics follow NC core: `objectType` is a
+ * stable string LaunchPad owns; `objectName` carries the dashboard
+ * UUID (the IEvent::setObject signature requires the numeric
+ * primary-key int as `objectId`).
+ */
+ public const OBJECT_TYPE = 'launchpad_dashboard';
- public const EVENT_CREATED = 'dashboard_created';
- public const EVENT_UPDATED = 'dashboard_updated';
- public const EVENT_DELETED = 'dashboard_deleted';
- public const EVENT_PUBLISHED = 'dashboard_published';
- public const EVENT_UNPUBLISHED = 'dashboard_unpublished';
- public const EVENT_SCHEDULED = 'dashboard_scheduled';
- public const EVENT_SHARED = 'dashboard_shared';
- public const EVENT_PUBLIC_SHARE_CREATED = 'dashboard_public_share_created';
- public const EVENT_COMMENTED = 'dashboard_commented';
- public const EVENT_REACTED = 'dashboard_reacted';
- public const EVENT_RESTORED = 'dashboard_restored';
- public const EVENT_LOCK_OVERRIDDEN = 'dashboard_lock_overridden';
- public const EVENT_ROLE_CHANGED = 'dashboard_role_changed';
- public const EVENT_ACKNOWLEDGED = 'dashboard_acknowledged';
+ public const EVENT_CREATED = 'dashboard_created';
+ public const EVENT_UPDATED = 'dashboard_updated';
+ public const EVENT_DELETED = 'dashboard_deleted';
+ public const EVENT_PUBLISHED = 'dashboard_published';
+ public const EVENT_UNPUBLISHED = 'dashboard_unpublished';
+ public const EVENT_SCHEDULED = 'dashboard_scheduled';
+ public const EVENT_SHARED = 'dashboard_shared';
+ public const EVENT_PUBLIC_SHARE_CREATED = 'dashboard_public_share_created';
+ public const EVENT_COMMENTED = 'dashboard_commented';
+ public const EVENT_REACTED = 'dashboard_reacted';
+ public const EVENT_RESTORED = 'dashboard_restored';
+ public const EVENT_LOCK_OVERRIDDEN = 'dashboard_lock_overridden';
+ public const EVENT_ROLE_CHANGED = 'dashboard_role_changed';
+ public const EVENT_ACKNOWLEDGED = 'dashboard_acknowledged';
- /**
- * Canonical list of every LaunchPad event type registered with NC
- * Activity. Used for the per-type opt-out registration loop and
- * the unit-test contract.
- *
- * `dashboard_viewed` is intentionally excluded — view tracking is
- * owned by `dashboard-view-analytics` and MUST NOT be published to
- * the Activity stream (see REQ-ACT-002 and design D2).
- */
- public const ALL_EVENTS = [
- self::EVENT_CREATED,
- self::EVENT_UPDATED,
- self::EVENT_DELETED,
- self::EVENT_PUBLISHED,
- self::EVENT_UNPUBLISHED,
- self::EVENT_SCHEDULED,
- self::EVENT_SHARED,
- self::EVENT_PUBLIC_SHARE_CREATED,
- self::EVENT_COMMENTED,
- self::EVENT_REACTED,
- self::EVENT_RESTORED,
- self::EVENT_LOCK_OVERRIDDEN,
- self::EVENT_ROLE_CHANGED,
- self::EVENT_ACKNOWLEDGED,
- ];
+ /**
+ * Canonical list of every LaunchPad event type registered with NC
+ * Activity. Used for the per-type opt-out registration loop and
+ * the unit-test contract.
+ *
+ * `dashboard_viewed` is intentionally excluded — view tracking is
+ * owned by `dashboard-view-analytics` and MUST NOT be published to
+ * the Activity stream (see REQ-ACT-002 and design D2).
+ */
+ public const ALL_EVENTS = [
+ self::EVENT_CREATED,
+ self::EVENT_UPDATED,
+ self::EVENT_DELETED,
+ self::EVENT_PUBLISHED,
+ self::EVENT_UNPUBLISHED,
+ self::EVENT_SCHEDULED,
+ self::EVENT_SHARED,
+ self::EVENT_PUBLIC_SHARE_CREATED,
+ self::EVENT_COMMENTED,
+ self::EVENT_REACTED,
+ self::EVENT_RESTORED,
+ self::EVENT_LOCK_OVERRIDDEN,
+ self::EVENT_ROLE_CHANGED,
+ self::EVENT_ACKNOWLEDGED,
+ ];
- /**
- * Constructor.
- *
- * @param IFactory $l10nFactory The L10N factory.
- * @param IURLGenerator $urlGenerator The URL generator.
- */
- public function __construct(
- private readonly IFactory $l10nFactory,
- private readonly IURLGenerator $urlGenerator,
- ) {
- }//end __construct()
+ /**
+ * Constructor.
+ *
+ * @param IFactory $l10nFactory The L10N factory.
+ * @param IURLGenerator $urlGenerator The URL generator.
+ */
+ public function __construct(
+ private readonly IFactory $l10nFactory,
+ private readonly IURLGenerator $urlGenerator,
+ ) {
+ }//end __construct()
- /**
- * Parse a raw activity event into a translated, rich-formatted one.
- *
- * Returns the event with `richSubject`, `parsedSubject`, `icon`,
- * and (where applicable) message fields populated. Unknown event
- * types throw `UnknownActivityException` so the NC Activity chain
- * can pass the event to the next provider (REQ-ACT-001 scenario).
- *
- * @param string $language The language code.
- * @param IEvent $event The raw event.
- * @param IEvent|null $previousEvent A previous event for merging (unused).
- *
- * @return IEvent The parsed event.
- *
- * @throws UnknownActivityException When the event type is not handled.
- * @spec openspec/specs/activity-feed-integration/spec.md
- */
- public function parse(
- $language,
- IEvent $event,
- ?IEvent $previousEvent=null
- ): IEvent {
- if ($event->getApp() !== self::APP_ID) {
- throw new UnknownActivityException(
- message: 'Unknown app: '.$event->getApp()
- );
- }
+ /**
+ * Parse a raw activity event into a translated, rich-formatted one.
+ *
+ * Returns the event with `richSubject`, `parsedSubject`, `icon`,
+ * and (where applicable) message fields populated. Unknown event
+ * types throw `UnknownActivityException` so the NC Activity chain
+ * can pass the event to the next provider (REQ-ACT-001 scenario).
+ *
+ * @param string $language The language code.
+ * @param IEvent $event The raw event.
+ * @param IEvent|null $previousEvent A previous event for merging (unused).
+ *
+ * @return IEvent The parsed event.
+ *
+ * @throws UnknownActivityException When the event type is not handled.
+ * @spec openspec/specs/activity-feed-integration/spec.md
+ */
+ public function parse(
+ $language,
+ IEvent $event,
+ ?IEvent $previousEvent = null,
+ ): IEvent {
+ if ($event->getApp() !== self::APP_ID) {
+ throw new UnknownActivityException(
+ message: 'Unknown app: ' . $event->getApp()
+ );
+ }
- $type = $event->getType();
- if (in_array(needle: $type, haystack: self::ALL_EVENTS, strict: true) === false) {
- throw new UnknownActivityException(
- message: 'Unknown subject: '.$type
- );
- }
+ $type = $event->getType();
+ if (in_array(needle: $type, haystack: self::ALL_EVENTS, strict: true) === false) {
+ throw new UnknownActivityException(
+ message: 'Unknown subject: ' . $type
+ );
+ }
- $l = $this->l10nFactory->get(app: self::APP_ID, lang: $language);
- $params = $event->getSubjectParameters();
- $isSelf = (bool) ($params['self'] ?? false);
- $actor = (string) ($params['actor'] ?? $event->getAuthor());
- $dashboard = (string) ($params['dashboard'] ?? $event->getObjectName());
- $recipient = (string) ($params['recipient'] ?? '');
- $role = (string) ($params['role'] ?? '');
- $target = (string) ($params['target'] ?? '');
+ $l = $this->l10nFactory->get(app: self::APP_ID, lang: $language);
+ $params = $event->getSubjectParameters();
+ $isSelf = (bool)($params['self'] ?? false);
+ $actor = (string)($params['actor'] ?? $event->getAuthor());
+ $dashboard = (string)($params['dashboard'] ?? $event->getObjectName());
+ $recipient = (string)($params['recipient'] ?? '');
+ $role = (string)($params['role'] ?? '');
+ $target = (string)($params['target'] ?? '');
- $template = $this->resolveSubjectTemplate(
- type: $type,
- isSelf: $isSelf
- );
- $rendered = strtr(
- $l->t($template),
- [
- '{actor}' => $actor,
- '{dashboard}' => $dashboard,
- '{recipient}' => $recipient,
- '{role}' => $role,
- '{target}' => $target,
- ]
- );
+ $template = $this->resolveSubjectTemplate(
+ type: $type,
+ isSelf: $isSelf
+ );
+ $rendered = strtr(
+ $l->t($template),
+ [
+ '{actor}' => $actor,
+ '{dashboard}' => $dashboard,
+ '{recipient}' => $recipient,
+ '{role}' => $role,
+ '{target}' => $target,
+ ]
+ );
- $event->setRichSubject(subject: $rendered);
- $event->setParsedSubject(subject: $rendered);
- $event->setIcon(icon: $this->getIcon(eventType: $type));
+ $event->setRichSubject(subject: $rendered);
+ $event->setParsedSubject(subject: $rendered);
+ $event->setIcon(icon: $this->getIcon(eventType: $type));
- return $event;
- }//end parse()
+ return $event;
+ }//end parse()
- /**
- * Return an absolute URL to the per-type Activity icon.
- *
- * Falls back to `img/activity/launchpad.svg` (the generic LaunchPad icon)
- * when `$eventType` is not a known constant.
- *
- * @param string $eventType The event type string.
- *
- * @return string The absolute icon URL.
- * @spec openspec/specs/activity-feed-integration/spec.md
- */
- public function getIcon(string $eventType): string
- {
- $known = in_array(
- needle: $eventType,
- haystack: self::ALL_EVENTS,
- strict: true
- );
- $file = 'activity/launchpad.svg';
- if ($known === true) {
- $file = 'activity/'.$eventType.'.svg';
- }
+ /**
+ * Return an absolute URL to the per-type Activity icon.
+ *
+ * Falls back to `img/activity/launchpad.svg` (the generic LaunchPad icon)
+ * when `$eventType` is not a known constant.
+ *
+ * @param string $eventType The event type string.
+ *
+ * @return string The absolute icon URL.
+ * @spec openspec/specs/activity-feed-integration/spec.md
+ */
+ public function getIcon(string $eventType): string {
+ $known = in_array(
+ needle: $eventType,
+ haystack: self::ALL_EVENTS,
+ strict: true
+ );
+ $file = 'activity/launchpad.svg';
+ if ($known === true) {
+ $file = 'activity/' . $eventType . '.svg';
+ }
- return $this->urlGenerator->getAbsoluteURL(
- url: $this->urlGenerator->imagePath(
- appName: self::APP_ID,
- file: $file
- )
- );
- }//end getIcon()
+ return $this->urlGenerator->getAbsoluteURL(
+ url: $this->urlGenerator->imagePath(
+ appName: self::APP_ID,
+ file: $file
+ )
+ );
+ }//end getIcon()
- /**
- * Return the canonical subject-template catalogue keyed by event
- * type with `self` (first-person) and `other` (third-person)
- * variants (REQ-ACT-010).
- *
- * Templates use `{placeholder}` substitution that is rendered both
- * by `parse()` and by NC Activity's translation layer.
- *
- * @return array
- * @spec openspec/specs/activity-feed-integration/spec.md
- */
- public function getSubjectTemplates(): array
- {
- return [
- self::EVENT_CREATED => [
- 'self' => 'You created dashboard {dashboard}',
- 'other' => '{actor} created dashboard {dashboard}',
- ],
- self::EVENT_UPDATED => [
- 'self' => 'You updated dashboard {dashboard}',
- 'other' => '{actor} updated dashboard {dashboard}',
- ],
- self::EVENT_DELETED => [
- 'self' => 'You deleted dashboard {dashboard}',
- 'other' => '{actor} deleted dashboard {dashboard}',
- ],
- self::EVENT_PUBLISHED => [
- 'self' => 'You published dashboard {dashboard}',
- 'other' => '{actor} published dashboard {dashboard}',
- ],
- self::EVENT_UNPUBLISHED => [
- 'self' => 'You unpublished dashboard {dashboard}',
- 'other' => '{actor} unpublished dashboard {dashboard}',
- ],
- self::EVENT_SCHEDULED => [
- 'self' => 'You scheduled dashboard {dashboard}',
- 'other' => '{actor} scheduled dashboard {dashboard}',
- ],
- self::EVENT_SHARED => [
- 'self' => 'You shared dashboard {dashboard} with {recipient}',
- 'other' => '{actor} shared dashboard {dashboard} with {recipient}',
- ],
- self::EVENT_PUBLIC_SHARE_CREATED => [
- 'self' => 'You created a public link for dashboard {dashboard}',
- 'other' => '{actor} created a public link for dashboard {dashboard}',
- ],
- self::EVENT_COMMENTED => [
- 'self' => 'You commented on dashboard {dashboard}',
- 'other' => '{actor} commented on dashboard {dashboard}',
- ],
- self::EVENT_REACTED => [
- 'self' => 'You reacted to dashboard {dashboard}',
- 'other' => '{actor} reacted to dashboard {dashboard}',
- ],
- self::EVENT_RESTORED => [
- 'self' => 'You restored dashboard {dashboard} to an earlier version',
- 'other' => '{actor} restored dashboard {dashboard} to an earlier version',
- ],
- self::EVENT_LOCK_OVERRIDDEN => [
- 'self' => 'You overrode the lock on dashboard {dashboard}',
- 'other' => '{actor} overrode the lock on dashboard {dashboard}',
- ],
- self::EVENT_ROLE_CHANGED => [
- 'self' => 'Your role in {dashboard} was changed to {role}',
- 'other' => "{actor} changed {target}'s role in {dashboard} to {role}",
- ],
- self::EVENT_ACKNOWLEDGED => [
- 'self' => 'You acknowledged {dashboard}',
- 'other' => '{actor} acknowledged {dashboard}',
- ],
- ];
- }//end getSubjectTemplates()
+ /**
+ * Return the canonical subject-template catalogue keyed by event
+ * type with `self` (first-person) and `other` (third-person)
+ * variants (REQ-ACT-010).
+ *
+ * Templates use `{placeholder}` substitution that is rendered both
+ * by `parse()` and by NC Activity's translation layer.
+ *
+ * @return array
+ * @spec openspec/specs/activity-feed-integration/spec.md
+ */
+ public function getSubjectTemplates(): array {
+ return [
+ self::EVENT_CREATED => [
+ 'self' => 'You created dashboard {dashboard}',
+ 'other' => '{actor} created dashboard {dashboard}',
+ ],
+ self::EVENT_UPDATED => [
+ 'self' => 'You updated dashboard {dashboard}',
+ 'other' => '{actor} updated dashboard {dashboard}',
+ ],
+ self::EVENT_DELETED => [
+ 'self' => 'You deleted dashboard {dashboard}',
+ 'other' => '{actor} deleted dashboard {dashboard}',
+ ],
+ self::EVENT_PUBLISHED => [
+ 'self' => 'You published dashboard {dashboard}',
+ 'other' => '{actor} published dashboard {dashboard}',
+ ],
+ self::EVENT_UNPUBLISHED => [
+ 'self' => 'You unpublished dashboard {dashboard}',
+ 'other' => '{actor} unpublished dashboard {dashboard}',
+ ],
+ self::EVENT_SCHEDULED => [
+ 'self' => 'You scheduled dashboard {dashboard}',
+ 'other' => '{actor} scheduled dashboard {dashboard}',
+ ],
+ self::EVENT_SHARED => [
+ 'self' => 'You shared dashboard {dashboard} with {recipient}',
+ 'other' => '{actor} shared dashboard {dashboard} with {recipient}',
+ ],
+ self::EVENT_PUBLIC_SHARE_CREATED => [
+ 'self' => 'You created a public link for dashboard {dashboard}',
+ 'other' => '{actor} created a public link for dashboard {dashboard}',
+ ],
+ self::EVENT_COMMENTED => [
+ 'self' => 'You commented on dashboard {dashboard}',
+ 'other' => '{actor} commented on dashboard {dashboard}',
+ ],
+ self::EVENT_REACTED => [
+ 'self' => 'You reacted to dashboard {dashboard}',
+ 'other' => '{actor} reacted to dashboard {dashboard}',
+ ],
+ self::EVENT_RESTORED => [
+ 'self' => 'You restored dashboard {dashboard} to an earlier version',
+ 'other' => '{actor} restored dashboard {dashboard} to an earlier version',
+ ],
+ self::EVENT_LOCK_OVERRIDDEN => [
+ 'self' => 'You overrode the lock on dashboard {dashboard}',
+ 'other' => '{actor} overrode the lock on dashboard {dashboard}',
+ ],
+ self::EVENT_ROLE_CHANGED => [
+ 'self' => 'Your role in {dashboard} was changed to {role}',
+ 'other' => "{actor} changed {target}'s role in {dashboard} to {role}",
+ ],
+ self::EVENT_ACKNOWLEDGED => [
+ 'self' => 'You acknowledged {dashboard}',
+ 'other' => '{actor} acknowledged {dashboard}',
+ ],
+ ];
+ }//end getSubjectTemplates()
- /**
- * Resolve the subject template string for `$type` honoring the
- * self/other variant split.
- *
- * @param string $type The event-type constant value.
- * @param bool $isSelf True when the actor equals the recipient.
- *
- * @return string The template string with `{placeholder}` tokens.
- */
- private function resolveSubjectTemplate(string $type, bool $isSelf): string
- {
- $templates = $this->getSubjectTemplates();
- $variant = 'other';
- if ($isSelf === true) {
- $variant = 'self';
- }
+ /**
+ * Resolve the subject template string for `$type` honoring the
+ * self/other variant split.
+ *
+ * @param string $type The event-type constant value.
+ * @param bool $isSelf True when the actor equals the recipient.
+ *
+ * @return string The template string with `{placeholder}` tokens.
+ */
+ private function resolveSubjectTemplate(string $type, bool $isSelf): string {
+ $templates = $this->getSubjectTemplates();
+ $variant = 'other';
+ if ($isSelf === true) {
+ $variant = 'self';
+ }
- return ($templates[$type][$variant] ?? '');
- }//end resolveSubjectTemplate()
+ return ($templates[$type][$variant] ?? '');
+ }//end resolveSubjectTemplate()
}//end class
diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php
index 3e117996..758f7886 100644
--- a/lib/AppInfo/Application.php
+++ b/lib/AppInfo/Application.php
@@ -57,318 +57,308 @@
* coupling count beyond
* the default threshold.
*/
-class Application extends App implements IBootstrap
-{
- public const APP_ID = 'launchpad';
+class Application extends App implements IBootstrap {
+ public const APP_ID = 'launchpad';
- /**
- * Constructor
- *
- * @param array $urlParams The URL parameters.
- */
- public function __construct(array $urlParams=[])
- {
- parent::__construct(appName: self::APP_ID, urlParams: $urlParams);
- }//end __construct()
+ /**
+ * Constructor
+ *
+ * @param array $urlParams The URL parameters.
+ */
+ public function __construct(array $urlParams = []) {
+ parent::__construct(appName: self::APP_ID, urlParams: $urlParams);
+ }//end __construct()
- /**
- * Register services, event listeners, etc.
- *
- * @param IRegistrationContext $context The registration context.
- *
- * @return void
- */
- public function register(IRegistrationContext $context): void
- {
- $this->registerUserLifecycle(context: $context);
- $this->registerSharedServices(context: $context);
- $this->registerCascadeListeners(context: $context);
- $this->registerIntegrations(context: $context);
+ /**
+ * Register services, event listeners, etc.
+ *
+ * @param IRegistrationContext $context The registration context.
+ *
+ * @return void
+ */
+ public function register(IRegistrationContext $context): void {
+ $this->registerUserLifecycle(context: $context);
+ $this->registerSharedServices(context: $context);
+ $this->registerCascadeListeners(context: $context);
+ $this->registerIntegrations(context: $context);
- // Observability (ADR-040): re-point the unchanged /api/health and
- // /api/metrics routes at thin subclasses of the OpenRegister AppHost
- // generic controllers, which render the declarative observability block
- // of src/manifest.json. The factories below are lazy — they reference
- // no OCA\OpenRegister\… symbol until a request resolves the controller,
- // so a disabled/absent OpenRegister never fatals NC bootstrap (the route
- // then surfaces the degraded OR-unavailable state instead). $appId is the
- // runtime app id `launchpad`; the engine reads the manifest under it and
- // emits the launchpad_ Prometheus prefix, preserving the contract.
- $this->registerObservability(context: $context);
- }//end register()
+ // Observability (ADR-040): re-point the unchanged /api/health and
+ // /api/metrics routes at thin subclasses of the OpenRegister AppHost
+ // generic controllers, which render the declarative observability block
+ // of src/manifest.json. The factories below are lazy — they reference
+ // no OCA\OpenRegister\… symbol until a request resolves the controller,
+ // so a disabled/absent OpenRegister never fatals NC bootstrap (the route
+ // then surfaces the degraded OR-unavailable state instead). $appId is the
+ // runtime app id `launchpad`; the engine reads the manifest under it and
+ // emits the launchpad_ Prometheus prefix, preserving the contract.
+ $this->registerObservability(context: $context);
+ }//end register()
- /**
- * Register the notifier and the user-deletion cascade listener.
- *
- * @param IRegistrationContext $context The registration context.
- *
- * @return void
- */
- private function registerUserLifecycle(IRegistrationContext $context): void
- {
- // Render `dashboard_shared` and `dashboard_ownership_transferred`
- // notifications via our INotifier. REQ-SHARE-011.
- $context->registerNotifierService(notifierClass: Notifier::class);
+ /**
+ * Register the notifier and the user-deletion cascade listener.
+ *
+ * @param IRegistrationContext $context The registration context.
+ *
+ * @return void
+ */
+ private function registerUserLifecycle(IRegistrationContext $context): void {
+ // Render `dashboard_shared` and `dashboard_ownership_transferred`
+ // notifications via our INotifier. REQ-SHARE-011.
+ $context->registerNotifierService(notifierClass: Notifier::class);
- // Cascade share cleanup + admin-retention transfer on user deletion.
- // Also fires the role-assignment cleanup. REQ-SHARE-012,
- // REQ-SHARE-013, REQ-ROLE-010. The same listener also satisfies the
- // owned-dashboard enumeration mandated by REQ-CSC-004 — every owned
- // dashboard is routed through the deletion path that dispatches
- // DashboardDeletedEvent below, triggering the full cascade stack.
- $context->registerEventListener(
- event: UserDeletedEvent::class,
- listener: UserDeletedListener::class
- );
- }//end registerUserLifecycle()
+ // Cascade share cleanup + admin-retention transfer on user deletion.
+ // Also fires the role-assignment cleanup. REQ-SHARE-012,
+ // REQ-SHARE-013, REQ-ROLE-010. The same listener also satisfies the
+ // owned-dashboard enumeration mandated by REQ-CSC-004 — every owned
+ // dashboard is routed through the deletion path that dispatches
+ // DashboardDeletedEvent below, triggering the full cascade stack.
+ $context->registerEventListener(
+ event: UserDeletedEvent::class,
+ listener: UserDeletedListener::class
+ );
+ }//end registerUserLifecycle()
- /**
- * Register the request- and instance-scoped shared services.
- *
- * @param IRegistrationContext $context The registration context.
- *
- * @return void
- */
- private function registerSharedServices(IRegistrationContext $context): void
- {
- // REQ-ACT-007/REQ-ACT-008: register DebounceHelper as a shared
- // singleton and inject the distributed cache. The PHP singleton
- // alone only lives for one request (PHP-FPM rebuilds the DI
- // container every request); it is the shared *distributed cache*
- // — not the singleton — that makes the 900-second debounce
- // guarantee hold across requests and workers when APCu is
- // absent. ActivityPublisher autowires from the app namespace —
- // no explicit binding needed (referenced here in this docblock
- // for the cross-capability discoverability contract:
- // {@see ActivityPublisher}).
- $context->registerService(
- name: DebounceHelper::class,
- factory: static fn(\Psr\Container\ContainerInterface $c): DebounceHelper => new DebounceHelper(
- cache: $c->get(\OCP\ICacheFactory::class)->createDistributed('launchpad_activity_debounce')
- ),
- shared: true
- );
+ /**
+ * Register the request- and instance-scoped shared services.
+ *
+ * @param IRegistrationContext $context The registration context.
+ *
+ * @return void
+ */
+ private function registerSharedServices(IRegistrationContext $context): void {
+ // REQ-ACT-007/REQ-ACT-008: register DebounceHelper as a shared
+ // singleton and inject the distributed cache. The PHP singleton
+ // alone only lives for one request (PHP-FPM rebuilds the DI
+ // container every request); it is the shared *distributed cache*
+ // — not the singleton — that makes the 900-second debounce
+ // guarantee hold across requests and workers when APCu is
+ // absent. ActivityPublisher autowires from the app namespace —
+ // no explicit binding needed (referenced here in this docblock
+ // for the cross-capability discoverability contract:
+ // {@see ActivityPublisher}).
+ $context->registerService(
+ name: DebounceHelper::class,
+ factory: static fn (\Psr\Container\ContainerInterface $c): DebounceHelper => new DebounceHelper(
+ cache: $c->get(\OCP\ICacheFactory::class)->createDistributed('launchpad_activity_debounce')
+ ),
+ shared: true
+ );
- // Task-7 of dashboard-public-share — request-scoped bearer marker
- // shared across the entire request so mutation services can
- // assert read-only context without middleware plumbing.
- $context->registerService(
- name: PublicShareContext::class,
- factory: static fn(): PublicShareContext => new PublicShareContext(),
- shared: true
- );
- }//end registerSharedServices()
+ // Task-7 of dashboard-public-share — request-scoped bearer marker
+ // shared across the entire request so mutation services can
+ // assert read-only context without middleware plumbing.
+ $context->registerService(
+ name: PublicShareContext::class,
+ factory: static fn (): PublicShareContext => new PublicShareContext(),
+ shared: true
+ );
+ }//end registerSharedServices()
- /**
- * Register the group- and dashboard-deletion cascade listeners.
- *
- * @param IRegistrationContext $context The registration context.
- *
- * @return void
- */
- private function registerCascadeListeners(IRegistrationContext $context): void
- {
- // Role-assignment cascade on group deletion. REQ-ROLE-011.
- // Group lifecycle cleanup. REQ-CSC-005.
- $context->registerEventListener(
- event: GroupDeletedEvent::class,
- listener: GroupDeletedListener::class
- );
+ /**
+ * Register the group- and dashboard-deletion cascade listeners.
+ *
+ * @param IRegistrationContext $context The registration context.
+ *
+ * @return void
+ */
+ private function registerCascadeListeners(IRegistrationContext $context): void {
+ // Role-assignment cascade on group deletion. REQ-ROLE-011.
+ // Group lifecycle cleanup. REQ-CSC-005.
+ $context->registerEventListener(
+ event: GroupDeletedEvent::class,
+ listener: GroupDeletedListener::class
+ );
- // DashboardDeletedEvent listener registry. REQ-CSC-002.
- // Each listener owns one dependent table (or, for TreeListener,
- // recursive child dispatch). Adding a new listener requires only
- // appending one registration line below — no edits to existing
- // listener classes, the event, or DashboardService.
- $context->registerEventListener(
- event: DashboardDeletedEvent::class,
- listener: WidgetPlacementsListener::class
- );
- $context->registerEventListener(
- event: DashboardDeletedEvent::class,
- listener: ReactionsListener::class
- );
- $context->registerEventListener(
- event: DashboardDeletedEvent::class,
- listener: LocksListener::class
- );
- $context->registerEventListener(
- event: DashboardDeletedEvent::class,
- listener: VersionsListener::class
- );
- $context->registerEventListener(
- event: DashboardDeletedEvent::class,
- listener: PublicSharesListener::class
- );
- $context->registerEventListener(
- event: DashboardDeletedEvent::class,
- listener: MetadataValuesListener::class
- );
- $context->registerEventListener(
- event: DashboardDeletedEvent::class,
- listener: TranslationsListener::class
- );
- $context->registerEventListener(
- event: DashboardDeletedEvent::class,
- listener: ViewAnalyticsListener::class
- );
- $context->registerEventListener(
- event: DashboardDeletedEvent::class,
- listener: TreeListener::class
- );
- }//end registerCascadeListeners()
+ // DashboardDeletedEvent listener registry. REQ-CSC-002.
+ // Each listener owns one dependent table (or, for TreeListener,
+ // recursive child dispatch). Adding a new listener requires only
+ // appending one registration line below — no edits to existing
+ // listener classes, the event, or DashboardService.
+ $context->registerEventListener(
+ event: DashboardDeletedEvent::class,
+ listener: WidgetPlacementsListener::class
+ );
+ $context->registerEventListener(
+ event: DashboardDeletedEvent::class,
+ listener: ReactionsListener::class
+ );
+ $context->registerEventListener(
+ event: DashboardDeletedEvent::class,
+ listener: LocksListener::class
+ );
+ $context->registerEventListener(
+ event: DashboardDeletedEvent::class,
+ listener: VersionsListener::class
+ );
+ $context->registerEventListener(
+ event: DashboardDeletedEvent::class,
+ listener: PublicSharesListener::class
+ );
+ $context->registerEventListener(
+ event: DashboardDeletedEvent::class,
+ listener: MetadataValuesListener::class
+ );
+ $context->registerEventListener(
+ event: DashboardDeletedEvent::class,
+ listener: TranslationsListener::class
+ );
+ $context->registerEventListener(
+ event: DashboardDeletedEvent::class,
+ listener: ViewAnalyticsListener::class
+ );
+ $context->registerEventListener(
+ event: DashboardDeletedEvent::class,
+ listener: TreeListener::class
+ );
+ }//end registerCascadeListeners()
- /**
- * Register the Nextcloud-surface integrations — unified search and the
- * content-security-policy contribution.
- *
- * @param IRegistrationContext $context The registration context.
- *
- * @return void
- */
- private function registerIntegrations(IRegistrationContext $context): void
- {
- // Surface dashboards, widget content, and metadata values in
- // Nextcloud's unified search (Ctrl+K). REQ-SRCH-001.
- $context->registerSearchProvider(class: LaunchPadSearchProvider::class);
+ /**
+ * Register the Nextcloud-surface integrations — unified search and the
+ * content-security-policy contribution.
+ *
+ * @param IRegistrationContext $context The registration context.
+ *
+ * @return void
+ */
+ private function registerIntegrations(IRegistrationContext $context): void {
+ // Surface dashboards, widget content, and metadata values in
+ // Nextcloud's unified search (Ctrl+K). REQ-SRCH-001.
+ $context->registerSearchProvider(class: LaunchPadSearchProvider::class);
- // `iframe` widget — contribute the admin allow-listed embed hosts
- // to LaunchPad's own `frame-src` CSP directive so the instance CSP
- // never blocks an otherwise-permitted embed (REQ-IFRAME-003).
- $context->registerEventListener(
- event: AddContentSecurityPolicyEvent::class,
- listener: CspListener::class
- );
- }//end registerIntegrations()
+ // `iframe` widget — contribute the admin allow-listed embed hosts
+ // to LaunchPad's own `frame-src` CSP directive so the instance CSP
+ // never blocks an otherwise-permitted embed (REQ-IFRAME-003).
+ $context->registerEventListener(
+ event: AddContentSecurityPolicyEvent::class,
+ listener: CspListener::class
+ );
+ }//end registerIntegrations()
- /**
- * Wire the AppHost observability controllers (ADR-040).
- *
- * Aliases the unchanged `health#index` / `metrics#index` route targets at
- * the OpenRegister AppHost generic controllers, per the documented leaf
- * adoption pattern (docs/Technical/declarative-observability.md). The
- * controller's `$appName` resolves to this leaf's app id, so the engine
- * loads `src/manifest.json`'s `observability` block under `launchpad` and
- * renders the `launchpad_`-prefixed Prometheus output. The generic
- * controllers own the auth posture: health is public (`#[PublicPage]`),
- * metrics admin-only.
- *
- * LaunchPad keeps its own bespoke Dashboard/Preferences/Settings/
- * AdminSettings boilerplate — entangled with the dashboard lifecycle,
- * permission matrix and DoS-guarded preferences (see
- * openspec/changes/adopt-apphost/design.md). The aliases are class-string
- * registrations, so a disabled/absent OpenRegister never fatals NC
- * bootstrap; the first request to an aliased route surfaces the degraded
- * OR-unavailable state instead.
- *
- * @param IRegistrationContext $context The registration context.
- *
- * @return void
- */
- private function registerObservability(IRegistrationContext $context): void
- {
- // Health controller. The generic class is referenced only as a string
- // and instantiated inside the closure, so no OCA\OpenRegister symbol is
- // touched until a request resolves the controller — keeping NC bootstrap
- // fatal-free when OpenRegister is disabled/absent. $appName is pinned to
- // this leaf's runtime app id (`launchpad`) so the engine loads the right
- // manifest and emits the `launchpad_` prefix, exactly as before adoption.
- $context->registerService(
- HealthController::class,
- // @psalm-suppress UnusedClosureParam,TooManyArguments
- static function (\Psr\Container\ContainerInterface $c): HealthController {
- return new HealthController(
- appName: self::APP_ID,
- request: $c->get(\OCP\IRequest::class),
- manifestLoader: self::optional(container: $c, id: 'OCA\\OpenRegister\\AppHost\\Observability\\ManifestLoader'),
- executor: self::optional(container: $c, id: 'OCA\\OpenRegister\\AppHost\\Observability\\HealthCheckExecutor')
- );
- }
- );
+ /**
+ * Wire the AppHost observability controllers (ADR-040).
+ *
+ * Aliases the unchanged `health#index` / `metrics#index` route targets at
+ * the OpenRegister AppHost generic controllers, per the documented leaf
+ * adoption pattern (docs/Technical/declarative-observability.md). The
+ * controller's `$appName` resolves to this leaf's app id, so the engine
+ * loads `src/manifest.json`'s `observability` block under `launchpad` and
+ * renders the `launchpad_`-prefixed Prometheus output. The generic
+ * controllers own the auth posture: health is public (`#[PublicPage]`),
+ * metrics admin-only.
+ *
+ * LaunchPad keeps its own bespoke Dashboard/Preferences/Settings/
+ * AdminSettings boilerplate — entangled with the dashboard lifecycle,
+ * permission matrix and DoS-guarded preferences (see
+ * openspec/changes/adopt-apphost/design.md). The aliases are class-string
+ * registrations, so a disabled/absent OpenRegister never fatals NC
+ * bootstrap; the first request to an aliased route surfaces the degraded
+ * OR-unavailable state instead.
+ *
+ * @param IRegistrationContext $context The registration context.
+ *
+ * @return void
+ */
+ private function registerObservability(IRegistrationContext $context): void {
+ // Health controller. The generic class is referenced only as a string
+ // and instantiated inside the closure, so no OCA\OpenRegister symbol is
+ // touched until a request resolves the controller — keeping NC bootstrap
+ // fatal-free when OpenRegister is disabled/absent. $appName is pinned to
+ // this leaf's runtime app id (`launchpad`) so the engine loads the right
+ // manifest and emits the `launchpad_` prefix, exactly as before adoption.
+ $context->registerService(
+ HealthController::class,
+ // @psalm-suppress UnusedClosureParam,TooManyArguments
+ static function (\Psr\Container\ContainerInterface $c): HealthController {
+ return new HealthController(
+ appName: self::APP_ID,
+ request: $c->get(\OCP\IRequest::class),
+ manifestLoader: self::optional(container: $c, id: 'OCA\\OpenRegister\\AppHost\\Observability\\ManifestLoader'),
+ executor: self::optional(container: $c, id: 'OCA\\OpenRegister\\AppHost\\Observability\\HealthCheckExecutor')
+ );
+ }
+ );
- // Metrics controller (admin-only — it omits #[NoAdminRequired]).
- $context->registerService(
- MetricsController::class,
- // @psalm-suppress UnusedClosureParam,TooManyArguments
- static function (\Psr\Container\ContainerInterface $c): MetricsController {
- return new MetricsController(
- appName: self::APP_ID,
- request: $c->get(\OCP\IRequest::class),
- manifestLoader: self::optional(container: $c, id: 'OCA\\OpenRegister\\AppHost\\Observability\\ManifestLoader'),
- engine: self::optional(container: $c, id: 'OCA\\OpenRegister\\AppHost\\Observability\\MetricsEngine')
- );
- }
- );
- }//end registerObservability()
+ // Metrics controller (admin-only — it omits #[NoAdminRequired]).
+ $context->registerService(
+ MetricsController::class,
+ // @psalm-suppress UnusedClosureParam,TooManyArguments
+ static function (\Psr\Container\ContainerInterface $c): MetricsController {
+ return new MetricsController(
+ appName: self::APP_ID,
+ request: $c->get(\OCP\IRequest::class),
+ manifestLoader: self::optional(container: $c, id: 'OCA\\OpenRegister\\AppHost\\Observability\\ManifestLoader'),
+ engine: self::optional(container: $c, id: 'OCA\\OpenRegister\\AppHost\\Observability\\MetricsEngine')
+ );
+ }
+ );
+ }//end registerObservability()
- /**
- * Resolve an OpenRegister collaborator, or null when it is unavailable.
- *
- * The class names are passed as STRINGS and the failure is swallowed, so
- * nothing here touches an `OCA\OpenRegister\…` symbol at load time and an
- * absent OpenRegister yields a degraded endpoint rather than an exception.
- *
- * This is the second half of the fix for a real outage mode: the controllers
- * used to EXTEND the OpenRegister generic controllers, and Nextcloud's router
- * reflects every controller class while scanning attribute routes — so a
- * missing parent class was a fatal during route matching that made every
- * route in this app return 500. Lazy DI cannot make an `extends` lazy, which
- * is why the controllers now inherit from OCP's Controller and take these as
- * nullable, untyped collaborators.
- *
- * @param \Psr\Container\ContainerInterface $container The DI container.
- * @param string $id Fully-qualified class name.
- *
- * @return object|null The service, or null when it cannot be resolved.
- */
- private static function optional(\Psr\Container\ContainerInterface $container, string $id): ?object
- {
- try {
- $service = $container->get($id);
- if (is_object($service) === true) {
- return $service;
- }
+ /**
+ * Resolve an OpenRegister collaborator, or null when it is unavailable.
+ *
+ * The class names are passed as STRINGS and the failure is swallowed, so
+ * nothing here touches an `OCA\OpenRegister\…` symbol at load time and an
+ * absent OpenRegister yields a degraded endpoint rather than an exception.
+ *
+ * This is the second half of the fix for a real outage mode: the controllers
+ * used to EXTEND the OpenRegister generic controllers, and Nextcloud's router
+ * reflects every controller class while scanning attribute routes — so a
+ * missing parent class was a fatal during route matching that made every
+ * route in this app return 500. Lazy DI cannot make an `extends` lazy, which
+ * is why the controllers now inherit from OCP's Controller and take these as
+ * nullable, untyped collaborators.
+ *
+ * @param \Psr\Container\ContainerInterface $container The DI container.
+ * @param string $id Fully-qualified class name.
+ *
+ * @return object|null The service, or null when it cannot be resolved.
+ */
+ private static function optional(\Psr\Container\ContainerInterface $container, string $id): ?object {
+ try {
+ $service = $container->get($id);
+ if (is_object($service) === true) {
+ return $service;
+ }
- return null;
- } catch (\Throwable) {
- return null;
- }
- }//end optional()
+ return null;
+ } catch (\Throwable) {
+ return null;
+ }
+ }//end optional()
- /**
- * App initialization after all apps are registered.
- *
- * @param IBootContext $context The boot context (unused; required by IBootstrap).
- *
- * @return void
- *
- * @SuppressWarnings(PHPMD.UnusedFormalParameter)
- * `IBootstrap::boot()` mandates the `IBootContext $context`
- * parameter. This boot step only installs a process-level libxml
- * entity loader, which needs nothing from the context, but the
- * parameter cannot be dropped without breaking the interface.
- */
- public function boot(IBootContext $context): void
- {
- // C2: block all external XML entity resolution at the process level.
- // LIBXML_NOENT in simplexml_load_string / DOMDocument::loadXML does
- // NOT disable entity substitution — it enables it. The only reliable
- // defence is to install a null entity loader here at boot time. This
- // is safe because Nextcloud itself does not rely on external XML
- // entities in its own code.
- if (function_exists('libxml_set_external_entity_loader') === true) {
- // @psalm-suppress UnusedFunctionCall
- libxml_set_external_entity_loader(static fn (): null => null);
- }
+ /**
+ * App initialization after all apps are registered.
+ *
+ * @param IBootContext $context The boot context (unused; required by IBootstrap).
+ *
+ * @return void
+ *
+ * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+ * `IBootstrap::boot()` mandates the `IBootContext $context`
+ * parameter. This boot step only installs a process-level libxml
+ * entity loader, which needs nothing from the context, but the
+ * parameter cannot be dropped without breaking the interface.
+ */
+ public function boot(IBootContext $context): void {
+ // C2: block all external XML entity resolution at the process level.
+ // LIBXML_NOENT in simplexml_load_string / DOMDocument::loadXML does
+ // NOT disable entity substitution — it enables it. The only reliable
+ // defence is to install a null entity loader here at boot time. This
+ // is safe because Nextcloud itself does not rely on external XML
+ // entities in its own code.
+ if (function_exists('libxml_set_external_entity_loader') === true) {
+ // @psalm-suppress UnusedFunctionCall
+ libxml_set_external_entity_loader(static fn (): null => null);
+ }
- // App initialization after all apps are registered.
- \OCP\Util::addStyle(application: self::APP_ID, file: 'launchpad');
+ // App initialization after all apps are registered.
+ \OCP\Util::addStyle(application: self::APP_ID, file: 'launchpad');
- // The dashboard view-analytics jobs (REQ-ANLT-003 design D2 +
- // REQ-ANLT-009) and the external-feed refresh job (REQ-FRJ-002) are
- // registered once via the RegisterBackgroundJobs repair step (install +
- // post-migration), NOT on every request. Registering them here issued a
- // JobList::has() SELECT against oc_jobs on each web request and tripped
- // Nextcloud's "dirty table reads" diagnostic.
- }//end boot()
+ // The dashboard view-analytics jobs (REQ-ANLT-003 design D2 +
+ // REQ-ANLT-009) and the external-feed refresh job (REQ-FRJ-002) are
+ // registered once via the RegisterBackgroundJobs repair step (install +
+ // post-migration), NOT on every request. Registering them here issued a
+ // JobList::has() SELECT against oc_jobs on each web request and tripped
+ // Nextcloud's "dirty table reads" diagnostic.
+ }//end boot()
}//end class
diff --git a/lib/BackgroundJob/HealthPingRefreshJob.php b/lib/BackgroundJob/HealthPingRefreshJob.php
index ec5ecad6..39fe4380 100644
--- a/lib/BackgroundJob/HealthPingRefreshJob.php
+++ b/lib/BackgroundJob/HealthPingRefreshJob.php
@@ -43,64 +43,62 @@
* @SuppressWarnings(PHPMD.UnusedFormalParameter) — $argument required by TimedJob interface.
* @spec openspec/specs/service-health-ping/spec.md
*/
-class HealthPingRefreshJob extends TimedJob
-{
+class HealthPingRefreshJob extends TimedJob {
- /**
- * Run interval in seconds — matches the minimum permitted per-tile
- * interval ({@see HealthPingService::MIN_INTERVAL_SECONDS}) so no
- * tile's configured interval is ever starved by the job cadence
- * itself; `refreshDuePlacements()` still only touches entries whose
- * OWN interval has actually elapsed.
- *
- * @var integer
- */
- public const INTERVAL_SECONDS = 15;
+ /**
+ * Run interval in seconds — matches the minimum permitted per-tile
+ * interval ({@see HealthPingService::MIN_INTERVAL_SECONDS}) so no
+ * tile's configured interval is ever starved by the job cadence
+ * itself; `refreshDuePlacements()` still only touches entries whose
+ * OWN interval has actually elapsed.
+ *
+ * @var integer
+ */
+ public const INTERVAL_SECONDS = 15;
- /**
- * Constructor.
- *
- * @param ITimeFactory $time Time factory (parent requirement).
- * @param HealthPingService $healthPingService The service performing the actual refresh.
- * @param LoggerInterface $logger PSR-3 logger.
- */
- public function __construct(
- ITimeFactory $time,
- private readonly HealthPingService $healthPingService,
- private readonly LoggerInterface $logger,
- ) {
- parent::__construct(time: $time);
- $this->setInterval(seconds: self::INTERVAL_SECONDS);
- $this->setTimeSensitivity(sensitivity: IJob::TIME_INSENSITIVE);
- }//end __construct()
+ /**
+ * Constructor.
+ *
+ * @param ITimeFactory $time Time factory (parent requirement).
+ * @param HealthPingService $healthPingService The service performing the actual refresh.
+ * @param LoggerInterface $logger PSR-3 logger.
+ */
+ public function __construct(
+ ITimeFactory $time,
+ private readonly HealthPingService $healthPingService,
+ private readonly LoggerInterface $logger,
+ ) {
+ parent::__construct(time: $time);
+ $this->setInterval(seconds: self::INTERVAL_SECONDS);
+ $this->setTimeSensitivity(sensitivity: IJob::TIME_INSENSITIVE);
+ }//end __construct()
- /**
- * Run one refresh tick. Never throws — a single broken placement is
- * isolated inside {@see HealthPingService::refreshDuePlacements()};
- * this wrapper additionally guards against any unexpected failure in
- * the service call itself so the scheduler's job list is never
- * poisoned.
- *
- * @param mixed $argument Ignored — the job carries no arguments.
- *
- * @return void
- *
- * @spec openspec/specs/service-health-ping/spec.md
- */
- protected function run($argument): void
- {
- try {
- $refreshed = $this->healthPingService->refreshDuePlacements();
- } catch (Throwable $exception) {
- $this->logger->warning(
- message: 'launchpad.healthping.job_failed',
- context: ['app' => Application::APP_ID, 'exception' => $exception->getMessage()]
- );
- return;
- }
+ /**
+ * Run one refresh tick. Never throws — a single broken placement is
+ * isolated inside {@see HealthPingService::refreshDuePlacements()};
+ * this wrapper additionally guards against any unexpected failure in
+ * the service call itself so the scheduler's job list is never
+ * poisoned.
+ *
+ * @param mixed $argument Ignored — the job carries no arguments.
+ *
+ * @return void
+ *
+ * @spec openspec/specs/service-health-ping/spec.md
+ */
+ protected function run($argument): void {
+ try {
+ $refreshed = $this->healthPingService->refreshDuePlacements();
+ } catch (Throwable $exception) {
+ $this->logger->warning(
+ message: 'launchpad.healthping.job_failed',
+ context: ['app' => Application::APP_ID, 'exception' => $exception->getMessage()]
+ );
+ return;
+ }
- $this->logger->debug(
- message: sprintf('launchpad.healthping.job_run refreshed=%d', $refreshed)
- );
- }//end run()
+ $this->logger->debug(
+ message: sprintf('launchpad.healthping.job_run refreshed=%d', $refreshed)
+ );
+ }//end run()
}//end class
diff --git a/lib/BackgroundJob/OrphanedDataCleanupJob.php b/lib/BackgroundJob/OrphanedDataCleanupJob.php
index 9fad5c4f..a8815a26 100644
--- a/lib/BackgroundJob/OrphanedDataCleanupJob.php
+++ b/lib/BackgroundJob/OrphanedDataCleanupJob.php
@@ -44,145 +44,142 @@
* @SuppressWarnings(PHPMD.UnusedFormalParameter) — $argument required by TimedJob interface.
* @spec openspec/specs/orphaned-data-cleanup/spec.md
*/
-class OrphanedDataCleanupJob extends TimedJob
-{
- /**
- * IAppConfig key holding the JSON-encoded auto-purge category list.
- *
- * @var string
- */
- public const CONFIG_KEY_CATEGORIES = 'cleanup_auto_purge_categories';
-
- /**
- * Run interval in seconds (24 hours). REQ-CLN-007 "scheduled
- * daily". Time-insensitive — the job is allowed to slip into the
- * next low-traffic window.
- *
- * @var int
- */
- public const INTERVAL_SECONDS = 86400;
-
- /**
- * Constructor.
- *
- * @param ITimeFactory $time Time factory
- * (parent
- * requirement).
- * @param OrphanedDataCleanupService $cleanupService The orchestrator.
- * @param CategoryRegistryService $registry Category registry
- * for the auto-safe
- * default list.
- * @param IAppConfig $appConfig App config to
- * read the
- * admin-chosen
- * auto-purge
- * set.
- * @param LoggerInterface $logger PSR-3 logger.
- */
- public function __construct(
- ITimeFactory $time,
- private readonly OrphanedDataCleanupService $cleanupService,
- private readonly CategoryRegistryService $registry,
- private readonly IAppConfig $appConfig,
- private readonly LoggerInterface $logger,
- ) {
- parent::__construct(time: $time);
- $this->setInterval(seconds: self::INTERVAL_SECONDS);
- $this->setTimeSensitivity(sensitivity: IJob::TIME_INSENSITIVE);
- }//end __construct()
-
- /**
- * Run the auto-purge.
- *
- * Reads the admin-configured category list, falls back to the
- * Tier-A default when none is configured. Skips quietly when the
- * list is empty (admin has explicitly disabled auto-purge).
- *
- * Errors thrown by individual category implementations are
- * caught by the parent {@see Job::start()} which logs them; we
- * additionally write a structured "skipped" log here when the
- * config is empty so cluster operators can grep for the reason.
- *
- * @param mixed $argument Ignored — the job carries no arguments.
- *
- * @return void
- *
- * @spec openspec/specs/orphaned-data-cleanup/spec.md
- */
- protected function run($argument): void
- {
- $categories = $this->resolveCategories();
-
- if (count(value: $categories) === 0) {
- $this->logger->info(
- message: 'launchpad.cleanup.job_skipped reason=no_categories_enabled'
- );
- return;
- }
-
- $result = $this->cleanupService->purge(
- categoryNames: $categories,
- dryRun: false,
- userId: null,
- source: 'job',
- );
-
- $this->logger->info(
- message: sprintf(
- 'launchpad.cleanup.job_run rows=%d duration_ms=%d categories=%s',
- $result->getTotalRows(),
- $result->getDurationMs(),
- implode(separator: ',', array: $categories),
- )
- );
- }//end run()
-
- /**
- * Resolve the configured auto-purge categories.
- *
- * Reads the JSON-encoded list from `IAppConfig` under
- * {@see self::CONFIG_KEY_CATEGORIES}. Falls back to the registry's
- * Tier-A default list when the config is missing or unparseable.
- * An explicit empty array (admin-set) is preserved — that signals
- * "auto-purge disabled" and the run() method skips.
- *
- * @return array The category names.
- */
- private function resolveCategories(): array
- {
- $raw = $this->appConfig->getValueString(
- app: Application::APP_ID,
- key: self::CONFIG_KEY_CATEGORIES,
- default: ''
- );
-
- if ($raw === '') {
- return $this->registry->getAutoSafeCategoryNames();
- }
-
- $decoded = json_decode(json: $raw, associative: true);
- if (is_array(value: $decoded) === false) {
- $this->logger->warning(
- message: sprintf(
- 'launchpad.cleanup.job_config_invalid raw=%s',
- $raw
- )
- );
- return $this->registry->getAutoSafeCategoryNames();
- }
-
- $known = $this->registry->getCategoryNames();
- $filtered = [];
- foreach ($decoded as $entry) {
- if (is_string(value: $entry) === false || $entry === '') {
- continue;
- }
-
- if (in_array(needle: $entry, haystack: $known, strict: true) === true) {
- $filtered[] = $entry;
- }
- }
-
- return $filtered;
- }//end resolveCategories()
+class OrphanedDataCleanupJob extends TimedJob {
+ /**
+ * IAppConfig key holding the JSON-encoded auto-purge category list.
+ *
+ * @var string
+ */
+ public const CONFIG_KEY_CATEGORIES = 'cleanup_auto_purge_categories';
+
+ /**
+ * Run interval in seconds (24 hours). REQ-CLN-007 "scheduled
+ * daily". Time-insensitive — the job is allowed to slip into the
+ * next low-traffic window.
+ *
+ * @var int
+ */
+ public const INTERVAL_SECONDS = 86400;
+
+ /**
+ * Constructor.
+ *
+ * @param ITimeFactory $time Time factory
+ * (parent
+ * requirement).
+ * @param OrphanedDataCleanupService $cleanupService The orchestrator.
+ * @param CategoryRegistryService $registry Category registry
+ * for the auto-safe
+ * default list.
+ * @param IAppConfig $appConfig App config to
+ * read the
+ * admin-chosen
+ * auto-purge
+ * set.
+ * @param LoggerInterface $logger PSR-3 logger.
+ */
+ public function __construct(
+ ITimeFactory $time,
+ private readonly OrphanedDataCleanupService $cleanupService,
+ private readonly CategoryRegistryService $registry,
+ private readonly IAppConfig $appConfig,
+ private readonly LoggerInterface $logger,
+ ) {
+ parent::__construct(time: $time);
+ $this->setInterval(seconds: self::INTERVAL_SECONDS);
+ $this->setTimeSensitivity(sensitivity: IJob::TIME_INSENSITIVE);
+ }//end __construct()
+
+ /**
+ * Run the auto-purge.
+ *
+ * Reads the admin-configured category list, falls back to the
+ * Tier-A default when none is configured. Skips quietly when the
+ * list is empty (admin has explicitly disabled auto-purge).
+ *
+ * Errors thrown by individual category implementations are
+ * caught by the parent {@see Job::start()} which logs them; we
+ * additionally write a structured "skipped" log here when the
+ * config is empty so cluster operators can grep for the reason.
+ *
+ * @param mixed $argument Ignored — the job carries no arguments.
+ *
+ * @return void
+ *
+ * @spec openspec/specs/orphaned-data-cleanup/spec.md
+ */
+ protected function run($argument): void {
+ $categories = $this->resolveCategories();
+
+ if (count(value: $categories) === 0) {
+ $this->logger->info(
+ message: 'launchpad.cleanup.job_skipped reason=no_categories_enabled'
+ );
+ return;
+ }
+
+ $result = $this->cleanupService->purge(
+ categoryNames: $categories,
+ dryRun: false,
+ userId: null,
+ source: 'job',
+ );
+
+ $this->logger->info(
+ message: sprintf(
+ 'launchpad.cleanup.job_run rows=%d duration_ms=%d categories=%s',
+ $result->getTotalRows(),
+ $result->getDurationMs(),
+ implode(separator: ',', array: $categories),
+ )
+ );
+ }//end run()
+
+ /**
+ * Resolve the configured auto-purge categories.
+ *
+ * Reads the JSON-encoded list from `IAppConfig` under
+ * {@see self::CONFIG_KEY_CATEGORIES}. Falls back to the registry's
+ * Tier-A default list when the config is missing or unparseable.
+ * An explicit empty array (admin-set) is preserved — that signals
+ * "auto-purge disabled" and the run() method skips.
+ *
+ * @return array The category names.
+ */
+ private function resolveCategories(): array {
+ $raw = $this->appConfig->getValueString(
+ app: Application::APP_ID,
+ key: self::CONFIG_KEY_CATEGORIES,
+ default: ''
+ );
+
+ if ($raw === '') {
+ return $this->registry->getAutoSafeCategoryNames();
+ }
+
+ $decoded = json_decode(json: $raw, associative: true);
+ if (is_array(value: $decoded) === false) {
+ $this->logger->warning(
+ message: sprintf(
+ 'launchpad.cleanup.job_config_invalid raw=%s',
+ $raw
+ )
+ );
+ return $this->registry->getAutoSafeCategoryNames();
+ }
+
+ $known = $this->registry->getCategoryNames();
+ $filtered = [];
+ foreach ($decoded as $entry) {
+ if (is_string(value: $entry) === false || $entry === '') {
+ continue;
+ }
+
+ if (in_array(needle: $entry, haystack: $known, strict: true) === true) {
+ $filtered[] = $entry;
+ }
+ }
+
+ return $filtered;
+ }//end resolveCategories()
}//end class
diff --git a/lib/BackgroundJob/PurgeViewsJob.php b/lib/BackgroundJob/PurgeViewsJob.php
index 7b774922..2b95b052 100644
--- a/lib/BackgroundJob/PurgeViewsJob.php
+++ b/lib/BackgroundJob/PurgeViewsJob.php
@@ -47,67 +47,65 @@
* @SuppressWarnings(PHPMD.UnusedFormalParameter) — $argument required by TimedJob interface.
* @spec openspec/specs/dashboard-view-analytics/spec.md
*/
-class PurgeViewsJob extends TimedJob
-{
- /**
- * Constructor.
- *
- * @param ITimeFactory $time Time factory used by
- * the parent
- * `TimedJob` to gate
- * the next-run
- * decision.
- * @param AnalyticsService $analyticsService Analytics service
- * (cutoff date + log
- * context).
- * @param DashboardViewMapper $viewMapper Aggregate-row
- * mapper.
- * @param TileClickMapper $tileClickMapper Tile-click
- * aggregate-row
- * mapper — reuses
- * the same cutoff
- * date (REQ-TANLT-005).
- * @param LoggerInterface $logger PSR logger.
- */
- public function __construct(
- ITimeFactory $time,
- private readonly AnalyticsService $analyticsService,
- private readonly DashboardViewMapper $viewMapper,
- private readonly TileClickMapper $tileClickMapper,
- private readonly LoggerInterface $logger,
- ) {
- parent::__construct(time: $time);
- $this->setInterval(seconds: 86400);
- }//end __construct()
+class PurgeViewsJob extends TimedJob {
+ /**
+ * Constructor.
+ *
+ * @param ITimeFactory $time Time factory used by
+ * the parent
+ * `TimedJob` to gate
+ * the next-run
+ * decision.
+ * @param AnalyticsService $analyticsService Analytics service
+ * (cutoff date + log
+ * context).
+ * @param DashboardViewMapper $viewMapper Aggregate-row
+ * mapper.
+ * @param TileClickMapper $tileClickMapper Tile-click
+ * aggregate-row
+ * mapper — reuses
+ * the same cutoff
+ * date (REQ-TANLT-005).
+ * @param LoggerInterface $logger PSR logger.
+ */
+ public function __construct(
+ ITimeFactory $time,
+ private readonly AnalyticsService $analyticsService,
+ private readonly DashboardViewMapper $viewMapper,
+ private readonly TileClickMapper $tileClickMapper,
+ private readonly LoggerInterface $logger,
+ ) {
+ parent::__construct(time: $time);
+ $this->setInterval(seconds: 86400);
+ }//end __construct()
- /**
- * Run the job — delete every aggregate row strictly older than
- * the cutoff date, in BOTH the dashboard-views table and the
- * tile-clicks table (REQ-TANLT-005 — same cutoff, same run, no
- * second job).
- *
- * @param mixed $argument Required by the base class; unused.
- *
- * @return void
- *
- * @spec openspec/specs/dashboard-view-analytics/spec.md
- * @spec openspec/specs/dashboard-view-analytics/spec.md
- */
- protected function run($argument): void
- {
- $cutoff = $this->analyticsService->getPurgeCutoffDate();
- $deletedViews = $this->viewMapper->deleteOlderThan(beforeDate: $cutoff);
- $deletedClicks = $this->tileClickMapper->deleteOlderThan(beforeDate: $cutoff);
+ /**
+ * Run the job — delete every aggregate row strictly older than
+ * the cutoff date, in BOTH the dashboard-views table and the
+ * tile-clicks table (REQ-TANLT-005 — same cutoff, same run, no
+ * second job).
+ *
+ * @param mixed $argument Required by the base class; unused.
+ *
+ * @return void
+ *
+ * @spec openspec/specs/dashboard-view-analytics/spec.md
+ * @spec openspec/specs/dashboard-view-analytics/spec.md
+ */
+ protected function run($argument): void {
+ $cutoff = $this->analyticsService->getPurgeCutoffDate();
+ $deletedViews = $this->viewMapper->deleteOlderThan(beforeDate: $cutoff);
+ $deletedClicks = $this->tileClickMapper->deleteOlderThan(beforeDate: $cutoff);
- $this->logger->info(
- message: 'launchpad analytics purge: deleted '.$deletedViews.' view rows and '
- .$deletedClicks.' tile-click rows older than '.$cutoff,
- context: [
- 'viewRows' => $deletedViews,
- 'tileRows' => $deletedClicks,
- 'cutoff' => $cutoff,
- 'retention' => $this->analyticsService->getRetentionDays(),
- ]
- );
- }//end run()
+ $this->logger->info(
+ message: 'launchpad analytics purge: deleted ' . $deletedViews . ' view rows and '
+ . $deletedClicks . ' tile-click rows older than ' . $cutoff,
+ context: [
+ 'viewRows' => $deletedViews,
+ 'tileRows' => $deletedClicks,
+ 'cutoff' => $cutoff,
+ 'retention' => $this->analyticsService->getRetentionDays(),
+ ]
+ );
+ }//end run()
}//end class
diff --git a/lib/BackgroundJob/SaltRotationJob.php b/lib/BackgroundJob/SaltRotationJob.php
index 9850bf67..b12f4dd4 100644
--- a/lib/BackgroundJob/SaltRotationJob.php
+++ b/lib/BackgroundJob/SaltRotationJob.php
@@ -46,43 +46,41 @@
* @SuppressWarnings(PHPMD.StaticAccess) — UniqueViewerDedup uses a static factory method.
* @spec openspec/specs/dashboard-view-analytics/spec.md
*/
-class SaltRotationJob extends TimedJob
-{
- /**
- * Constructor.
- *
- * @param ITimeFactory $time Time factory.
- * @param UniqueViewerDedup $dedup Dedup service whose salt is
- * rotated.
- * @param LoggerInterface $logger PSR logger.
- */
- public function __construct(
- ITimeFactory $time,
- private readonly UniqueViewerDedup $dedup,
- private readonly LoggerInterface $logger,
- ) {
- parent::__construct(time: $time);
- $this->setInterval(seconds: 86400);
- }//end __construct()
+class SaltRotationJob extends TimedJob {
+ /**
+ * Constructor.
+ *
+ * @param ITimeFactory $time Time factory.
+ * @param UniqueViewerDedup $dedup Dedup service whose salt is
+ * rotated.
+ * @param LoggerInterface $logger PSR logger.
+ */
+ public function __construct(
+ ITimeFactory $time,
+ private readonly UniqueViewerDedup $dedup,
+ private readonly LoggerInterface $logger,
+ ) {
+ parent::__construct(time: $time);
+ $this->setInterval(seconds: 86400);
+ }//end __construct()
- /**
- * Run the job — overwrite the persisted daily salt with a fresh
- * 32-byte random value (no history kept).
- *
- * @param mixed $argument Required by the base class; unused.
- *
- * @return void
- *
- * @spec openspec/specs/dashboard-view-analytics/spec.md
- */
- protected function run($argument): void
- {
- $today = UniqueViewerDedup::utcDateFor();
- $this->dedup->rotateSalt(viewBucketDate: $today);
+ /**
+ * Run the job — overwrite the persisted daily salt with a fresh
+ * 32-byte random value (no history kept).
+ *
+ * @param mixed $argument Required by the base class; unused.
+ *
+ * @return void
+ *
+ * @spec openspec/specs/dashboard-view-analytics/spec.md
+ */
+ protected function run($argument): void {
+ $today = UniqueViewerDedup::utcDateFor();
+ $this->dedup->rotateSalt(viewBucketDate: $today);
- $this->logger->info(
- message: 'launchpad analytics salt rotated for '.$today,
- context: ['date' => $today]
- );
- }//end run()
+ $this->logger->info(
+ message: 'launchpad analytics salt rotated for ' . $today,
+ context: ['date' => $today]
+ );
+ }//end run()
}//end class
diff --git a/lib/BackgroundJob/TemplateResyncJob.php b/lib/BackgroundJob/TemplateResyncJob.php
index f6010c29..e8e13df0 100644
--- a/lib/BackgroundJob/TemplateResyncJob.php
+++ b/lib/BackgroundJob/TemplateResyncJob.php
@@ -36,88 +36,86 @@
*
* @spec openspec/specs/admin-templates/spec.md#requirement-req-resync-005-re-sync-is-idempotent-audited-async-capable-and-notifies-users
*/
-class TemplateResyncJob extends QueuedJob
-{
- /**
- * Constructor.
- *
- * @param ITimeFactory $time Time factory (parent
- * requirement).
- * @param TemplateResyncService $resyncService The re-sync orchestrator
- * — {@see
- * TemplateResyncService::applyResync()}
- * recomputes the plan fresh
- * at run time (rather than
- * deserialising a stale
- * one), so the apply
- * reflects the template's
- * state at the moment the
- * job actually runs.
- * @param LoggerInterface $logger PSR-3 logger.
- */
- public function __construct(
- ITimeFactory $time,
- private readonly TemplateResyncService $resyncService,
- private readonly LoggerInterface $logger,
- ) {
- parent::__construct(time: $time);
- }//end __construct()
+class TemplateResyncJob extends QueuedJob {
+ /**
+ * Constructor.
+ *
+ * @param ITimeFactory $time Time factory (parent
+ * requirement).
+ * @param TemplateResyncService $resyncService The re-sync orchestrator
+ * — {@see
+ * TemplateResyncService::applyResync()}
+ * recomputes the plan fresh
+ * at run time (rather than
+ * deserialising a stale
+ * one), so the apply
+ * reflects the template's
+ * state at the moment the
+ * job actually runs.
+ * @param LoggerInterface $logger PSR-3 logger.
+ */
+ public function __construct(
+ ITimeFactory $time,
+ private readonly TemplateResyncService $resyncService,
+ private readonly LoggerInterface $logger,
+ ) {
+ parent::__construct(time: $time);
+ }//end __construct()
- /**
- * Apply the re-sync plan for `$argument['templateId']` /
- * `$argument['strategy']`, writing the audit record and notifying
- * every affected user on completion.
- *
- * Malformed arguments are logged and skipped rather than throwing —
- * a throw here would make NC's job runner retry indefinitely with the
- * same bad payload.
- *
- * @param mixed $argument `{templateId: int, strategy: string,
- * actingAdminId: string}`.
- *
- * @return void
- *
- * @spec openspec/specs/admin-templates/spec.md
- */
- protected function run($argument): void
- {
- $templateId = (int) ($argument['templateId'] ?? 0);
- $strategy = (string) ($argument['strategy'] ?? '');
- $actingAdminId = (string) ($argument['actingAdminId'] ?? '');
+ /**
+ * Apply the re-sync plan for `$argument['templateId']` /
+ * `$argument['strategy']`, writing the audit record and notifying
+ * every affected user on completion.
+ *
+ * Malformed arguments are logged and skipped rather than throwing —
+ * a throw here would make NC's job runner retry indefinitely with the
+ * same bad payload.
+ *
+ * @param mixed $argument `{templateId: int, strategy: string,
+ * actingAdminId: string}`.
+ *
+ * @return void
+ *
+ * @spec openspec/specs/admin-templates/spec.md
+ */
+ protected function run($argument): void {
+ $templateId = (int)($argument['templateId'] ?? 0);
+ $strategy = (string)($argument['strategy'] ?? '');
+ $actingAdminId = (string)($argument['actingAdminId'] ?? '');
- if ($templateId <= 0 || $strategy === '' || $actingAdminId === '') {
- $this->logger->warning(
- message: 'launchpad.template_resync.job_skipped reason=invalid_arguments',
- context: ['argument' => $argument]
- );
- return;
- }
+ if ($templateId <= 0 || $strategy === '' || $actingAdminId === '') {
+ $this->logger->warning(
+ message: 'launchpad.template_resync.job_skipped reason=invalid_arguments',
+ context: ['argument' => $argument]
+ );
+ return;
+ }
- try {
- $result = $this->resyncService->applyResync(
- templateId: $templateId,
- strategy: $strategy,
- actingAdminId: $actingAdminId
- );
+ try {
+ $result = $this->resyncService->applyResync(
+ templateId: $templateId,
+ strategy: $strategy,
+ actingAdminId: $actingAdminId
+ );
- $this->logger->info(
- message: sprintf(
- 'launchpad.template_resync.job_completed template=%d strategy=%s affected=%d total=%d',
- $templateId,
- $strategy,
- $result['affectedCount'],
- $result['totalCopies']
- )
- );
- } catch (Throwable $t) {
- $this->logger->error(
- message: 'launchpad.template_resync.job_failed',
- context: [
- 'templateId' => $templateId,
- 'strategy' => $strategy,
- 'exception' => $t,
- ]
- );
- }//end try
- }//end run()
+ $this->logger->info(
+ message: sprintf(
+ 'launchpad.template_resync.job_completed template=%d strategy=%s affected=%d total=%d',
+ $templateId,
+ $strategy,
+ $result['affectedCount'],
+ $result['totalCopies']
+ )
+ );
+ } catch (Throwable $t) {
+ $this->logger->error(
+ message: 'launchpad.template_resync.job_failed',
+ context: [
+ 'templateId' => $templateId,
+ 'strategy' => $strategy,
+ 'exception' => $t,
+ ]
+ );
+ }//end try
+ }//end run()
}//end class
diff --git a/lib/Command/CleanupPurgeCommand.php b/lib/Command/CleanupPurgeCommand.php
index 2313f36e..f372b2ef 100644
--- a/lib/Command/CleanupPurgeCommand.php
+++ b/lib/Command/CleanupPurgeCommand.php
@@ -44,238 +44,235 @@
/**
* `launchpad:cleanup:purge` CLI command.
*/
-class CleanupPurgeCommand extends Command
-{
- /**
- * Constructor.
- *
- * @param OrphanedDataCleanupService $cleanupService The orchestrator.
- * @param CategoryRegistryService $registry Category registry
- * (for the
- * unknown-name
- * error path).
- */
- public function __construct(
- private readonly OrphanedDataCleanupService $cleanupService,
- private readonly CategoryRegistryService $registry,
- ) {
- parent::__construct();
- }//end __construct()
+class CleanupPurgeCommand extends Command {
+ /**
+ * Constructor.
+ *
+ * @param OrphanedDataCleanupService $cleanupService The orchestrator.
+ * @param CategoryRegistryService $registry Category registry
+ * (for the
+ * unknown-name
+ * error path).
+ */
+ public function __construct(
+ private readonly OrphanedDataCleanupService $cleanupService,
+ private readonly CategoryRegistryService $registry,
+ ) {
+ parent::__construct();
+ }//end __construct()
- /**
- * Configure the command name, description and options.
- *
- * @return void
- *
- * @spec openspec/specs/orphaned-data-cleanup/spec.md
- */
- protected function configure(): void
- {
- $this->setName(name: 'launchpad:cleanup:purge')
- ->setDescription(
- description: 'Delete orphaned LaunchPad data. See options for dry-run and per-category limits.'
- )
- ->addOption(
- name: 'category',
- shortcut: null,
- mode: InputOption::VALUE_REQUIRED,
- description: 'Limit to one category by name. Default is all.'
- )
- ->addOption(
- name: 'dry-run',
- shortcut: null,
- mode: InputOption::VALUE_NONE,
- description: 'Wrap deletes in a rolled-back transaction.'
- )
- ->addOption(
- name: 'yes',
- shortcut: 'y',
- mode: InputOption::VALUE_NONE,
- description: 'Skip the interactive confirmation prompt. Required for cron / CI use.'
- );
- }//end configure()
+ /**
+ * Configure the command name, description and options.
+ *
+ * @return void
+ *
+ * @spec openspec/specs/orphaned-data-cleanup/spec.md
+ */
+ protected function configure(): void {
+ $this->setName(name: 'launchpad:cleanup:purge')
+ ->setDescription(
+ description: 'Delete orphaned LaunchPad data. See options for dry-run and per-category limits.'
+ )
+ ->addOption(
+ name: 'category',
+ shortcut: null,
+ mode: InputOption::VALUE_REQUIRED,
+ description: 'Limit to one category by name. Default is all.'
+ )
+ ->addOption(
+ name: 'dry-run',
+ shortcut: null,
+ mode: InputOption::VALUE_NONE,
+ description: 'Wrap deletes in a rolled-back transaction.'
+ )
+ ->addOption(
+ name: 'yes',
+ shortcut: 'y',
+ mode: InputOption::VALUE_NONE,
+ description: 'Skip the interactive confirmation prompt. Required for cron / CI use.'
+ );
+ }//end configure()
- /**
- * Execute the purge.
- *
- * @param InputInterface $input The console input.
- * @param OutputInterface $output The console output.
- *
- * @return int 0 on success, 1 on validation failure.
- *
- * @spec openspec/specs/orphaned-data-cleanup/spec.md
- */
- protected function execute(
- InputInterface $input,
- OutputInterface $output
- ): int {
- $categoryOption = $input->getOption(name: 'category');
- $dryRun = (bool) $input->getOption(name: 'dry-run');
- $assumeYes = (bool) $input->getOption(name: 'yes');
+ /**
+ * Execute the purge.
+ *
+ * @param InputInterface $input The console input.
+ * @param OutputInterface $output The console output.
+ *
+ * @return int 0 on success, 1 on validation failure.
+ *
+ * @spec openspec/specs/orphaned-data-cleanup/spec.md
+ */
+ protected function execute(
+ InputInterface $input,
+ OutputInterface $output,
+ ): int {
+ $categoryOption = $input->getOption(name: 'category');
+ $dryRun = (bool)$input->getOption(name: 'dry-run');
+ $assumeYes = (bool)$input->getOption(name: 'yes');
- $categoryNames = [];
- if (is_string(value: $categoryOption) === true && $categoryOption !== '') {
- if ($this->registry->getCategoryByName(name: $categoryOption) === null) {
- $output->writeln(
- messages: sprintf(
- 'Unknown cleanup category: %s',
- $categoryOption
- )
- );
- $output->writeln(
- messages: sprintf(
- 'Valid categories: %s',
- implode(separator: ', ', array: $this->registry->getCategoryNames())
- )
- );
+ $categoryNames = [];
+ if (is_string(value: $categoryOption) === true && $categoryOption !== '') {
+ if ($this->registry->getCategoryByName(name: $categoryOption) === null) {
+ $output->writeln(
+ messages: sprintf(
+ 'Unknown cleanup category: %s',
+ $categoryOption
+ )
+ );
+ $output->writeln(
+ messages: sprintf(
+ 'Valid categories: %s',
+ implode(separator: ', ', array: $this->registry->getCategoryNames())
+ )
+ );
- return 1;
- }
+ return 1;
+ }
- $categoryNames = [$categoryOption];
- }
+ $categoryNames = [$categoryOption];
+ }
- $effectiveCategories = $categoryNames;
- if (count(value: $effectiveCategories) === 0) {
- $effectiveCategories = $this->registry->getCategoryNames();
- }
+ $effectiveCategories = $categoryNames;
+ if (count(value: $effectiveCategories) === 0) {
+ $effectiveCategories = $this->registry->getCategoryNames();
+ }
- if ($this->confirmPurge(
- input: $input,
- output: $output,
- assumeYes: $assumeYes,
- effectiveCategories: $effectiveCategories
- ) === false
- ) {
- $output->writeln(messages: 'Purge cancelled.');
- return 0;
- }
+ if ($this->confirmPurge(
+ input: $input,
+ output: $output,
+ assumeYes: $assumeYes,
+ effectiveCategories: $effectiveCategories
+ ) === false
+ ) {
+ $output->writeln(messages: 'Purge cancelled.');
+ return 0;
+ }
- $result = $this->cleanupService->purge(
- categoryNames: $categoryNames,
- dryRun: $dryRun,
- userId: null,
- source: 'cli',
- );
+ $result = $this->cleanupService->purge(
+ categoryNames: $categoryNames,
+ dryRun: $dryRun,
+ userId: null,
+ source: 'cli',
+ );
- $output->writeln(
- messages: $this->formatSummary(
- result: $result,
- categoryNames: $categoryNames,
- dryRun: $dryRun
- )
- );
+ $output->writeln(
+ messages: $this->formatSummary(
+ result: $result,
+ categoryNames: $categoryNames,
+ dryRun: $dryRun
+ )
+ );
- $this->writeSkipped(output: $output, skipped: $result->getSkipped());
+ $this->writeSkipped(output: $output, skipped: $result->getSkipped());
- return 0;
- }//end execute()
+ return 0;
+ }//end execute()
- /**
- * Ask the operator to confirm the purge.
- *
- * Returns `true` immediately when `--yes` was supplied, or when the
- * console has no question helper registered (the pre-existing
- * non-interactive fallback). Otherwise the confirmation question is
- * asked and its answer returned.
- *
- * @param InputInterface $input The console input.
- * @param OutputInterface $output The console output.
- * @param bool $assumeYes Whether `--yes` was
- * supplied.
- * @param array $effectiveCategories Categories named in
- * the prompt.
- *
- * @return bool True when the purge may proceed.
- */
- private function confirmPurge(
- InputInterface $input,
- OutputInterface $output,
- bool $assumeYes,
- array $effectiveCategories
- ): bool {
- if ($assumeYes === true) {
- return true;
- }
+ /**
+ * Ask the operator to confirm the purge.
+ *
+ * Returns `true` immediately when `--yes` was supplied, or when the
+ * console has no question helper registered (the pre-existing
+ * non-interactive fallback). Otherwise the confirmation question is
+ * asked and its answer returned.
+ *
+ * @param InputInterface $input The console input.
+ * @param OutputInterface $output The console output.
+ * @param bool $assumeYes Whether `--yes` was
+ * supplied.
+ * @param array $effectiveCategories Categories named in
+ * the prompt.
+ *
+ * @return bool True when the purge may proceed.
+ */
+ private function confirmPurge(
+ InputInterface $input,
+ OutputInterface $output,
+ bool $assumeYes,
+ array $effectiveCategories,
+ ): bool {
+ if ($assumeYes === true) {
+ return true;
+ }
- $helper = $this->getHelper(name: 'question');
- if (($helper instanceof QuestionHelper) === false) {
- return true;
- }
+ $helper = $this->getHelper(name: 'question');
+ if (($helper instanceof QuestionHelper) === false) {
+ return true;
+ }
- $question = new ConfirmationQuestion(
- question: sprintf(
- 'Delete orphaned data in categories: [%s]? (y/N) ',
- implode(separator: ', ', array: $effectiveCategories)
- ),
- default: false
- );
+ $question = new ConfirmationQuestion(
+ question: sprintf(
+ 'Delete orphaned data in categories: [%s]? (y/N) ',
+ implode(separator: ', ', array: $effectiveCategories)
+ ),
+ default: false
+ );
- return ($helper->ask(input: $input, output: $output, question: $question) !== false);
- }//end confirmPurge()
+ return ($helper->ask(input: $input, output: $output, question: $question) !== false);
+ }//end confirmPurge()
- /**
- * Build the one-line summary written after a purge.
- *
- * A single explicitly-named category gets the per-category wording;
- * every other invocation gets the across-categories wording. Dry runs
- * are prefixed so the operator can never mistake a preview for a
- * completed purge.
- *
- * @param CleanupResult $result The purge result.
- * @param array $categoryNames The explicitly requested categories.
- * @param bool $dryRun Whether this was a dry run.
- *
- * @return string The summary line.
- */
- private function formatSummary(
- CleanupResult $result,
- array $categoryNames,
- bool $dryRun
- ): string {
- $prefix = 'Purged';
- if ($dryRun === true) {
- $prefix = 'DRY-RUN: Would purge';
- }
+ /**
+ * Build the one-line summary written after a purge.
+ *
+ * A single explicitly-named category gets the per-category wording;
+ * every other invocation gets the across-categories wording. Dry runs
+ * are prefixed so the operator can never mistake a preview for a
+ * completed purge.
+ *
+ * @param CleanupResult $result The purge result.
+ * @param array $categoryNames The explicitly requested categories.
+ * @param bool $dryRun Whether this was a dry run.
+ *
+ * @return string The summary line.
+ */
+ private function formatSummary(
+ CleanupResult $result,
+ array $categoryNames,
+ bool $dryRun,
+ ): string {
+ $prefix = 'Purged';
+ if ($dryRun === true) {
+ $prefix = 'DRY-RUN: Would purge';
+ }
- if (count(value: $categoryNames) === 1) {
- return sprintf(
- '%s %d items from category \'%s\' in %dms.',
- $prefix,
- $result->getTotalRows(),
- $categoryNames[0],
- $result->getDurationMs()
- );
- }
+ if (count(value: $categoryNames) === 1) {
+ return sprintf(
+ '%s %d items from category \'%s\' in %dms.',
+ $prefix,
+ $result->getTotalRows(),
+ $categoryNames[0],
+ $result->getDurationMs()
+ );
+ }
- return sprintf(
- '%s %d items across %d categories in %dms.',
- $prefix,
- $result->getTotalRows(),
- count(value: $result->getByCategory()),
- $result->getDurationMs()
- );
- }//end formatSummary()
+ return sprintf(
+ '%s %d items across %d categories in %dms.',
+ $prefix,
+ $result->getTotalRows(),
+ count(value: $result->getByCategory()),
+ $result->getDurationMs()
+ );
+ }//end formatSummary()
- /**
- * Write the skipped-categories notice when there is one.
- *
- * @param OutputInterface $output The console output.
- * @param array $skipped The skipped category names.
- *
- * @return void
- */
- private function writeSkipped(OutputInterface $output, array $skipped): void
- {
- if (count(value: $skipped) === 0) {
- return;
- }
+ /**
+ * Write the skipped-categories notice when there is one.
+ *
+ * @param OutputInterface $output The console output.
+ * @param array $skipped The skipped category names.
+ *
+ * @return void
+ */
+ private function writeSkipped(OutputInterface $output, array $skipped): void {
+ if (count(value: $skipped) === 0) {
+ return;
+ }
- $output->writeln(
- messages: sprintf(
- 'Skipped categories: %s',
- implode(separator: ', ', array: $skipped)
- )
- );
- }//end writeSkipped()
+ $output->writeln(
+ messages: sprintf(
+ 'Skipped categories: %s',
+ implode(separator: ', ', array: $skipped)
+ )
+ );
+ }//end writeSkipped()
}//end class
diff --git a/lib/Command/CleanupScanCommand.php b/lib/Command/CleanupScanCommand.php
index b025d3ff..4cc6cc85 100644
--- a/lib/Command/CleanupScanCommand.php
+++ b/lib/Command/CleanupScanCommand.php
@@ -36,81 +36,79 @@
/**
* `launchpad:cleanup:scan` CLI command.
*/
-class CleanupScanCommand extends Command
-{
- /**
- * Constructor.
- *
- * @param OrphanedDataCleanupService $cleanupService The orchestrator.
- */
- public function __construct(
- private readonly OrphanedDataCleanupService $cleanupService,
- ) {
- parent::__construct();
- }//end __construct()
+class CleanupScanCommand extends Command {
+ /**
+ * Constructor.
+ *
+ * @param OrphanedDataCleanupService $cleanupService The orchestrator.
+ */
+ public function __construct(
+ private readonly OrphanedDataCleanupService $cleanupService,
+ ) {
+ parent::__construct();
+ }//end __construct()
- /**
- * Configure the command name + description.
- *
- * @return void
- *
- * @spec openspec/specs/orphaned-data-cleanup/spec.md
- */
- protected function configure(): void
- {
- $this->setName(name: 'launchpad:cleanup:scan')
- ->setDescription(
- description: 'Scan LaunchPad storage for orphans by category. Exits non-zero when any are found.'
- );
- }//end configure()
+ /**
+ * Configure the command name + description.
+ *
+ * @return void
+ *
+ * @spec openspec/specs/orphaned-data-cleanup/spec.md
+ */
+ protected function configure(): void {
+ $this->setName(name: 'launchpad:cleanup:scan')
+ ->setDescription(
+ description: 'Scan LaunchPad storage for orphans by category. Exits non-zero when any are found.'
+ );
+ }//end configure()
- /**
- * Execute the scan.
- *
- * @param InputInterface $input The console input.
- * @param OutputInterface $output The console output.
- *
- * @return int 0 when no orphans, 1 otherwise.
- *
- * @spec openspec/specs/orphaned-data-cleanup/spec.md
- */
- protected function execute(
- InputInterface $input,
- OutputInterface $output
- ): int {
- $result = $this->cleanupService->scan();
+ /**
+ * Execute the scan.
+ *
+ * @param InputInterface $input The console input.
+ * @param OutputInterface $output The console output.
+ *
+ * @return int 0 when no orphans, 1 otherwise.
+ *
+ * @spec openspec/specs/orphaned-data-cleanup/spec.md
+ */
+ protected function execute(
+ InputInterface $input,
+ OutputInterface $output,
+ ): int {
+ $result = $this->cleanupService->scan();
- $table = new Table(output: $output);
- $table->setHeaders(headers: ['Category', 'Count']);
+ $table = new Table(output: $output);
+ $table->setHeaders(headers: ['Category', 'Count']);
- foreach ($result->getByCategory() as $name => $count) {
- $table->addRow(row: [$name, (string) $count]);
- }
+ foreach ($result->getByCategory() as $name => $count) {
+ $table->addRow(row: [$name, (string)$count]);
+ }
- $table->addRow(row: ['TOTAL', (string) $result->getTotalRows()]);
- $table->render();
+ $table->addRow(row: ['TOTAL', (string)$result->getTotalRows()]);
+ $table->render();
- $skipped = $result->getSkipped();
- if (count(value: $skipped) > 0) {
- $output->writeln(
- messages: sprintf(
- 'Skipped categories (feature unavailable): %s',
- implode(separator: ', ', array: $skipped)
- )
- );
- }
+ $skipped = $result->getSkipped();
+ if (count(value: $skipped) > 0) {
+ $output->writeln(
+ messages: sprintf(
+ 'Skipped categories (feature unavailable): %s',
+ implode(separator: ', ', array: $skipped)
+ )
+ );
+ }
- $output->writeln(
- messages: sprintf(
- 'Scan completed in %dms.',
- $result->getDurationMs()
- )
- );
+ $output->writeln(
+ messages: sprintf(
+ 'Scan completed in %dms.',
+ $result->getDurationMs()
+ )
+ );
- if ($result->getTotalRows() === 0) {
- return 0;
- }
+ if ($result->getTotalRows() === 0) {
+ return 0;
+ }
- return 1;
- }//end execute()
+ return 1;
+ }//end execute()
}//end class
diff --git a/lib/Command/CommandBase.php b/lib/Command/CommandBase.php
index e732c3a9..bd015c9e 100644
--- a/lib/Command/CommandBase.php
+++ b/lib/Command/CommandBase.php
@@ -45,338 +45,329 @@
/**
* Abstract base for LaunchPad CLI commands (REQ-CLI-002).
*/
-abstract class CommandBase extends Command
-{
- /**
- * Constructor.
- *
- * @param CommandService $commandService Shared exit-code, JSON and
- * audit-log helper.
- * @param IUserSession $userSession Caller resolution for the
- * audit log (REQ-CLI-010).
- */
- public function __construct(
- protected readonly CommandService $commandService,
- private readonly IUserSession $userSession
- ) {
- parent::__construct();
- }//end __construct()
+abstract class CommandBase extends Command {
+ /**
+ * Constructor.
+ *
+ * @param CommandService $commandService Shared exit-code, JSON and
+ * audit-log helper.
+ * @param IUserSession $userSession Caller resolution for the
+ * audit log (REQ-CLI-010).
+ */
+ public function __construct(
+ protected readonly CommandService $commandService,
+ private readonly IUserSession $userSession,
+ ) {
+ parent::__construct();
+ }//end __construct()
- /**
- * Wire the three global flags shared by every `launchpad:*` command
- * (REQ-CLI-002), then defer to the child for per-command options.
- *
- * @return void
- *
- * @spec openspec/specs/cli-commands/spec.md
- */
- final protected function configure(): void
- {
- $this->addOption(
- name: 'json',
- shortcut: null,
- mode: InputOption::VALUE_NONE,
- description: 'Emit a single JSON envelope on stdout (REQ-CLI-007).'
- );
- $this->addOption(
- name: 'quiet',
- shortcut: 'q',
- mode: InputOption::VALUE_NONE,
- description: 'Suppress non-essential output. Errors still go to stderr.'
- );
- $this->addOption(
- name: 'no-interaction',
- shortcut: 'n',
- mode: InputOption::VALUE_NONE,
- description: 'Skip confirmation prompts (assume yes) — for CI/automation.'
- );
+ /**
+ * Wire the three global flags shared by every `launchpad:*` command
+ * (REQ-CLI-002), then defer to the child for per-command options.
+ *
+ * @return void
+ *
+ * @spec openspec/specs/cli-commands/spec.md
+ */
+ final protected function configure(): void {
+ $this->addOption(
+ name: 'json',
+ shortcut: null,
+ mode: InputOption::VALUE_NONE,
+ description: 'Emit a single JSON envelope on stdout (REQ-CLI-007).'
+ );
+ $this->addOption(
+ name: 'quiet',
+ shortcut: 'q',
+ mode: InputOption::VALUE_NONE,
+ description: 'Suppress non-essential output. Errors still go to stderr.'
+ );
+ $this->addOption(
+ name: 'no-interaction',
+ shortcut: 'n',
+ mode: InputOption::VALUE_NONE,
+ description: 'Skip confirmation prompts (assume yes) — for CI/automation.'
+ );
- $this->configureCommand();
- }//end configure()
+ $this->configureCommand();
+ }//end configure()
- /**
- * Hook for subclasses to declare name, description, arguments and
- * extra options. The three global flags are registered by
- * {@see configure()}; subclasses MUST NOT re-declare them.
- *
- * @return void
- *
- * @spec openspec/specs/cli-commands/spec.md
- */
- abstract protected function configureCommand(): void;
+ /**
+ * Hook for subclasses to declare name, description, arguments and
+ * extra options. The three global flags are registered by
+ * {@see configure()}; subclasses MUST NOT re-declare them.
+ *
+ * @return void
+ *
+ * @spec openspec/specs/cli-commands/spec.md
+ */
+ abstract protected function configureCommand(): void;
- /**
- * Execute the command's business logic.
- *
- * Returning an exit code MUST use one of the
- * {@see CommandService}::EXIT_* constants.
- *
- * @param InputInterface $input CLI input.
- * @param OutputInterface $output CLI output (use the helpers on
- * this class to honour `--quiet`
- * and `--json`).
- *
- * @return int
- *
- * @spec openspec/specs/cli-commands/spec.md
- */
- abstract protected function handle(
- InputInterface $input,
- OutputInterface $output
- ): int;
+ /**
+ * Execute the command's business logic.
+ *
+ * Returning an exit code MUST use one of the
+ * {@see CommandService}::EXIT_* constants.
+ *
+ * @param InputInterface $input CLI input.
+ * @param OutputInterface $output CLI output (use the helpers on
+ * this class to honour `--quiet`
+ * and `--json`).
+ *
+ * @return int
+ *
+ * @spec openspec/specs/cli-commands/spec.md
+ */
+ abstract protected function handle(
+ InputInterface $input,
+ OutputInterface $output,
+ ): int;
- /**
- * Symfony entry point — wraps {@see handle()} with timing, JSON
- * envelope on uncaught exception, and the audit log line
- * (REQ-CLI-010).
- *
- * @param InputInterface $input CLI input.
- * @param OutputInterface $output CLI output.
- *
- * @return int
- *
- * @spec openspec/specs/cli-commands/spec.md
- */
- final protected function execute(
- InputInterface $input,
- OutputInterface $output
- ): int {
- $started = (int) round(num: (microtime(as_float: true) * 1000));
- $exitCode = CommandService::EXIT_ERROR;
- try {
- $exitCode = $this->handle(input: $input, output: $output);
- } catch (Throwable $e) {
- $exitCode = CommandService::EXIT_ERROR;
- $envelope = $this->commandService->envelopeError(
- exitCode: $exitCode,
- code: 'INTERNAL_ERROR',
- message: $e->getMessage(),
- context: ['exceptionClass' => $e::class]
- );
- if ($this->isJson(input: $input) === true) {
- $output->writeln(messages: $this->commandService->encodeEnvelope(envelope: $envelope));
- }
+ /**
+ * Symfony entry point — wraps {@see handle()} with timing, JSON
+ * envelope on uncaught exception, and the audit log line
+ * (REQ-CLI-010).
+ *
+ * @param InputInterface $input CLI input.
+ * @param OutputInterface $output CLI output.
+ *
+ * @return int
+ *
+ * @spec openspec/specs/cli-commands/spec.md
+ */
+ final protected function execute(
+ InputInterface $input,
+ OutputInterface $output,
+ ): int {
+ $started = (int)round(num: (microtime(as_float: true) * 1000));
+ $exitCode = CommandService::EXIT_ERROR;
+ try {
+ $exitCode = $this->handle(input: $input, output: $output);
+ } catch (Throwable $e) {
+ $exitCode = CommandService::EXIT_ERROR;
+ $envelope = $this->commandService->envelopeError(
+ exitCode: $exitCode,
+ code: 'INTERNAL_ERROR',
+ message: $e->getMessage(),
+ context: ['exceptionClass' => $e::class]
+ );
+ if ($this->isJson(input: $input) === true) {
+ $output->writeln(messages: $this->commandService->encodeEnvelope(envelope: $envelope));
+ }
- if ($this->isJson(input: $input) === false) {
- $this->writeError(output: $output, message: ''.$e->getMessage().'');
- }
- } finally {
- $finished = (int) round(num: (microtime(as_float: true) * 1000));
- $this->commandService->audit(
- command: $this->stripPrefix(name: (string) $this->getName()),
- args: $this->collectArgsForAudit(input: $input),
- exitCode: $exitCode,
- durationMs: ($finished - $started),
- byUser: $this->resolveByUser()
- );
- }//end try
+ if ($this->isJson(input: $input) === false) {
+ $this->writeError(output: $output, message: '' . $e->getMessage() . '');
+ }
+ } finally {
+ $finished = (int)round(num: (microtime(as_float: true) * 1000));
+ $this->commandService->audit(
+ command: $this->stripPrefix(name: (string)$this->getName()),
+ args: $this->collectArgsForAudit(input: $input),
+ exitCode: $exitCode,
+ durationMs: ($finished - $started),
+ byUser: $this->resolveByUser()
+ );
+ }//end try
- return $exitCode;
- }//end execute()
+ return $exitCode;
+ }//end execute()
- /**
- * Whether the caller asked for JSON output.
- *
- * @param InputInterface $input The CLI input.
- *
- * @return boolean
- */
- final protected function isJson(InputInterface $input): bool
- {
- return (bool) $input->getOption(name: 'json');
- }//end isJson()
+ /**
+ * Whether the caller asked for JSON output.
+ *
+ * @param InputInterface $input The CLI input.
+ *
+ * @return boolean
+ */
+ final protected function isJson(InputInterface $input): bool {
+ return (bool)$input->getOption(name: 'json');
+ }//end isJson()
- /**
- * Whether the caller asked for quiet output.
- *
- * @param InputInterface $input The CLI input.
- *
- * @return boolean
- */
- final protected function isQuiet(InputInterface $input): bool
- {
- return (bool) $input->getOption(name: 'quiet');
- }//end isQuiet()
+ /**
+ * Whether the caller asked for quiet output.
+ *
+ * @param InputInterface $input The CLI input.
+ *
+ * @return boolean
+ */
+ final protected function isQuiet(InputInterface $input): bool {
+ return (bool)$input->getOption(name: 'quiet');
+ }//end isQuiet()
- /**
- * Whether prompts should be suppressed (CI mode).
- *
- * @param InputInterface $input The CLI input.
- *
- * @return boolean
- */
- final protected function isNoInteraction(InputInterface $input): bool
- {
- return (bool) $input->getOption(name: 'no-interaction');
- }//end isNoInteraction()
+ /**
+ * Whether prompts should be suppressed (CI mode).
+ *
+ * @param InputInterface $input The CLI input.
+ *
+ * @return boolean
+ */
+ final protected function isNoInteraction(InputInterface $input): bool {
+ return (bool)$input->getOption(name: 'no-interaction');
+ }//end isNoInteraction()
- /**
- * Emit a successful payload as either JSON envelope (when `--json`)
- * or as the supplied human-readable text (skipped when `--quiet`).
- *
- * @param InputInterface $input CLI input.
- * @param OutputInterface $output CLI output.
- * @param array|list|null $data Payload.
- * @param string $human Optional human-readable line.
- *
- * @return void
- *
- * @spec openspec/specs/cli-commands/spec.md
- */
- final protected function emitSuccess(
- InputInterface $input,
- OutputInterface $output,
- array|null $data,
- string $human=''
- ): void {
- if ($this->isJson(input: $input) === true) {
- $output->writeln(
- messages: $this->commandService->encodeEnvelope(
- envelope: $this->commandService->envelopeSuccess(data: $data)
- )
- );
- return;
- }
+ /**
+ * Emit a successful payload as either JSON envelope (when `--json`)
+ * or as the supplied human-readable text (skipped when `--quiet`).
+ *
+ * @param InputInterface $input CLI input.
+ * @param OutputInterface $output CLI output.
+ * @param array|list|null $data Payload.
+ * @param string $human Optional human-readable line.
+ *
+ * @return void
+ *
+ * @spec openspec/specs/cli-commands/spec.md
+ */
+ final protected function emitSuccess(
+ InputInterface $input,
+ OutputInterface $output,
+ ?array $data,
+ string $human = '',
+ ): void {
+ if ($this->isJson(input: $input) === true) {
+ $output->writeln(
+ messages: $this->commandService->encodeEnvelope(
+ envelope: $this->commandService->envelopeSuccess(data: $data)
+ )
+ );
+ return;
+ }
- if ($human !== '' && $this->isQuiet(input: $input) === false) {
- $output->writeln(messages: $human);
- }
- }//end emitSuccess()
+ if ($human !== '' && $this->isQuiet(input: $input) === false) {
+ $output->writeln(messages: $human);
+ }
+ }//end emitSuccess()
- /**
- * Emit an error envelope to stdout (when `--json`) or a ``
- * line on stderr (always; `--quiet` does NOT mute errors per
- * REQ-CLI-002).
- *
- * @param InputInterface $input CLI input.
- * @param OutputInterface $output CLI output.
- * @param int $exitCode Exit code constant.
- * @param string $code Stable error identifier.
- * @param string $message Human-readable text.
- * @param array|null $context Optional metadata.
- *
- * @return int Echoes back the exit code for caller convenience.
- *
- * @spec openspec/specs/cli-commands/spec.md
- */
- final protected function emitError(
- InputInterface $input,
- OutputInterface $output,
- int $exitCode,
- string $code,
- string $message,
- array|null $context=null
- ): int {
- if ($this->isJson(input: $input) === true) {
- $output->writeln(
- messages: $this->commandService->encodeEnvelope(
- envelope: $this->commandService->envelopeError(
- exitCode: $exitCode,
- code: $code,
- message: $message,
- context: $context
- )
- )
- );
+ /**
+ * Emit an error envelope to stdout (when `--json`) or a ``
+ * line on stderr (always; `--quiet` does NOT mute errors per
+ * REQ-CLI-002).
+ *
+ * @param InputInterface $input CLI input.
+ * @param OutputInterface $output CLI output.
+ * @param int $exitCode Exit code constant.
+ * @param string $code Stable error identifier.
+ * @param string $message Human-readable text.
+ * @param array|null $context Optional metadata.
+ *
+ * @return int Echoes back the exit code for caller convenience.
+ *
+ * @spec openspec/specs/cli-commands/spec.md
+ */
+ final protected function emitError(
+ InputInterface $input,
+ OutputInterface $output,
+ int $exitCode,
+ string $code,
+ string $message,
+ ?array $context = null,
+ ): int {
+ if ($this->isJson(input: $input) === true) {
+ $output->writeln(
+ messages: $this->commandService->encodeEnvelope(
+ envelope: $this->commandService->envelopeError(
+ exitCode: $exitCode,
+ code: $code,
+ message: $message,
+ context: $context
+ )
+ )
+ );
- return $exitCode;
- }
+ return $exitCode;
+ }
- $this->writeError(output: $output, message: ''.$message.'');
+ $this->writeError(output: $output, message: '' . $message . '');
- return $exitCode;
- }//end emitError()
+ return $exitCode;
+ }//end emitError()
- /**
- * Write a line to the dedicated stderr stream when the runtime
- * `OutputInterface` actually exposes one (the production
- * `ConsoleOutput` does); fall back to the regular stream otherwise
- * (in-memory test buffers don't split stderr out).
- *
- * @param OutputInterface $output Live output handle.
- * @param string $message The fully decorated message.
- *
- * @return void
- */
- private function writeError(OutputInterface $output, string $message): void
- {
- if ($output instanceof ConsoleOutputInterface) {
- $output->getErrorOutput()->writeln(messages: $message);
- return;
- }
+ /**
+ * Write a line to the dedicated stderr stream when the runtime
+ * `OutputInterface` actually exposes one (the production
+ * `ConsoleOutput` does); fall back to the regular stream otherwise
+ * (in-memory test buffers don't split stderr out).
+ *
+ * @param OutputInterface $output Live output handle.
+ * @param string $message The fully decorated message.
+ *
+ * @return void
+ */
+ private function writeError(OutputInterface $output, string $message): void {
+ if ($output instanceof ConsoleOutputInterface) {
+ $output->getErrorOutput()->writeln(messages: $message);
+ return;
+ }
- $output->writeln(messages: $message);
- }//end writeError()
+ $output->writeln(messages: $message);
+ }//end writeError()
- /**
- * Strip the canonical `launchpad:` prefix from the command name for
- * audit-log clarity (REQ-CLI-010).
- *
- * @param string $name The full command name.
- *
- * @return string
- */
- private function stripPrefix(string $name): string
- {
- if (str_starts_with(haystack: $name, needle: 'launchpad:') === true) {
- return substr(string: $name, offset: 7);
- }
+ /**
+ * Strip the canonical `launchpad:` prefix from the command name for
+ * audit-log clarity (REQ-CLI-010).
+ *
+ * @param string $name The full command name.
+ *
+ * @return string
+ */
+ private function stripPrefix(string $name): string {
+ if (str_starts_with(haystack: $name, needle: 'launchpad:') === true) {
+ return substr(string: $name, offset: 7);
+ }
- return $name;
- }//end stripPrefix()
+ return $name;
+ }//end stripPrefix()
- /**
- * Build a single space-joined argv-tail string for the audit line.
- * We use the raw `$argv` so option ordering matches what the
- * operator typed (REQ-CLI-010). The Symfony `InputInterface` does
- * not expose the original argv slice, so reading `$_SERVER['argv']`
- * is intentional here.
- *
- * @param InputInterface $input CLI input (kept for future API use).
- *
- * @return string
- *
- * @SuppressWarnings(PHPMD.Superglobals)
- * The audit line must record options in the order the operator
- * typed them (REQ-CLI-010). Symfony's `InputInterface` exposes only
- * the parsed, normalised token set — it has no accessor for the
- * original argv slice — so `$_SERVER['argv']` is the only source.
- */
- private function collectArgsForAudit(InputInterface $input): string
- {
- unset($input);
+ /**
+ * Build a single space-joined argv-tail string for the audit line.
+ * We use the raw `$argv` so option ordering matches what the
+ * operator typed (REQ-CLI-010). The Symfony `InputInterface` does
+ * not expose the original argv slice, so reading `$_SERVER['argv']`
+ * is intentional here.
+ *
+ * @param InputInterface $input CLI input (kept for future API use).
+ *
+ * @return string
+ *
+ * @SuppressWarnings(PHPMD.Superglobals)
+ * The audit line must record options in the order the operator
+ * typed them (REQ-CLI-010). Symfony's `InputInterface` exposes only
+ * the parsed, normalised token set — it has no accessor for the
+ * original argv slice — so `$_SERVER['argv']` is the only source.
+ */
+ private function collectArgsForAudit(InputInterface $input): string {
+ unset($input);
- $argv = (array) ($_SERVER['argv'] ?? []);
- // Drop the binary path and the command name (first two slots
- // when invoked via `php occ launchpad:foo ...`).
- $tail = array_slice(array: $argv, offset: 2);
+ $argv = (array)($_SERVER['argv'] ?? []);
+ // Drop the binary path and the command name (first two slots
+ // when invoked via `php occ launchpad:foo ...`).
+ $tail = array_slice(array: $argv, offset: 2);
- return implode(separator: ' ', array: array_map(callback: 'strval', array: $tail));
- }//end collectArgsForAudit()
+ return implode(separator: ' ', array: array_map(callback: 'strval', array: $tail));
+ }//end collectArgsForAudit()
- /**
- * Resolve the caller user id for the audit log. Returns `null` to
- * indicate the special `cli` sentinel when no Nextcloud session is
- * active (typical for cron / shell invocations) — REQ-CLI-010.
- *
- * @return string|null
- */
- private function resolveByUser(): string|null
- {
- try {
- $user = $this->userSession->getUser();
- if ($user === null) {
- return null;
- }
+ /**
+ * Resolve the caller user id for the audit log. Returns `null` to
+ * indicate the special `cli` sentinel when no Nextcloud session is
+ * active (typical for cron / shell invocations) — REQ-CLI-010.
+ *
+ * @return string|null
+ */
+ private function resolveByUser(): ?string {
+ try {
+ $user = $this->userSession->getUser();
+ if ($user === null) {
+ return null;
+ }
- $uid = $user->getUID();
- if ($uid === '') {
- return null;
- }
+ $uid = $user->getUID();
+ if ($uid === '') {
+ return null;
+ }
- return $uid;
- } catch (Throwable) {
- return null;
- }
- }//end resolveByUser()
+ return $uid;
+ } catch (Throwable) {
+ return null;
+ }
+ }//end resolveByUser()
}//end class
diff --git a/lib/Command/DashboardDebugShareCommand.php b/lib/Command/DashboardDebugShareCommand.php
index 505061b0..e2cf540d 100644
--- a/lib/Command/DashboardDebugShareCommand.php
+++ b/lib/Command/DashboardDebugShareCommand.php
@@ -36,118 +36,116 @@
/**
* `launchpad:dashboard:debug-share` console command.
*/
-class DashboardDebugShareCommand extends CommandBase
-{
- /**
- * Constructor.
- *
- * @param CommandService $commandService Shared CLI helper.
- * @param IUserSession $userSession Caller resolution.
- * @param DashboardMapper $dashboardMapper Dashboard mapper.
- * @param DashboardShareMapper $shareMapper Share mapper.
- */
- public function __construct(
- CommandService $commandService,
- IUserSession $userSession,
- private readonly DashboardMapper $dashboardMapper,
- private readonly DashboardShareMapper $shareMapper
- ) {
- parent::__construct(commandService: $commandService, userSession: $userSession);
- }//end __construct()
+class DashboardDebugShareCommand extends CommandBase {
+ /**
+ * Constructor.
+ *
+ * @param CommandService $commandService Shared CLI helper.
+ * @param IUserSession $userSession Caller resolution.
+ * @param DashboardMapper $dashboardMapper Dashboard mapper.
+ * @param DashboardShareMapper $shareMapper Share mapper.
+ */
+ public function __construct(
+ CommandService $commandService,
+ IUserSession $userSession,
+ private readonly DashboardMapper $dashboardMapper,
+ private readonly DashboardShareMapper $shareMapper,
+ ) {
+ parent::__construct(commandService: $commandService, userSession: $userSession);
+ }//end __construct()
- /**
- * Wire command name, description, and per-command options.
- *
- * @return void
- *
- * @spec openspec/specs/cli-commands/spec.md
- */
- protected function configureCommand(): void
- {
- $this->setName(name: 'launchpad:dashboard:debug-share')
- ->setDescription(description: 'Dump sharing & lock state for a dashboard.')
- ->setHelp(
- help: implode(
- separator: "\n",
- array: [
- 'Print share rows, lock state, version count and view count for support diagnostics.',
- '',
- 'Examples:',
- ' php occ launchpad:dashboard:debug-share a1b2c3d4-... --json',
- ' php occ launchpad:dashboard:debug-share a1b2c3d4-... | jq .',
- ]
- )
- )
- ->addArgument(
- name: 'uuid',
- mode: InputArgument::REQUIRED,
- description: 'Dashboard UUID.'
- );
- }//end configureCommand()
+ /**
+ * Wire command name, description, and per-command options.
+ *
+ * @return void
+ *
+ * @spec openspec/specs/cli-commands/spec.md
+ */
+ protected function configureCommand(): void {
+ $this->setName(name: 'launchpad:dashboard:debug-share')
+ ->setDescription(description: 'Dump sharing & lock state for a dashboard.')
+ ->setHelp(
+ help: implode(
+ separator: "\n",
+ array: [
+ 'Print share rows, lock state, version count and view count for support diagnostics.',
+ '',
+ 'Examples:',
+ ' php occ launchpad:dashboard:debug-share a1b2c3d4-... --json',
+ ' php occ launchpad:dashboard:debug-share a1b2c3d4-... | jq .',
+ ]
+ )
+ )
+ ->addArgument(
+ name: 'uuid',
+ mode: InputArgument::REQUIRED,
+ description: 'Dashboard UUID.'
+ );
+ }//end configureCommand()
- /**
- * Execute the diagnostics dump.
- *
- * @param InputInterface $input CLI input.
- * @param OutputInterface $output CLI output.
- *
- * @return int
- *
- * @spec openspec/specs/cli-commands/spec.md
- */
- protected function handle(
- InputInterface $input,
- OutputInterface $output
- ): int {
- $uuid = (string) $input->getArgument(name: 'uuid');
+ /**
+ * Execute the diagnostics dump.
+ *
+ * @param InputInterface $input CLI input.
+ * @param OutputInterface $output CLI output.
+ *
+ * @return int
+ *
+ * @spec openspec/specs/cli-commands/spec.md
+ */
+ protected function handle(
+ InputInterface $input,
+ OutputInterface $output,
+ ): int {
+ $uuid = (string)$input->getArgument(name: 'uuid');
- try {
- $dashboard = $this->dashboardMapper->findByUuid(uuid: $uuid);
- } catch (DoesNotExistException) {
- return $this->emitError(
- input: $input,
- output: $output,
- exitCode: CommandService::EXIT_NOT_FOUND,
- code: 'NOT_FOUND',
- message: 'Dashboard not found',
- context: ['uuid' => $uuid]
- );
- }
+ try {
+ $dashboard = $this->dashboardMapper->findByUuid(uuid: $uuid);
+ } catch (DoesNotExistException) {
+ return $this->emitError(
+ input: $input,
+ output: $output,
+ exitCode: CommandService::EXIT_NOT_FOUND,
+ code: 'NOT_FOUND',
+ message: 'Dashboard not found',
+ context: ['uuid' => $uuid]
+ );
+ }
- $shares = array_map(
- callback: static function (DashboardShare $share): array {
- return $share->jsonSerialize();
- },
- array: $this->shareMapper->findByDashboardId(dashboardId: (int) $dashboard->getId())
- );
+ $shares = array_map(
+ callback: static function (DashboardShare $share): array {
+ return $share->jsonSerialize();
+ },
+ array: $this->shareMapper->findByDashboardId(dashboardId: (int)$dashboard->getId())
+ );
- // Lock / version / view capabilities live in sibling specs that
- // may or may not have shipped yet; absence is reported as the
- // documented sentinel values rather than a hard failure.
- $payload = [
- 'uuid' => $uuid,
- 'shares' => $shares,
- 'locked' => false,
- 'lockedBy' => null,
- 'lockedAt' => null,
- 'versionCount' => 0,
- 'viewCount' => 0,
- ];
+ // Lock / version / view capabilities live in sibling specs that
+ // may or may not have shipped yet; absence is reported as the
+ // documented sentinel values rather than a hard failure.
+ $payload = [
+ 'uuid' => $uuid,
+ 'shares' => $shares,
+ 'locked' => false,
+ 'lockedBy' => null,
+ 'lockedAt' => null,
+ 'versionCount' => 0,
+ 'viewCount' => 0,
+ ];
- if ($this->isJson(input: $input) === true) {
- $this->emitSuccess(input: $input, output: $output, data: $payload);
- return CommandService::EXIT_SUCCESS;
- }
+ if ($this->isJson(input: $input) === true) {
+ $this->emitSuccess(input: $input, output: $output, data: $payload);
+ return CommandService::EXIT_SUCCESS;
+ }
- if ($this->isQuiet(input: $input) === false) {
- $output->writeln(
- messages: (string) json_encode(
- value: $payload,
- flags: (JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)
- )
- );
- }
+ if ($this->isQuiet(input: $input) === false) {
+ $output->writeln(
+ messages: (string)json_encode(
+ value: $payload,
+ flags: (JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)
+ )
+ );
+ }
- return CommandService::EXIT_SUCCESS;
- }//end handle()
+ return CommandService::EXIT_SUCCESS;
+ }//end handle()
}//end class
diff --git a/lib/Command/DashboardDeleteCommand.php b/lib/Command/DashboardDeleteCommand.php
index eb4f40fb..e930eae2 100644
--- a/lib/Command/DashboardDeleteCommand.php
+++ b/lib/Command/DashboardDeleteCommand.php
@@ -44,228 +44,226 @@
/**
* `launchpad:dashboard:delete` console command.
*/
-class DashboardDeleteCommand extends CommandBase
-{
- /**
- * Constructor.
- *
- * @param CommandService $commandService Shared CLI helper.
- * @param IUserSession $userSession Caller resolution.
- * @param DashboardMapper $dashboardMapper Dashboard mapper.
- * @param WidgetPlacementMapper $placementMapper Widget mapper.
- * @param DashboardTreeService $treeService Tree service for
- * cascading delete.
- * @param IEventDispatcher|null $eventDispatcher Event dispatcher for
- * DashboardDeletedEvent
- * (SB1 fix, REQ-CSC-001).
- */
- public function __construct(
- CommandService $commandService,
- IUserSession $userSession,
- private readonly DashboardMapper $dashboardMapper,
- private readonly WidgetPlacementMapper $placementMapper,
- private readonly DashboardTreeService $treeService,
- private readonly ?IEventDispatcher $eventDispatcher=null,
- ) {
- parent::__construct(commandService: $commandService, userSession: $userSession);
- }//end __construct()
+class DashboardDeleteCommand extends CommandBase {
+ /**
+ * Constructor.
+ *
+ * @param CommandService $commandService Shared CLI helper.
+ * @param IUserSession $userSession Caller resolution.
+ * @param DashboardMapper $dashboardMapper Dashboard mapper.
+ * @param WidgetPlacementMapper $placementMapper Widget mapper.
+ * @param DashboardTreeService $treeService Tree service for
+ * cascading delete.
+ * @param IEventDispatcher|null $eventDispatcher Event dispatcher for
+ * DashboardDeletedEvent
+ * (SB1 fix, REQ-CSC-001).
+ */
+ public function __construct(
+ CommandService $commandService,
+ IUserSession $userSession,
+ private readonly DashboardMapper $dashboardMapper,
+ private readonly WidgetPlacementMapper $placementMapper,
+ private readonly DashboardTreeService $treeService,
+ private readonly ?IEventDispatcher $eventDispatcher = null,
+ ) {
+ parent::__construct(commandService: $commandService, userSession: $userSession);
+ }//end __construct()
- /**
- * Wire command name, description, and per-command options.
- *
- * @return void
- *
- * @spec openspec/specs/cli-commands/spec.md
- */
- protected function configureCommand(): void
- {
- $this->setName(name: 'launchpad:dashboard:delete')
- ->setDescription(description: 'Delete a dashboard by UUID.')
- ->setHelp(
- help: implode(
- separator: "\n",
- array: [
- 'Delete a dashboard. Refuses when children exist unless --cascade is set.',
- '',
- 'Examples:',
- ' php occ launchpad:dashboard:delete a1b2c3d4-... --no-interaction',
- ' php occ launchpad:dashboard:delete a1b2c3d4-... --cascade --no-interaction',
- ]
- )
- )
- ->addArgument(
- name: 'uuid',
- mode: InputArgument::REQUIRED,
- description: 'Dashboard UUID to delete.'
- )
- ->addOption(
- name: 'cascade',
- shortcut: null,
- mode: InputOption::VALUE_NONE,
- description: 'Recursively delete child dashboards as well.'
- );
- }//end configureCommand()
+ /**
+ * Wire command name, description, and per-command options.
+ *
+ * @return void
+ *
+ * @spec openspec/specs/cli-commands/spec.md
+ */
+ protected function configureCommand(): void {
+ $this->setName(name: 'launchpad:dashboard:delete')
+ ->setDescription(description: 'Delete a dashboard by UUID.')
+ ->setHelp(
+ help: implode(
+ separator: "\n",
+ array: [
+ 'Delete a dashboard. Refuses when children exist unless --cascade is set.',
+ '',
+ 'Examples:',
+ ' php occ launchpad:dashboard:delete a1b2c3d4-... --no-interaction',
+ ' php occ launchpad:dashboard:delete a1b2c3d4-... --cascade --no-interaction',
+ ]
+ )
+ )
+ ->addArgument(
+ name: 'uuid',
+ mode: InputArgument::REQUIRED,
+ description: 'Dashboard UUID to delete.'
+ )
+ ->addOption(
+ name: 'cascade',
+ shortcut: null,
+ mode: InputOption::VALUE_NONE,
+ description: 'Recursively delete child dashboards as well.'
+ );
+ }//end configureCommand()
- /**
- * Execute the deletion.
- *
- * @param InputInterface $input CLI input.
- * @param OutputInterface $output CLI output.
- *
- * @return int
- *
- * @spec openspec/specs/cli-commands/spec.md
- */
- protected function handle(
- InputInterface $input,
- OutputInterface $output
- ): int {
- $uuid = (string) $input->getArgument(name: 'uuid');
- $cascade = (bool) $input->getOption(name: 'cascade');
+ /**
+ * Execute the deletion.
+ *
+ * @param InputInterface $input CLI input.
+ * @param OutputInterface $output CLI output.
+ *
+ * @return int
+ *
+ * @spec openspec/specs/cli-commands/spec.md
+ */
+ protected function handle(
+ InputInterface $input,
+ OutputInterface $output,
+ ): int {
+ $uuid = (string)$input->getArgument(name: 'uuid');
+ $cascade = (bool)$input->getOption(name: 'cascade');
- try {
- $dashboard = $this->dashboardMapper->findByUuid(uuid: $uuid);
- } catch (DoesNotExistException) {
- return $this->emitError(
- input: $input,
- output: $output,
- exitCode: CommandService::EXIT_NOT_FOUND,
- code: 'NOT_FOUND',
- message: 'Dashboard not found',
- context: ['uuid' => $uuid]
- );
- }
+ try {
+ $dashboard = $this->dashboardMapper->findByUuid(uuid: $uuid);
+ } catch (DoesNotExistException) {
+ return $this->emitError(
+ input: $input,
+ output: $output,
+ exitCode: CommandService::EXIT_NOT_FOUND,
+ code: 'NOT_FOUND',
+ message: 'Dashboard not found',
+ context: ['uuid' => $uuid]
+ );
+ }
- $childCount = $this->dashboardMapper->countChildrenByParent(parentUuid: $uuid);
- if ($childCount > 0 && $cascade === false) {
- return $this->emitError(
- input: $input,
- output: $output,
- exitCode: CommandService::EXIT_INVALID_ARGS,
- code: 'CHILDREN_EXIST',
- message: 'Use --cascade to also delete child dashboards',
- context: ['uuid' => $uuid, 'childCount' => $childCount]
- );
- }
+ $childCount = $this->dashboardMapper->countChildrenByParent(parentUuid: $uuid);
+ if ($childCount > 0 && $cascade === false) {
+ return $this->emitError(
+ input: $input,
+ output: $output,
+ exitCode: CommandService::EXIT_INVALID_ARGS,
+ code: 'CHILDREN_EXIST',
+ message: 'Use --cascade to also delete child dashboards',
+ context: ['uuid' => $uuid, 'childCount' => $childCount]
+ );
+ }
- if ($this->confirmDeletion(
- input: $input,
- output: $output,
- dashboard: $dashboard,
- uuid: $uuid
- ) === false
- ) {
- return $this->emitError(
- input: $input,
- output: $output,
- exitCode: CommandService::EXIT_INVALID_ARGS,
- code: 'ABORTED',
- message: 'Deletion aborted by user.'
- );
- }
+ if ($this->confirmDeletion(
+ input: $input,
+ output: $output,
+ dashboard: $dashboard,
+ uuid: $uuid
+ ) === false
+ ) {
+ return $this->emitError(
+ input: $input,
+ output: $output,
+ exitCode: CommandService::EXIT_INVALID_ARGS,
+ code: 'ABORTED',
+ message: 'Deletion aborted by user.'
+ );
+ }
- $this->applyDeletion(
- dashboard: $dashboard,
- uuid: $uuid,
- cascade: $cascade
- );
+ $this->applyDeletion(
+ dashboard: $dashboard,
+ uuid: $uuid,
+ cascade: $cascade
+ );
- $cascadeNote = '';
- if ($cascade === true) {
- $cascadeNote = ' and '.$childCount.' descendant(s)';
- }
+ $cascadeNote = '';
+ if ($cascade === true) {
+ $cascadeNote = ' and ' . $childCount . ' descendant(s)';
+ }
- $this->emitSuccess(
- input: $input,
- output: $output,
- data: ['uuid' => $uuid, 'cascade' => $cascade, 'childCount' => $childCount],
- human: 'Deleted dashboard '.$uuid.$cascadeNote
- );
+ $this->emitSuccess(
+ input: $input,
+ output: $output,
+ data: ['uuid' => $uuid, 'cascade' => $cascade, 'childCount' => $childCount],
+ human: 'Deleted dashboard ' . $uuid . $cascadeNote
+ );
- return CommandService::EXIT_SUCCESS;
- }//end handle()
+ return CommandService::EXIT_SUCCESS;
+ }//end handle()
- /**
- * Ask the operator to confirm the deletion.
- *
- * The prompt is skipped entirely — and the deletion allowed — when
- * `--no-interaction` was supplied or the caller asked for JSON
- * output, because neither mode can service a terminal question.
- *
- * @param InputInterface $input CLI input.
- * @param OutputInterface $output CLI output.
- * @param Dashboard $dashboard The dashboard being deleted (its
- * name appears in the prompt).
- * @param string $uuid The dashboard UUID.
- *
- * @return bool True when the deletion may proceed.
- */
- private function confirmDeletion(
- InputInterface $input,
- OutputInterface $output,
- Dashboard $dashboard,
- string $uuid
- ): bool {
- if ($this->isNoInteraction(input: $input) === true
- || $this->isJson(input: $input) === true
- ) {
- return true;
- }
+ /**
+ * Ask the operator to confirm the deletion.
+ *
+ * The prompt is skipped entirely — and the deletion allowed — when
+ * `--no-interaction` was supplied or the caller asked for JSON
+ * output, because neither mode can service a terminal question.
+ *
+ * @param InputInterface $input CLI input.
+ * @param OutputInterface $output CLI output.
+ * @param Dashboard $dashboard The dashboard being deleted (its
+ * name appears in the prompt).
+ * @param string $uuid The dashboard UUID.
+ *
+ * @return bool True when the deletion may proceed.
+ */
+ private function confirmDeletion(
+ InputInterface $input,
+ OutputInterface $output,
+ Dashboard $dashboard,
+ string $uuid,
+ ): bool {
+ if ($this->isNoInteraction(input: $input) === true
+ || $this->isJson(input: $input) === true
+ ) {
+ return true;
+ }
- $helper = new QuestionHelper();
- $question = new ConfirmationQuestion(
- question: sprintf(
- 'Delete dashboard "%s" (%s)? [y/N] ',
- (string) $dashboard->getName(),
- $uuid
- ),
- default: false
- );
+ $helper = new QuestionHelper();
+ $question = new ConfirmationQuestion(
+ question: sprintf(
+ 'Delete dashboard "%s" (%s)? [y/N] ',
+ (string)$dashboard->getName(),
+ $uuid
+ ),
+ default: false
+ );
- return (bool) $helper->ask(input: $input, output: $output, question: $question);
- }//end confirmDeletion()
+ return (bool)$helper->ask(input: $input, output: $output, question: $question);
+ }//end confirmDeletion()
- /**
- * Delete the dashboard, honouring the `--cascade` flag.
- *
- * The cascading path delegates to
- * {@see DashboardTreeService::deleteSubtree()}, which removes the
- * descendants and dispatches its own events. The non-cascading path
- * removes this dashboard's widget placements, deletes the row, and
- * dispatches {@see DashboardDeletedEvent} itself.
- *
- * @param Dashboard $dashboard The dashboard to delete.
- * @param string $uuid The dashboard UUID.
- * @param bool $cascade Whether `--cascade` was supplied.
- *
- * @return void
- */
- private function applyDeletion(
- Dashboard $dashboard,
- string $uuid,
- bool $cascade
- ): void {
- if ($cascade === true) {
- $this->treeService->deleteSubtree(dashboard: $dashboard);
- return;
- }
+ /**
+ * Delete the dashboard, honouring the `--cascade` flag.
+ *
+ * The cascading path delegates to
+ * {@see DashboardTreeService::deleteSubtree()}, which removes the
+ * descendants and dispatches its own events. The non-cascading path
+ * removes this dashboard's widget placements, deletes the row, and
+ * dispatches {@see DashboardDeletedEvent} itself.
+ *
+ * @param Dashboard $dashboard The dashboard to delete.
+ * @param string $uuid The dashboard UUID.
+ * @param bool $cascade Whether `--cascade` was supplied.
+ *
+ * @return void
+ */
+ private function applyDeletion(
+ Dashboard $dashboard,
+ string $uuid,
+ bool $cascade,
+ ): void {
+ if ($cascade === true) {
+ $this->treeService->deleteSubtree(dashboard: $dashboard);
+ return;
+ }
- $this->placementMapper->deleteByDashboardId(dashboardId: (int) $dashboard->getId());
- $this->dashboardMapper->delete(entity: $dashboard);
+ $this->placementMapper->deleteByDashboardId(dashboardId: (int)$dashboard->getId());
+ $this->dashboardMapper->delete(entity: $dashboard);
- // SB1 fix: dispatch DashboardDeletedEvent for cascade cleanup
- // (REQ-CSC-001).
- if ($this->eventDispatcher === null || $uuid === '') {
- return;
- }
+ // SB1 fix: dispatch DashboardDeletedEvent for cascade cleanup
+ // (REQ-CSC-001).
+ if ($this->eventDispatcher === null || $uuid === '') {
+ return;
+ }
- $this->eventDispatcher->dispatchTyped(
- new DashboardDeletedEvent(
- dashboardUuid: $uuid,
- ownerUserId: (string) ($dashboard->getUserId() ?? ''),
- type: (string) ($dashboard->getType() ?? Dashboard::TYPE_USER),
- deletedAt: new DateTimeImmutable()
- )
- );
- }//end applyDeletion()
+ $this->eventDispatcher->dispatchTyped(
+ new DashboardDeletedEvent(
+ dashboardUuid: $uuid,
+ ownerUserId: (string)($dashboard->getUserId() ?? ''),
+ type: (string)($dashboard->getType() ?? Dashboard::TYPE_USER),
+ deletedAt: new DateTimeImmutable()
+ )
+ );
+ }//end applyDeletion()
}//end class
diff --git a/lib/Command/DashboardListCommand.php b/lib/Command/DashboardListCommand.php
index cf14a1b9..184cea38 100644
--- a/lib/Command/DashboardListCommand.php
+++ b/lib/Command/DashboardListCommand.php
@@ -35,352 +35,346 @@
/**
* `launchpad:dashboard:list` console command.
*/
-class DashboardListCommand extends CommandBase
-{
- /**
- * Allowed values for `--status` (REQ-CLI-003).
- *
- * @var list
- */
- private const ALLOWED_STATUS = ['draft', 'published', 'scheduled'];
-
- /**
- * Constructor.
- *
- * @param CommandService $commandService Shared CLI helper.
- * @param IUserSession $userSession Caller resolution.
- * @param DashboardMapper $dashboardMapper Dashboard mapper.
- * @param IUserManager $userManager For `--user` validation.
- */
- public function __construct(
- CommandService $commandService,
- IUserSession $userSession,
- private readonly DashboardMapper $dashboardMapper,
- private readonly IUserManager $userManager
- ) {
- parent::__construct(commandService: $commandService, userSession: $userSession);
- }//end __construct()
-
- /**
- * Wire command name, description, and per-command options.
- *
- * @return void
- *
- * @spec openspec/specs/cli-commands/spec.md
- */
- protected function configureCommand(): void
- {
- $this->setName(name: 'launchpad:dashboard:list')
- ->setDescription(description: 'List dashboards with optional filters.')
- ->setHelp(
- help: implode(
- separator: "\n",
- array: [
- 'List LaunchPad dashboards visible on this instance.',
- '',
- 'Options:',
- ' --user= Restrict to dashboards owned by user.',
- ' --group= Restrict to group-shared dashboards for group.',
- ' --status= Filter on publication status (draft|published|scheduled).',
- '',
- 'Examples:',
- ' php occ launchpad:dashboard:list',
- ' php occ launchpad:dashboard:list --user=alice --status=published --json',
- ]
- )
- )
- ->addOption(
- name: 'user',
- shortcut: null,
- mode: InputOption::VALUE_REQUIRED,
- description: 'Filter by owning user id.'
- )
- ->addOption(
- name: 'group',
- shortcut: null,
- mode: InputOption::VALUE_REQUIRED,
- description: 'Filter by group id (group-shared dashboards).'
- )
- ->addOption(
- name: 'status',
- shortcut: null,
- mode: InputOption::VALUE_REQUIRED,
- description: 'Filter by publication status.'
- );
- }//end configureCommand()
-
- /**
- * Execute the listing.
- *
- * @param InputInterface $input CLI input.
- * @param OutputInterface $output CLI output.
- *
- * @return int
- *
- * @spec openspec/specs/cli-commands/spec.md
- */
- protected function handle(
- InputInterface $input,
- OutputInterface $output
- ): int {
- $user = $input->getOption(name: 'user');
- $group = $input->getOption(name: 'group');
- $status = $input->getOption(name: 'status');
-
- $rejection = $this->validateFilters(
- input: $input,
- output: $output,
- user: $user,
- status: $status
- );
- if ($rejection !== null) {
- return $rejection;
- }
-
- $dashboards = $this->collect(
- user: $this->optionToString(value: $user),
- group: $this->optionToString(value: $group),
- status: $this->optionToString(value: $status)
- );
-
- $rows = $this->toRows(dashboards: $dashboards);
-
- if ($this->isJson(input: $input) === true) {
- $this->emitSuccess(
- input: $input,
- output: $output,
- data: ['dashboards' => $rows, 'count' => count(value: $rows)]
- );
- return CommandService::EXIT_SUCCESS;
- }
-
- $this->writeTable(input: $input, output: $output, rows: $rows);
-
- return CommandService::EXIT_SUCCESS;
- }//end handle()
-
- /**
- * Validate the `--status` and `--user` filters.
- *
- * Returns the exit code of the emitted error envelope when a filter
- * is rejected, or `null` when both filters are acceptable (including
- * when they were not supplied at all).
- *
- * @param InputInterface $input CLI input.
- * @param OutputInterface $output CLI output.
- * @param mixed $user Raw `--user` option value.
- * @param mixed $status Raw `--status` option value.
- *
- * @return int|null The error exit code, or null when valid.
- */
- private function validateFilters(
- InputInterface $input,
- OutputInterface $output,
- mixed $user,
- mixed $status
- ): ?int {
- if ($status !== null
- && in_array(needle: (string) $status, haystack: self::ALLOWED_STATUS, strict: true) === false
- ) {
- return $this->emitError(
- input: $input,
- output: $output,
- exitCode: CommandService::EXIT_INVALID_ARGS,
- code: 'INVALID_ARGUMENT',
- message: 'Invalid --status value: '.(string) $status,
- context: ['allowed' => self::ALLOWED_STATUS]
- );
- }
-
- if ($user !== null && $this->userManager->userExists(uid: (string) $user) === false) {
- return $this->emitError(
- input: $input,
- output: $output,
- exitCode: CommandService::EXIT_NOT_FOUND,
- code: 'NOT_FOUND',
- message: 'User not found: '.(string) $user,
- context: ['userId' => (string) $user]
- );
- }
-
- return null;
- }//end validateFilters()
-
- /**
- * Normalise a raw console option to a nullable string.
- *
- * An unset option arrives as `null` and must stay `null` so the
- * collector can tell "no filter" from "filter on the empty string".
- *
- * @param mixed $value The raw option value.
- *
- * @return string|null The cast value, or null when unset.
- */
- private function optionToString(mixed $value): ?string
- {
- if ($value === null) {
- return null;
- }
-
- return (string) $value;
- }//end optionToString()
-
- /**
- * Flatten dashboards into the row shape shared by both output modes.
- *
- * @param array $dashboards The dashboards to flatten.
- *
- * @return list> The rows.
- */
- private function toRows(array $dashboards): array
- {
- return array_map(
- callback: static function (Dashboard $dashboard): array {
- return [
- 'uuid' => (string) $dashboard->getUuid(),
- 'name' => (string) $dashboard->getName(),
- 'type' => (string) $dashboard->getType(),
- 'owner' => (string) ($dashboard->getUserId() ?? ''),
- 'group' => (string) ($dashboard->getGroupId() ?? ''),
- 'publicationStatus' => (string) $dashboard->getPublicationStatus(),
- ];
- },
- array: $dashboards
- );
- }//end toRows()
-
- /**
- * Render the compact human-readable table.
- *
- * Writes nothing at all in quiet mode; writes the empty-result notice
- * when no dashboard matched; otherwise writes a header plus one line
- * per row.
- *
- * @param InputInterface $input CLI input.
- * @param OutputInterface $output CLI output.
- * @param list> $rows The rows to render.
- *
- * @return void
- */
- private function writeTable(
- InputInterface $input,
- OutputInterface $output,
- array $rows
- ): void {
- if ($this->isQuiet(input: $input) === true) {
- return;
- }
-
- if (count(value: $rows) === 0) {
- $output->writeln(messages: 'No dashboards match the supplied filters.');
- return;
- }
-
- $output->writeln(
- messages: sprintf('%-36s %-20s %-14s %-12s %s', 'UUID', 'NAME', 'TYPE', 'STATUS', 'OWNER')
- );
- foreach ($rows as $row) {
- $output->writeln(
- messages: sprintf(
- '%-36s %-20s %-14s %-12s %s',
- $row['uuid'],
- mb_strimwidth(string: $row['name'], start: 0, width: 20, trim_marker: '..'),
- $row['type'],
- $row['publicationStatus'],
- $row['owner']
- )
- );
- }
- }//end writeTable()
-
- /**
- * Collect dashboards across all relevant scopes for the supplied
- * filters. The mapper exposes scope-specific finders; we fan out
- * and apply post-filters in PHP to keep the command non-invasive
- * (no schema or mapper changes).
- *
- * @param string|null $user Optional user filter.
- * @param string|null $group Optional group filter.
- * @param string|null $status Optional publication status filter.
- *
- * @return list
- */
- private function collect(
- string|null $user,
- string|null $group,
- string|null $status
- ): array {
- $dashboards = $this->collectScope(user: $user, group: $group);
-
- if ($status === null) {
- return $dashboards;
- }
-
- return array_values(
- array: array_filter(
- array: $dashboards,
- callback: static function (Dashboard $dashboard) use ($status): bool {
- return $dashboard->getPublicationStatus() === $status;
- }
- )
- );
- }//end collect()
-
- /**
- * Pick the mapper scope that matches the supplied filters.
- *
- * The three scopes are mutually exclusive and ordered by specificity:
- * `--user` wins over `--group`, and with neither filter the whole
- * instance-wide scope is walked.
- *
- * @param string|null $user Optional user filter.
- * @param string|null $group Optional group filter.
- *
- * @return list
- */
- private function collectScope(string|null $user, string|null $group): array
- {
- if ($user !== null) {
- return array_values(array: $this->dashboardMapper->findByUserId(userId: $user));
- }
-
- if ($group !== null) {
- return array_values(array: $this->dashboardMapper->findByGroup(groupId: $group));
- }
-
- return $this->collectInstanceWide();
- }//end collectScope()
-
- /**
- * Walk every dashboard visible instance-wide: the admin templates
- * plus every root dashboard and its descendants.
- *
- * Roots without a UUID cannot be used as a descendant anchor, so
- * they are emitted on their own and their (unreachable) subtree is
- * skipped.
- *
- * @return list
- */
- private function collectInstanceWide(): array
- {
- $dashboards = [];
-
- foreach ($this->dashboardMapper->findAdminTemplates() as $dashboard) {
- $dashboards[] = $dashboard;
- }
-
- foreach ($this->dashboardMapper->findByParent(parentUuid: null) as $root) {
- $dashboards[] = $root;
- $uuid = (string) $root->getUuid();
- if ($uuid === '') {
- continue;
- }
-
- foreach ($this->dashboardMapper->findDescendants(ancestorUuid: $uuid) as $child) {
- $dashboards[] = $child;
- }
- }
-
- return $dashboards;
- }//end collectInstanceWide()
+class DashboardListCommand extends CommandBase {
+ /**
+ * Allowed values for `--status` (REQ-CLI-003).
+ *
+ * @var list
+ */
+ private const ALLOWED_STATUS = ['draft', 'published', 'scheduled'];
+
+ /**
+ * Constructor.
+ *
+ * @param CommandService $commandService Shared CLI helper.
+ * @param IUserSession $userSession Caller resolution.
+ * @param DashboardMapper $dashboardMapper Dashboard mapper.
+ * @param IUserManager $userManager For `--user` validation.
+ */
+ public function __construct(
+ CommandService $commandService,
+ IUserSession $userSession,
+ private readonly DashboardMapper $dashboardMapper,
+ private readonly IUserManager $userManager,
+ ) {
+ parent::__construct(commandService: $commandService, userSession: $userSession);
+ }//end __construct()
+
+ /**
+ * Wire command name, description, and per-command options.
+ *
+ * @return void
+ *
+ * @spec openspec/specs/cli-commands/spec.md
+ */
+ protected function configureCommand(): void {
+ $this->setName(name: 'launchpad:dashboard:list')
+ ->setDescription(description: 'List dashboards with optional filters.')
+ ->setHelp(
+ help: implode(
+ separator: "\n",
+ array: [
+ 'List LaunchPad dashboards visible on this instance.',
+ '',
+ 'Options:',
+ ' --user= Restrict to dashboards owned by user.',
+ ' --group= Restrict to group-shared dashboards for group.',
+ ' --status= Filter on publication status (draft|published|scheduled).',
+ '',
+ 'Examples:',
+ ' php occ launchpad:dashboard:list',
+ ' php occ launchpad:dashboard:list --user=alice --status=published --json',
+ ]
+ )
+ )
+ ->addOption(
+ name: 'user',
+ shortcut: null,
+ mode: InputOption::VALUE_REQUIRED,
+ description: 'Filter by owning user id.'
+ )
+ ->addOption(
+ name: 'group',
+ shortcut: null,
+ mode: InputOption::VALUE_REQUIRED,
+ description: 'Filter by group id (group-shared dashboards).'
+ )
+ ->addOption(
+ name: 'status',
+ shortcut: null,
+ mode: InputOption::VALUE_REQUIRED,
+ description: 'Filter by publication status.'
+ );
+ }//end configureCommand()
+
+ /**
+ * Execute the listing.
+ *
+ * @param InputInterface $input CLI input.
+ * @param OutputInterface $output CLI output.
+ *
+ * @return int
+ *
+ * @spec openspec/specs/cli-commands/spec.md
+ */
+ protected function handle(
+ InputInterface $input,
+ OutputInterface $output,
+ ): int {
+ $user = $input->getOption(name: 'user');
+ $group = $input->getOption(name: 'group');
+ $status = $input->getOption(name: 'status');
+
+ $rejection = $this->validateFilters(
+ input: $input,
+ output: $output,
+ user: $user,
+ status: $status
+ );
+ if ($rejection !== null) {
+ return $rejection;
+ }
+
+ $dashboards = $this->collect(
+ user: $this->optionToString(value: $user),
+ group: $this->optionToString(value: $group),
+ status: $this->optionToString(value: $status)
+ );
+
+ $rows = $this->toRows(dashboards: $dashboards);
+
+ if ($this->isJson(input: $input) === true) {
+ $this->emitSuccess(
+ input: $input,
+ output: $output,
+ data: ['dashboards' => $rows, 'count' => count(value: $rows)]
+ );
+ return CommandService::EXIT_SUCCESS;
+ }
+
+ $this->writeTable(input: $input, output: $output, rows: $rows);
+
+ return CommandService::EXIT_SUCCESS;
+ }//end handle()
+
+ /**
+ * Validate the `--status` and `--user` filters.
+ *
+ * Returns the exit code of the emitted error envelope when a filter
+ * is rejected, or `null` when both filters are acceptable (including
+ * when they were not supplied at all).
+ *
+ * @param InputInterface $input CLI input.
+ * @param OutputInterface $output CLI output.
+ * @param mixed $user Raw `--user` option value.
+ * @param mixed $status Raw `--status` option value.
+ *
+ * @return int|null The error exit code, or null when valid.
+ */
+ private function validateFilters(
+ InputInterface $input,
+ OutputInterface $output,
+ mixed $user,
+ mixed $status,
+ ): ?int {
+ if ($status !== null
+ && in_array(needle: (string)$status, haystack: self::ALLOWED_STATUS, strict: true) === false
+ ) {
+ return $this->emitError(
+ input: $input,
+ output: $output,
+ exitCode: CommandService::EXIT_INVALID_ARGS,
+ code: 'INVALID_ARGUMENT',
+ message: 'Invalid --status value: ' . (string)$status,
+ context: ['allowed' => self::ALLOWED_STATUS]
+ );
+ }
+
+ if ($user !== null && $this->userManager->userExists(uid: (string)$user) === false) {
+ return $this->emitError(
+ input: $input,
+ output: $output,
+ exitCode: CommandService::EXIT_NOT_FOUND,
+ code: 'NOT_FOUND',
+ message: 'User not found: ' . (string)$user,
+ context: ['userId' => (string)$user]
+ );
+ }
+
+ return null;
+ }//end validateFilters()
+
+ /**
+ * Normalise a raw console option to a nullable string.
+ *
+ * An unset option arrives as `null` and must stay `null` so the
+ * collector can tell "no filter" from "filter on the empty string".
+ *
+ * @param mixed $value The raw option value.
+ *
+ * @return string|null The cast value, or null when unset.
+ */
+ private function optionToString(mixed $value): ?string {
+ if ($value === null) {
+ return null;
+ }
+
+ return (string)$value;
+ }//end optionToString()
+
+ /**
+ * Flatten dashboards into the row shape shared by both output modes.
+ *
+ * @param array $dashboards The dashboards to flatten.
+ *
+ * @return list> The rows.
+ */
+ private function toRows(array $dashboards): array {
+ return array_map(
+ callback: static function (Dashboard $dashboard): array {
+ return [
+ 'uuid' => (string)$dashboard->getUuid(),
+ 'name' => (string)$dashboard->getName(),
+ 'type' => (string)$dashboard->getType(),
+ 'owner' => (string)($dashboard->getUserId() ?? ''),
+ 'group' => (string)($dashboard->getGroupId() ?? ''),
+ 'publicationStatus' => (string)$dashboard->getPublicationStatus(),
+ ];
+ },
+ array: $dashboards
+ );
+ }//end toRows()
+
+ /**
+ * Render the compact human-readable table.
+ *
+ * Writes nothing at all in quiet mode; writes the empty-result notice
+ * when no dashboard matched; otherwise writes a header plus one line
+ * per row.
+ *
+ * @param InputInterface $input CLI input.
+ * @param OutputInterface $output CLI output.
+ * @param list> $rows The rows to render.
+ *
+ * @return void
+ */
+ private function writeTable(
+ InputInterface $input,
+ OutputInterface $output,
+ array $rows,
+ ): void {
+ if ($this->isQuiet(input: $input) === true) {
+ return;
+ }
+
+ if (count(value: $rows) === 0) {
+ $output->writeln(messages: 'No dashboards match the supplied filters.');
+ return;
+ }
+
+ $output->writeln(
+ messages: sprintf('%-36s %-20s %-14s %-12s %s', 'UUID', 'NAME', 'TYPE', 'STATUS', 'OWNER')
+ );
+ foreach ($rows as $row) {
+ $output->writeln(
+ messages: sprintf(
+ '%-36s %-20s %-14s %-12s %s',
+ $row['uuid'],
+ mb_strimwidth(string: $row['name'], start: 0, width: 20, trim_marker: '..'),
+ $row['type'],
+ $row['publicationStatus'],
+ $row['owner']
+ )
+ );
+ }
+ }//end writeTable()
+
+ /**
+ * Collect dashboards across all relevant scopes for the supplied
+ * filters. The mapper exposes scope-specific finders; we fan out
+ * and apply post-filters in PHP to keep the command non-invasive
+ * (no schema or mapper changes).
+ *
+ * @param string|null $user Optional user filter.
+ * @param string|null $group Optional group filter.
+ * @param string|null $status Optional publication status filter.
+ *
+ * @return list
+ */
+ private function collect(
+ ?string $user,
+ ?string $group,
+ ?string $status,
+ ): array {
+ $dashboards = $this->collectScope(user: $user, group: $group);
+
+ if ($status === null) {
+ return $dashboards;
+ }
+
+ return array_values(
+ array: array_filter(
+ array: $dashboards,
+ callback: static function (Dashboard $dashboard) use ($status): bool {
+ return $dashboard->getPublicationStatus() === $status;
+ }
+ )
+ );
+ }//end collect()
+
+ /**
+ * Pick the mapper scope that matches the supplied filters.
+ *
+ * The three scopes are mutually exclusive and ordered by specificity:
+ * `--user` wins over `--group`, and with neither filter the whole
+ * instance-wide scope is walked.
+ *
+ * @param string|null $user Optional user filter.
+ * @param string|null $group Optional group filter.
+ *
+ * @return list
+ */
+ private function collectScope(?string $user, ?string $group): array {
+ if ($user !== null) {
+ return array_values(array: $this->dashboardMapper->findByUserId(userId: $user));
+ }
+
+ if ($group !== null) {
+ return array_values(array: $this->dashboardMapper->findByGroup(groupId: $group));
+ }
+
+ return $this->collectInstanceWide();
+ }//end collectScope()
+
+ /**
+ * Walk every dashboard visible instance-wide: the admin templates
+ * plus every root dashboard and its descendants.
+ *
+ * Roots without a UUID cannot be used as a descendant anchor, so
+ * they are emitted on their own and their (unreachable) subtree is
+ * skipped.
+ *
+ * @return list
+ */
+ private function collectInstanceWide(): array {
+ $dashboards = [];
+
+ foreach ($this->dashboardMapper->findAdminTemplates() as $dashboard) {
+ $dashboards[] = $dashboard;
+ }
+
+ foreach ($this->dashboardMapper->findByParent(parentUuid: null) as $root) {
+ $dashboards[] = $root;
+ $uuid = (string)$root->getUuid();
+ if ($uuid === '') {
+ continue;
+ }
+
+ foreach ($this->dashboardMapper->findDescendants(ancestorUuid: $uuid) as $child) {
+ $dashboards[] = $child;
+ }
+ }
+
+ return $dashboards;
+ }//end collectInstanceWide()
}//end class
diff --git a/lib/Command/DashboardShowCommand.php b/lib/Command/DashboardShowCommand.php
index dc807f4a..dd20205d 100644
--- a/lib/Command/DashboardShowCommand.php
+++ b/lib/Command/DashboardShowCommand.php
@@ -35,129 +35,127 @@
/**
* `launchpad:dashboard:show` console command.
*/
-class DashboardShowCommand extends CommandBase
-{
- /**
- * Pattern matching a UUID v4 (the format LaunchPad mints) — accepts
- * the relaxed v* variant so older fixtures still validate.
- *
- * @var string
- */
- private const UUID_REGEX = '/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i';
+class DashboardShowCommand extends CommandBase {
+ /**
+ * Pattern matching a UUID v4 (the format LaunchPad mints) — accepts
+ * the relaxed v* variant so older fixtures still validate.
+ *
+ * @var string
+ */
+ private const UUID_REGEX = '/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i';
- /**
- * Constructor.
- *
- * @param CommandService $commandService Shared CLI helper.
- * @param IUserSession $userSession Caller resolution.
- * @param DashboardMapper $dashboardMapper Dashboard mapper.
- * @param WidgetPlacementMapper $placementMapper Widget mapper.
- */
- public function __construct(
- CommandService $commandService,
- IUserSession $userSession,
- private readonly DashboardMapper $dashboardMapper,
- private readonly WidgetPlacementMapper $placementMapper
- ) {
- parent::__construct(commandService: $commandService, userSession: $userSession);
- }//end __construct()
+ /**
+ * Constructor.
+ *
+ * @param CommandService $commandService Shared CLI helper.
+ * @param IUserSession $userSession Caller resolution.
+ * @param DashboardMapper $dashboardMapper Dashboard mapper.
+ * @param WidgetPlacementMapper $placementMapper Widget mapper.
+ */
+ public function __construct(
+ CommandService $commandService,
+ IUserSession $userSession,
+ private readonly DashboardMapper $dashboardMapper,
+ private readonly WidgetPlacementMapper $placementMapper,
+ ) {
+ parent::__construct(commandService: $commandService, userSession: $userSession);
+ }//end __construct()
- /**
- * Wire command name, description, and per-command options.
- *
- * @return void
- *
- * @spec openspec/specs/cli-commands/spec.md
- */
- protected function configureCommand(): void
- {
- $this->setName(name: 'launchpad:dashboard:show')
- ->setDescription(description: 'Display full dashboard configuration.')
- ->setHelp(
- help: implode(
- separator: "\n",
- array: [
- 'Display the full configuration of a single dashboard, including the widget tree.',
- '',
- 'Examples:',
- ' php occ launchpad:dashboard:show a1b2c3d4-e5f6-4789-abcd-ef1234567890',
- ' php occ launchpad:dashboard:show a1b2c3d4-e5f6-4789-abcd-ef1234567890 --json',
- ]
- )
- )
- ->addArgument(
- name: 'uuid',
- mode: InputArgument::REQUIRED,
- description: 'The dashboard UUID.'
- );
- }//end configureCommand()
+ /**
+ * Wire command name, description, and per-command options.
+ *
+ * @return void
+ *
+ * @spec openspec/specs/cli-commands/spec.md
+ */
+ protected function configureCommand(): void {
+ $this->setName(name: 'launchpad:dashboard:show')
+ ->setDescription(description: 'Display full dashboard configuration.')
+ ->setHelp(
+ help: implode(
+ separator: "\n",
+ array: [
+ 'Display the full configuration of a single dashboard, including the widget tree.',
+ '',
+ 'Examples:',
+ ' php occ launchpad:dashboard:show a1b2c3d4-e5f6-4789-abcd-ef1234567890',
+ ' php occ launchpad:dashboard:show a1b2c3d4-e5f6-4789-abcd-ef1234567890 --json',
+ ]
+ )
+ )
+ ->addArgument(
+ name: 'uuid',
+ mode: InputArgument::REQUIRED,
+ description: 'The dashboard UUID.'
+ );
+ }//end configureCommand()
- /**
- * Execute the show.
- *
- * @param InputInterface $input CLI input.
- * @param OutputInterface $output CLI output.
- *
- * @return int
- *
- * @spec openspec/specs/cli-commands/spec.md
- */
- protected function handle(
- InputInterface $input,
- OutputInterface $output
- ): int {
- $uuid = (string) $input->getArgument(name: 'uuid');
+ /**
+ * Execute the show.
+ *
+ * @param InputInterface $input CLI input.
+ * @param OutputInterface $output CLI output.
+ *
+ * @return int
+ *
+ * @spec openspec/specs/cli-commands/spec.md
+ */
+ protected function handle(
+ InputInterface $input,
+ OutputInterface $output,
+ ): int {
+ $uuid = (string)$input->getArgument(name: 'uuid');
- if (preg_match(pattern: self::UUID_REGEX, subject: $uuid) !== 1) {
- return $this->emitError(
- input: $input,
- output: $output,
- exitCode: CommandService::EXIT_INVALID_ARGS,
- code: 'INVALID_ARGUMENT',
- message: "Invalid UUID format: '".$uuid."'",
- context: ['field' => 'uuid', 'providedValue' => $uuid]
- );
- }
+ if (preg_match(pattern: self::UUID_REGEX, subject: $uuid) !== 1) {
+ return $this->emitError(
+ input: $input,
+ output: $output,
+ exitCode: CommandService::EXIT_INVALID_ARGS,
+ code: 'INVALID_ARGUMENT',
+ message: "Invalid UUID format: '" . $uuid . "'",
+ context: ['field' => 'uuid', 'providedValue' => $uuid]
+ );
+ }
- try {
- $dashboard = $this->dashboardMapper->findByUuid(uuid: $uuid);
- } catch (DoesNotExistException) {
- return $this->emitError(
- input: $input,
- output: $output,
- exitCode: CommandService::EXIT_NOT_FOUND,
- code: 'NOT_FOUND',
- message: 'Dashboard not found',
- context: ['uuid' => $uuid]
- );
- }
+ try {
+ $dashboard = $this->dashboardMapper->findByUuid(uuid: $uuid);
+ } catch (DoesNotExistException) {
+ return $this->emitError(
+ input: $input,
+ output: $output,
+ exitCode: CommandService::EXIT_NOT_FOUND,
+ code: 'NOT_FOUND',
+ message: 'Dashboard not found',
+ context: ['uuid' => $uuid]
+ );
+ }
- $placements = array_map(
- callback: static function (WidgetPlacement $placement): array {
- return $placement->jsonSerialize();
- },
- array: $this->placementMapper->findByDashboardId(dashboardId: (int) $dashboard->getId())
- );
+ $placements = array_map(
+ callback: static function (WidgetPlacement $placement): array {
+ return $placement->jsonSerialize();
+ },
+ array: $this->placementMapper->findByDashboardId(dashboardId: (int)$dashboard->getId())
+ );
- $payload = [
- 'dashboard' => $dashboard->jsonSerialize(),
- 'widgets' => $placements,
- ];
+ $payload = [
+ 'dashboard' => $dashboard->jsonSerialize(),
+ 'widgets' => $placements,
+ ];
- if ($this->isJson(input: $input) === true) {
- $this->emitSuccess(input: $input, output: $output, data: $payload);
- return CommandService::EXIT_SUCCESS;
- }
+ if ($this->isJson(input: $input) === true) {
+ $this->emitSuccess(input: $input, output: $output, data: $payload);
+ return CommandService::EXIT_SUCCESS;
+ }
- if ($this->isQuiet(input: $input) === false) {
- $output->writeln(
- messages: (string) json_encode(
- value: $payload,
- flags: (JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)
- )
- );
- }
+ if ($this->isQuiet(input: $input) === false) {
+ $output->writeln(
+ messages: (string)json_encode(
+ value: $payload,
+ flags: (JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)
+ )
+ );
+ }
- return CommandService::EXIT_SUCCESS;
- }//end handle()
+ return CommandService::EXIT_SUCCESS;
+ }//end handle()
}//end class
diff --git a/lib/Command/DemoShowcasesInstallCommand.php b/lib/Command/DemoShowcasesInstallCommand.php
index b2644cb0..f3d6aba6 100644
--- a/lib/Command/DemoShowcasesInstallCommand.php
+++ b/lib/Command/DemoShowcasesInstallCommand.php
@@ -37,101 +37,99 @@
/**
* `launchpad:demo-showcases:install` console command.
*/
-class DemoShowcasesInstallCommand extends Command
-{
- /**
- * Constructor.
- *
- * @param DemoShowcasesService $showcases Showcase service.
- */
- public function __construct(
- private readonly DemoShowcasesService $showcases,
- ) {
- parent::__construct();
- }//end __construct()
+class DemoShowcasesInstallCommand extends Command {
+ /**
+ * Constructor.
+ *
+ * @param DemoShowcasesService $showcases Showcase service.
+ */
+ public function __construct(
+ private readonly DemoShowcasesService $showcases,
+ ) {
+ parent::__construct();
+ }//end __construct()
- /**
- * Configure CLI options.
- *
- * @return void
- *
- * @spec openspec/specs/demo-data-showcases/spec.md
- */
- protected function configure(): void
- {
- $this->setName(name: 'launchpad:demo-showcases:install')
- ->setDescription(description: 'Install a bundled LaunchPad demo showcase dashboard.')
- ->addArgument(
- name: 'id',
- mode: InputArgument::REQUIRED,
- description: 'Showcase ID (e.g. de-bron, gemeente-duin).'
- )
- ->addOption(
- name: 'lang',
- shortcut: null,
- mode: InputOption::VALUE_REQUIRED,
- description: 'Locale (forward-compatible; v1 always resolves to nl).',
- default: 'nl'
- )
- ->addOption(
- name: 'force',
- shortcut: 'f',
- mode: InputOption::VALUE_NONE,
- description: 'Reinstall even if the showcase is already installed.'
- );
- }//end configure()
+ /**
+ * Configure CLI options.
+ *
+ * @return void
+ *
+ * @spec openspec/specs/demo-data-showcases/spec.md
+ */
+ protected function configure(): void {
+ $this->setName(name: 'launchpad:demo-showcases:install')
+ ->setDescription(description: 'Install a bundled LaunchPad demo showcase dashboard.')
+ ->addArgument(
+ name: 'id',
+ mode: InputArgument::REQUIRED,
+ description: 'Showcase ID (e.g. de-bron, gemeente-duin).'
+ )
+ ->addOption(
+ name: 'lang',
+ shortcut: null,
+ mode: InputOption::VALUE_REQUIRED,
+ description: 'Locale (forward-compatible; v1 always resolves to nl).',
+ default: 'nl'
+ )
+ ->addOption(
+ name: 'force',
+ shortcut: 'f',
+ mode: InputOption::VALUE_NONE,
+ description: 'Reinstall even if the showcase is already installed.'
+ );
+ }//end configure()
- /**
- * Execute the command.
- *
- * @param InputInterface $input CLI input.
- * @param OutputInterface $output CLI output.
- *
- * @return int Exit code.
- *
- * @spec openspec/specs/demo-data-showcases/spec.md
- */
- protected function execute(
- InputInterface $input,
- OutputInterface $output
- ): int {
- $id = (string) $input->getArgument(name: 'id');
- $lang = (string) ($input->getOption(name: 'lang') ?? 'nl');
- $force = (bool) $input->getOption(name: 'force');
+ /**
+ * Execute the command.
+ *
+ * @param InputInterface $input CLI input.
+ * @param OutputInterface $output CLI output.
+ *
+ * @return int Exit code.
+ *
+ * @spec openspec/specs/demo-data-showcases/spec.md
+ */
+ protected function execute(
+ InputInterface $input,
+ OutputInterface $output,
+ ): int {
+ $id = (string)$input->getArgument(name: 'id');
+ $lang = (string)($input->getOption(name: 'lang') ?? 'nl');
+ $force = (bool)$input->getOption(name: 'force');
- try {
- $result = $this->showcases->installShowcase(
- showcaseId: $id,
- lang: $lang,
- force: $force
- );
- } catch (ShowcaseNotFoundException) {
- $output->writeln(messages: 'Showcase not found: '.$id.'');
- return self::FAILURE;
- } catch (Throwable $e) {
- $output->writeln(messages: 'Installation failed: '.$e->getMessage().'');
- return self::FAILURE;
- }
+ try {
+ $result = $this->showcases->installShowcase(
+ showcaseId: $id,
+ lang: $lang,
+ force: $force
+ );
+ } catch (ShowcaseNotFoundException) {
+ $output->writeln(messages: 'Showcase not found: ' . $id . '');
+ return self::FAILURE;
+ } catch (Throwable $e) {
+ $output->writeln(messages: 'Installation failed: ' . $e->getMessage() . '');
+ return self::FAILURE;
+ }
- if ($result['alreadyInstalled'] === true) {
- $output->writeln(
- messages: 'Showcase '.$id.' is already installed (UUID: '.$result['installedDashboardUuid'].').'
- );
- $output->writeln(messages: 'Use --force to reinstall.');
- return self::SUCCESS;
- }
+ if ($result['alreadyInstalled'] === true) {
+ $output->writeln(
+ messages: 'Showcase ' . $id . ' is already installed (UUID: ' . $result['installedDashboardUuid'] . ').'
+ );
+ $output->writeln(messages: 'Use --force to reinstall.');
+ return self::SUCCESS;
+ }
- $output->writeln(
- messages: 'Installed dashboard '.$result['installedDashboardUuid']
- );
+ $output->writeln(
+ messages: 'Installed dashboard ' . $result['installedDashboardUuid']
+ );
- $skipped = $result['skippedWidgets'];
- if ($skipped !== []) {
- $output->writeln(
- messages: 'Skipped unknown widgets: '.implode(separator: ', ', array: $skipped).''
- );
- }
+ $skipped = $result['skippedWidgets'];
+ if ($skipped !== []) {
+ $output->writeln(
+ messages: 'Skipped unknown widgets: ' . implode(separator: ', ', array: $skipped) . ''
+ );
+ }
- return self::SUCCESS;
- }//end execute()
+ return self::SUCCESS;
+ }//end execute()
}//end class
diff --git a/lib/Command/DemoShowcasesListCommand.php b/lib/Command/DemoShowcasesListCommand.php
index fce2722e..a0417a60 100644
--- a/lib/Command/DemoShowcasesListCommand.php
+++ b/lib/Command/DemoShowcasesListCommand.php
@@ -34,85 +34,83 @@
/**
* `launchpad:demo-showcases:list` console command.
*/
-class DemoShowcasesListCommand extends Command
-{
- /**
- * Constructor.
- *
- * @param DemoShowcasesService $showcases Showcase service.
- */
- public function __construct(
- private readonly DemoShowcasesService $showcases,
- ) {
- parent::__construct();
- }//end __construct()
+class DemoShowcasesListCommand extends Command {
+ /**
+ * Constructor.
+ *
+ * @param DemoShowcasesService $showcases Showcase service.
+ */
+ public function __construct(
+ private readonly DemoShowcasesService $showcases,
+ ) {
+ parent::__construct();
+ }//end __construct()
- /**
- * Configure CLI options.
- *
- * @return void
- *
- * @spec openspec/specs/demo-data-showcases/spec.md
- */
- protected function configure(): void
- {
- $this->setName(name: 'launchpad:demo-showcases:list')
- ->setDescription(description: 'List every bundled LaunchPad demo showcase.')
- ->addOption(
- name: 'json',
- shortcut: null,
- mode: InputOption::VALUE_NONE,
- description: 'Emit machine-parseable JSON instead of a table.'
- );
- }//end configure()
+ /**
+ * Configure CLI options.
+ *
+ * @return void
+ *
+ * @spec openspec/specs/demo-data-showcases/spec.md
+ */
+ protected function configure(): void {
+ $this->setName(name: 'launchpad:demo-showcases:list')
+ ->setDescription(description: 'List every bundled LaunchPad demo showcase.')
+ ->addOption(
+ name: 'json',
+ shortcut: null,
+ mode: InputOption::VALUE_NONE,
+ description: 'Emit machine-parseable JSON instead of a table.'
+ );
+ }//end configure()
- /**
- * Execute the command.
- *
- * @param InputInterface $input CLI input.
- * @param OutputInterface $output CLI output.
- *
- * @return int Exit code.
- *
- * @spec openspec/specs/demo-data-showcases/spec.md
- */
- protected function execute(
- InputInterface $input,
- OutputInterface $output
- ): int {
- $showcases = $this->showcases->getAvailableShowcases();
+ /**
+ * Execute the command.
+ *
+ * @param InputInterface $input CLI input.
+ * @param OutputInterface $output CLI output.
+ *
+ * @return int Exit code.
+ *
+ * @spec openspec/specs/demo-data-showcases/spec.md
+ */
+ protected function execute(
+ InputInterface $input,
+ OutputInterface $output,
+ ): int {
+ $showcases = $this->showcases->getAvailableShowcases();
- if ((bool) $input->getOption(name: 'json') === true) {
- $output->writeln(
- messages: (string) json_encode(
- value: $showcases,
- flags: (JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)
- )
- );
- return self::SUCCESS;
- }
+ if ((bool)$input->getOption(name: 'json') === true) {
+ $output->writeln(
+ messages: (string)json_encode(
+ value: $showcases,
+ flags: (JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)
+ )
+ );
+ return self::SUCCESS;
+ }
- $table = new Table(output: $output);
- $table->setHeaders(headers: ['ID', 'Name', 'Language', 'Status', 'Dashboard UUID']);
+ $table = new Table(output: $output);
+ $table->setHeaders(headers: ['ID', 'Name', 'Language', 'Status', 'Dashboard UUID']);
- foreach ($showcases as $showcase) {
- $status = 'Not installed';
- if ($showcase['isInstalled'] === true) {
- $status = 'Installed';
- }
+ foreach ($showcases as $showcase) {
+ $status = 'Not installed';
+ if ($showcase['isInstalled'] === true) {
+ $status = 'Installed';
+ }
- $table->addRow(
- row: [
- $showcase['id'],
- $showcase['name'],
- $showcase['language'],
- $status,
- (string) ($showcase['installedDashboardUuid'] ?? '-'),
- ]
- );
- }
+ $table->addRow(
+ row: [
+ $showcase['id'],
+ $showcase['name'],
+ $showcase['language'],
+ $status,
+ (string)($showcase['installedDashboardUuid'] ?? '-'),
+ ]
+ );
+ }
- $table->render();
- return self::SUCCESS;
- }//end execute()
+ $table->render();
+ return self::SUCCESS;
+ }//end execute()
}//end class
diff --git a/lib/Command/ExportCommand.php b/lib/Command/ExportCommand.php
index f748e365..15afeba2 100644
--- a/lib/Command/ExportCommand.php
+++ b/lib/Command/ExportCommand.php
@@ -37,215 +37,213 @@
/**
* `launchpad:export` console command.
*/
-class ExportCommand extends Command
-{
- /**
- * Constructor.
- *
- * @param ExportService $exportService Export service.
- * @param DashboardMapper $dashboardMapper Dashboard mapper for
- * site-scope iteration.
- */
- public function __construct(
- private readonly ExportService $exportService,
- private readonly DashboardMapper $dashboardMapper,
- ) {
- parent::__construct();
- }//end __construct()
-
- /**
- * Configure CLI options.
- *
- * @return void
- *
- * @spec openspec/specs/dashboard-export-import/spec.md
- */
- protected function configure(): void
- {
- $this->setName(name: 'launchpad:export')
- ->setDescription(description: 'Export LaunchPad dashboards to a versioned ZIP archive.')
- ->addOption(
- name: 'scope',
- shortcut: null,
- mode: InputOption::VALUE_REQUIRED,
- description: 'Export scope: "site" (default) or "dashboard".',
- default: 'site'
- )
- ->addOption(
- name: 'dashboard-uuid',
- shortcut: null,
- mode: InputOption::VALUE_REQUIRED,
- description: 'Dashboard UUID, required when --scope=dashboard.'
- )
- ->addOption(
- name: 'output',
- shortcut: 'o',
- mode: InputOption::VALUE_REQUIRED,
- description: 'Output file path for the ZIP archive.'
- );
- }//end configure()
-
- /**
- * Execute the export.
- *
- * @param InputInterface $input CLI input.
- * @param OutputInterface $output CLI output.
- *
- * @return int Exit code (0 success, 1 error).
- *
- * @spec openspec/specs/dashboard-export-import/spec.md
- */
- protected function execute(
- InputInterface $input,
- OutputInterface $output
- ): int {
- $scope = (string) $input->getOption(name: 'scope');
- $outputPath = (string) ($input->getOption(name: 'output') ?? '');
- $dashboardUid = (string) ($input->getOption(name: 'dashboard-uuid') ?? '');
-
- if ($outputPath === '') {
- $output->writeln(messages: '--output parameter is required');
- return self::FAILURE;
- }
-
- if (in_array(needle: $scope, haystack: ['site', 'dashboard'], strict: true) === false) {
- $output->writeln(
- messages: 'Unsupported scope: '.$scope.'. Use "site" or "dashboard".'
- );
- return self::FAILURE;
- }
-
- if ($scope === 'dashboard' && $dashboardUid === '') {
- $output->writeln(
- messages: '--dashboard-uuid is required when --scope=dashboard'
- );
- return self::FAILURE;
- }
-
- try {
- $count = $this->writeArchive(
- scope: $scope,
- dashboardUuid: $dashboardUid,
- outputPath: $outputPath
- );
- } catch (DoesNotExistException) {
- $output->writeln(messages: 'Dashboard not found: '.$dashboardUid.'');
- return self::FAILURE;
- } catch (Throwable $e) {
- $output->writeln(messages: 'Export failed: '.$e->getMessage().'');
- return self::FAILURE;
- }
-
- $noun = 'dashboards';
- if ($count === 1) {
- $noun = 'dashboard';
- }
-
- $output->writeln(
- messages: 'Exported '.(string) $count.' '.$noun.' to '.$outputPath
- );
- return self::SUCCESS;
- }//end execute()
-
- /**
- * Write the archive to disk by reusing the export service helpers.
- *
- * The CLI uses the same serializer the HTTP path uses; we copy the
- * temporary stream back to the requested on-disk location.
- *
- * @param string $scope The export scope.
- * @param string $dashboardUuid Dashboard UUID (when scope=dashboard).
- * @param string $outputPath Destination file path.
- *
- * @return int The dashboard count written.
- *
- * @throws DoesNotExistException When the dashboard is not found.
- */
- private function writeArchive(
- string $scope,
- string $dashboardUuid,
- string $outputPath
- ): int {
- $dashboards = $this->collectDashboards(
- scope: $scope,
- dashboardUuid: $dashboardUuid
- );
-
- $zip = new ZipArchive();
- if ($zip->open(filename: $outputPath, flags: ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
- throw new RuntimeException(message: 'Could not open ZIP archive at '.$outputPath);
- }
-
- $manifest = $this->exportService->buildManifest(
- scope: $scope,
- dashboardCount: count($dashboards),
- currentUserId: 'cli'
- );
- $zip->addFromString(
- name: 'manifest.json',
- content: (string) json_encode(
- value: $manifest,
- flags: (JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)
- )
- );
-
- foreach ($dashboards as $dashboard) {
- $payload = $this->exportService->serializeDashboard(dashboard: $dashboard);
- $uuid = (string) $dashboard->getUuid();
- if ($uuid === '') {
- continue;
- }
-
- $zip->addFromString(
- name: 'dashboards/'.$uuid.'.json',
- content: (string) json_encode(value: $payload, flags: (JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES))
- );
- $zip->addEmptyDir(dirname: 'assets/widgets/'.$uuid.'/');
- }
-
- $zip->addFromString(name: 'metadata-fields.json', content: '[]');
- $zip->addEmptyDir(dirname: 'assets/icons/');
-
- $zip->close();
-
- return count($dashboards);
- }//end writeArchive()
-
- /**
- * Resolve the dashboard collection for the requested scope.
- *
- * @param string $scope The export scope.
- * @param string $dashboardUuid Dashboard UUID (when scope=dashboard).
- *
- * @return Dashboard[] The dashboards to export.
- *
- * @throws DoesNotExistException When the dashboard is not found.
- */
- private function collectDashboards(
- string $scope,
- string $dashboardUuid
- ): array {
- if ($scope === 'dashboard') {
- return [$this->dashboardMapper->findByUuid(uuid: $dashboardUuid)];
- }
-
- $all = [];
- foreach ($this->dashboardMapper->findAdminTemplates() as $tpl) {
- $all[] = $tpl;
- }
-
- foreach ($this->dashboardMapper->findByParent(parentUuid: null) as $root) {
- $all[] = $root;
- $uuid = (string) $root->getUuid();
- if ($uuid === '') {
- continue;
- }
-
- foreach ($this->dashboardMapper->findDescendants(ancestorUuid: $uuid) as $child) {
- $all[] = $child;
- }
- }
-
- return $all;
- }//end collectDashboards()
+class ExportCommand extends Command {
+ /**
+ * Constructor.
+ *
+ * @param ExportService $exportService Export service.
+ * @param DashboardMapper $dashboardMapper Dashboard mapper for
+ * site-scope iteration.
+ */
+ public function __construct(
+ private readonly ExportService $exportService,
+ private readonly DashboardMapper $dashboardMapper,
+ ) {
+ parent::__construct();
+ }//end __construct()
+
+ /**
+ * Configure CLI options.
+ *
+ * @return void
+ *
+ * @spec openspec/specs/dashboard-export-import/spec.md
+ */
+ protected function configure(): void {
+ $this->setName(name: 'launchpad:export')
+ ->setDescription(description: 'Export LaunchPad dashboards to a versioned ZIP archive.')
+ ->addOption(
+ name: 'scope',
+ shortcut: null,
+ mode: InputOption::VALUE_REQUIRED,
+ description: 'Export scope: "site" (default) or "dashboard".',
+ default: 'site'
+ )
+ ->addOption(
+ name: 'dashboard-uuid',
+ shortcut: null,
+ mode: InputOption::VALUE_REQUIRED,
+ description: 'Dashboard UUID, required when --scope=dashboard.'
+ )
+ ->addOption(
+ name: 'output',
+ shortcut: 'o',
+ mode: InputOption::VALUE_REQUIRED,
+ description: 'Output file path for the ZIP archive.'
+ );
+ }//end configure()
+
+ /**
+ * Execute the export.
+ *
+ * @param InputInterface $input CLI input.
+ * @param OutputInterface $output CLI output.
+ *
+ * @return int Exit code (0 success, 1 error).
+ *
+ * @spec openspec/specs/dashboard-export-import/spec.md
+ */
+ protected function execute(
+ InputInterface $input,
+ OutputInterface $output,
+ ): int {
+ $scope = (string)$input->getOption(name: 'scope');
+ $outputPath = (string)($input->getOption(name: 'output') ?? '');
+ $dashboardUid = (string)($input->getOption(name: 'dashboard-uuid') ?? '');
+
+ if ($outputPath === '') {
+ $output->writeln(messages: '--output parameter is required');
+ return self::FAILURE;
+ }
+
+ if (in_array(needle: $scope, haystack: ['site', 'dashboard'], strict: true) === false) {
+ $output->writeln(
+ messages: 'Unsupported scope: ' . $scope . '. Use "site" or "dashboard".'
+ );
+ return self::FAILURE;
+ }
+
+ if ($scope === 'dashboard' && $dashboardUid === '') {
+ $output->writeln(
+ messages: '--dashboard-uuid is required when --scope=dashboard'
+ );
+ return self::FAILURE;
+ }
+
+ try {
+ $count = $this->writeArchive(
+ scope: $scope,
+ dashboardUuid: $dashboardUid,
+ outputPath: $outputPath
+ );
+ } catch (DoesNotExistException) {
+ $output->writeln(messages: 'Dashboard not found: ' . $dashboardUid . '');
+ return self::FAILURE;
+ } catch (Throwable $e) {
+ $output->writeln(messages: 'Export failed: ' . $e->getMessage() . '');
+ return self::FAILURE;
+ }
+
+ $noun = 'dashboards';
+ if ($count === 1) {
+ $noun = 'dashboard';
+ }
+
+ $output->writeln(
+ messages: 'Exported ' . (string)$count . ' ' . $noun . ' to ' . $outputPath
+ );
+ return self::SUCCESS;
+ }//end execute()
+
+ /**
+ * Write the archive to disk by reusing the export service helpers.
+ *
+ * The CLI uses the same serializer the HTTP path uses; we copy the
+ * temporary stream back to the requested on-disk location.
+ *
+ * @param string $scope The export scope.
+ * @param string $dashboardUuid Dashboard UUID (when scope=dashboard).
+ * @param string $outputPath Destination file path.
+ *
+ * @return int The dashboard count written.
+ *
+ * @throws DoesNotExistException When the dashboard is not found.
+ */
+ private function writeArchive(
+ string $scope,
+ string $dashboardUuid,
+ string $outputPath,
+ ): int {
+ $dashboards = $this->collectDashboards(
+ scope: $scope,
+ dashboardUuid: $dashboardUuid
+ );
+
+ $zip = new ZipArchive();
+ if ($zip->open(filename: $outputPath, flags: ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
+ throw new RuntimeException(message: 'Could not open ZIP archive at ' . $outputPath);
+ }
+
+ $manifest = $this->exportService->buildManifest(
+ scope: $scope,
+ dashboardCount: count($dashboards),
+ currentUserId: 'cli'
+ );
+ $zip->addFromString(
+ name: 'manifest.json',
+ content: (string)json_encode(
+ value: $manifest,
+ flags: (JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)
+ )
+ );
+
+ foreach ($dashboards as $dashboard) {
+ $payload = $this->exportService->serializeDashboard(dashboard: $dashboard);
+ $uuid = (string)$dashboard->getUuid();
+ if ($uuid === '') {
+ continue;
+ }
+
+ $zip->addFromString(
+ name: 'dashboards/' . $uuid . '.json',
+ content: (string)json_encode(value: $payload, flags: (JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES))
+ );
+ $zip->addEmptyDir(dirname: 'assets/widgets/' . $uuid . '/');
+ }
+
+ $zip->addFromString(name: 'metadata-fields.json', content: '[]');
+ $zip->addEmptyDir(dirname: 'assets/icons/');
+
+ $zip->close();
+
+ return count($dashboards);
+ }//end writeArchive()
+
+ /**
+ * Resolve the dashboard collection for the requested scope.
+ *
+ * @param string $scope The export scope.
+ * @param string $dashboardUuid Dashboard UUID (when scope=dashboard).
+ *
+ * @return Dashboard[] The dashboards to export.
+ *
+ * @throws DoesNotExistException When the dashboard is not found.
+ */
+ private function collectDashboards(
+ string $scope,
+ string $dashboardUuid,
+ ): array {
+ if ($scope === 'dashboard') {
+ return [$this->dashboardMapper->findByUuid(uuid: $dashboardUuid)];
+ }
+
+ $all = [];
+ foreach ($this->dashboardMapper->findAdminTemplates() as $tpl) {
+ $all[] = $tpl;
+ }
+
+ foreach ($this->dashboardMapper->findByParent(parentUuid: null) as $root) {
+ $all[] = $root;
+ $uuid = (string)$root->getUuid();
+ if ($uuid === '') {
+ continue;
+ }
+
+ foreach ($this->dashboardMapper->findDescendants(ancestorUuid: $uuid) as $child) {
+ $all[] = $child;
+ }
+ }
+
+ return $all;
+ }//end collectDashboards()
}//end class
diff --git a/lib/Command/I18nCopyNavigationCommand.php b/lib/Command/I18nCopyNavigationCommand.php
index 3ee06b5a..45448de6 100644
--- a/lib/Command/I18nCopyNavigationCommand.php
+++ b/lib/Command/I18nCopyNavigationCommand.php
@@ -34,128 +34,126 @@
/**
* `launchpad:i18n:copy-navigation` console command.
*/
-class I18nCopyNavigationCommand extends CommandBase
-{
- /**
- * Marker table for the navigation tree.
- *
- * @var string
- */
- private const NAV_TABLE = 'launchpad_navigation';
+class I18nCopyNavigationCommand extends CommandBase {
+ /**
+ * Marker table for the navigation tree.
+ *
+ * @var string
+ */
+ private const NAV_TABLE = 'launchpad_navigation';
- /**
- * Constructor.
- *
- * @param CommandService $commandService Shared CLI helper.
- * @param IUserSession $userSession Caller resolution.
- * @param IDBConnection $db Database connection.
- */
- public function __construct(
- CommandService $commandService,
- IUserSession $userSession,
- private readonly IDBConnection $db
- ) {
- parent::__construct(commandService: $commandService, userSession: $userSession);
- }//end __construct()
+ /**
+ * Constructor.
+ *
+ * @param CommandService $commandService Shared CLI helper.
+ * @param IUserSession $userSession Caller resolution.
+ * @param IDBConnection $db Database connection.
+ */
+ public function __construct(
+ CommandService $commandService,
+ IUserSession $userSession,
+ private readonly IDBConnection $db,
+ ) {
+ parent::__construct(commandService: $commandService, userSession: $userSession);
+ }//end __construct()
- /**
- * Wire command name, description, and per-command options.
- *
- * @return void
- *
- * @spec openspec/specs/cli-commands/spec.md
- */
- protected function configureCommand(): void
- {
- $this->setName(name: 'launchpad:i18n:copy-navigation')
- ->setDescription(description: 'Clone org-navigation between language variants.')
- ->setHelp(
- help: implode(
- separator: "\n",
- array: [
- 'Clone the entire org-navigation tree from one language variant to another.',
- 'Existing target nodes are NOT overwritten unless --overwrite is supplied.',
- '',
- 'Examples:',
- ' php occ launchpad:i18n:copy-navigation --from=nl --to=en',
- ' php occ launchpad:i18n:copy-navigation --from=nl --to=en --overwrite --json',
- ]
- )
- )
- ->addOption(
- name: 'from',
- shortcut: null,
- mode: InputOption::VALUE_REQUIRED,
- description: 'Source language code.'
- )
- ->addOption(
- name: 'to',
- shortcut: null,
- mode: InputOption::VALUE_REQUIRED,
- description: 'Target language code.'
- )
- ->addOption(
- name: 'overwrite',
- shortcut: null,
- mode: InputOption::VALUE_NONE,
- description: 'Overwrite existing target nodes that conflict.'
- );
- }//end configureCommand()
+ /**
+ * Wire command name, description, and per-command options.
+ *
+ * @return void
+ *
+ * @spec openspec/specs/cli-commands/spec.md
+ */
+ protected function configureCommand(): void {
+ $this->setName(name: 'launchpad:i18n:copy-navigation')
+ ->setDescription(description: 'Clone org-navigation between language variants.')
+ ->setHelp(
+ help: implode(
+ separator: "\n",
+ array: [
+ 'Clone the entire org-navigation tree from one language variant to another.',
+ 'Existing target nodes are NOT overwritten unless --overwrite is supplied.',
+ '',
+ 'Examples:',
+ ' php occ launchpad:i18n:copy-navigation --from=nl --to=en',
+ ' php occ launchpad:i18n:copy-navigation --from=nl --to=en --overwrite --json',
+ ]
+ )
+ )
+ ->addOption(
+ name: 'from',
+ shortcut: null,
+ mode: InputOption::VALUE_REQUIRED,
+ description: 'Source language code.'
+ )
+ ->addOption(
+ name: 'to',
+ shortcut: null,
+ mode: InputOption::VALUE_REQUIRED,
+ description: 'Target language code.'
+ )
+ ->addOption(
+ name: 'overwrite',
+ shortcut: null,
+ mode: InputOption::VALUE_NONE,
+ description: 'Overwrite existing target nodes that conflict.'
+ );
+ }//end configureCommand()
- /**
- * Execute the clone.
- *
- * @param InputInterface $input CLI input.
- * @param OutputInterface $output CLI output.
- *
- * @return int
- *
- * @spec openspec/specs/cli-commands/spec.md
- */
- protected function handle(
- InputInterface $input,
- OutputInterface $output
- ): int {
- $from = $input->getOption(name: 'from');
- $to = $input->getOption(name: 'to');
+ /**
+ * Execute the clone.
+ *
+ * @param InputInterface $input CLI input.
+ * @param OutputInterface $output CLI output.
+ *
+ * @return int
+ *
+ * @spec openspec/specs/cli-commands/spec.md
+ */
+ protected function handle(
+ InputInterface $input,
+ OutputInterface $output,
+ ): int {
+ $from = $input->getOption(name: 'from');
+ $to = $input->getOption(name: 'to');
- if ($from === null || $to === null
- || (string) $from === '' || (string) $to === ''
- ) {
- return $this->emitError(
- input: $input,
- output: $output,
- exitCode: CommandService::EXIT_INVALID_ARGS,
- code: 'INVALID_ARGUMENT',
- message: 'Both --from and --to are required',
- context: ['from' => $from, 'to' => $to]
- );
- }
+ if ($from === null || $to === null
+ || (string)$from === '' || (string)$to === ''
+ ) {
+ return $this->emitError(
+ input: $input,
+ output: $output,
+ exitCode: CommandService::EXIT_INVALID_ARGS,
+ code: 'INVALID_ARGUMENT',
+ message: 'Both --from and --to are required',
+ context: ['from' => $from, 'to' => $to]
+ );
+ }
- if ($this->db->tableExists(table: self::NAV_TABLE) === false) {
- return $this->emitError(
- input: $input,
- output: $output,
- exitCode: CommandService::EXIT_NOT_FOUND,
- code: 'NOT_FOUND',
- message: "No navigation tree found for language '".(string) $from."'",
- context: ['language' => (string) $from]
- );
- }
+ if ($this->db->tableExists(table: self::NAV_TABLE) === false) {
+ return $this->emitError(
+ input: $input,
+ output: $output,
+ exitCode: CommandService::EXIT_NOT_FOUND,
+ code: 'NOT_FOUND',
+ message: "No navigation tree found for language '" . (string)$from . "'",
+ context: ['language' => (string)$from]
+ );
+ }
- $copied = 0;
+ $copied = 0;
- $this->emitSuccess(
- input: $input,
- output: $output,
- data: [
- 'from' => (string) $from,
- 'to' => (string) $to,
- 'copied' => $copied,
- ],
- human: 'Copied '.$copied.' nodes from '.(string) $from.' to '.(string) $to
- );
+ $this->emitSuccess(
+ input: $input,
+ output: $output,
+ data: [
+ 'from' => (string)$from,
+ 'to' => (string)$to,
+ 'copied' => $copied,
+ ],
+ human: 'Copied ' . $copied . ' nodes from ' . (string)$from . ' to ' . (string)$to
+ );
- return CommandService::EXIT_SUCCESS;
- }//end handle()
+ return CommandService::EXIT_SUCCESS;
+ }//end handle()
}//end class
diff --git a/lib/Command/I18nExportStringsCommand.php b/lib/Command/I18nExportStringsCommand.php
index 6a18dc5a..3f83e040 100644
--- a/lib/Command/I18nExportStringsCommand.php
+++ b/lib/Command/I18nExportStringsCommand.php
@@ -37,180 +37,176 @@
/**
* `launchpad:i18n:export-strings` console command.
*/
-class I18nExportStringsCommand extends CommandBase
-{
- /**
- * Translation marker patterns. Each entry produces a captured
- * single- or double-quoted string literal.
- *
- * @var list
- */
- private const MARKER_PATTERNS = [
- '/->t\(\s*[\'"]([^\'"]+)[\'"]/',
- '/\bt\(\s*[\'"]([^\'"]+)[\'"]/',
- '/\bn\(\s*[\'"]([^\'"]+)[\'"]/',
- ];
-
- /**
- * Constructor.
- *
- * @param CommandService $commandService Shared CLI helper.
- * @param IUserSession $userSession Caller resolution.
- */
- public function __construct(
- CommandService $commandService,
- IUserSession $userSession
- ) {
- parent::__construct(commandService: $commandService, userSession: $userSession);
- }//end __construct()
-
- /**
- * Wire command name, description, and per-command options.
- *
- * @return void
- *
- * @spec openspec/specs/cli-commands/spec.md
- */
- protected function configureCommand(): void
- {
- $this->setName(name: 'launchpad:i18n:export-strings')
- ->setDescription(description: 'Extract translatable strings to l10n/launchpad.pot.')
- ->setHelp(
- help: implode(
- separator: "\n",
- array: [
- 'Scan lib/ and src/ for translatable strings and write them to l10n/launchpad.pot.',
- 'Idempotent — overwrites the existing POT file.',
- '',
- 'Examples:',
- ' php occ launchpad:i18n:export-strings',
- ' php occ launchpad:i18n:export-strings --json',
- ]
- )
- );
- }//end configureCommand()
-
- /**
- * Execute the extraction.
- *
- * @param InputInterface $input CLI input.
- * @param OutputInterface $output CLI output.
- *
- * @return int
- *
- * @spec openspec/specs/cli-commands/spec.md
- */
- protected function handle(
- InputInterface $input,
- OutputInterface $output
- ): int {
- $appRoot = (string) realpath(path: __DIR__.'/../..');
- if ($appRoot === '') {
- return $this->emitError(
- input: $input,
- output: $output,
- exitCode: CommandService::EXIT_ERROR,
- code: 'INTERNAL_ERROR',
- message: 'Could not resolve app root directory.'
- );
- }
-
- $strings = [];
- foreach (['lib', 'src'] as $sub) {
- $path = $appRoot.'/'.$sub;
- if (is_dir(filename: $path) === false) {
- continue;
- }
-
- $this->collectFromDir(directory: $path, sink: $strings);
- }
-
- ksort(array: $strings);
- $this->writePot(strings: array_keys(array: $strings), outPath: $appRoot.'/l10n/launchpad.pot');
-
- $this->emitSuccess(
- input: $input,
- output: $output,
- data: ['count' => count(value: $strings), 'output' => 'l10n/launchpad.pot'],
- human: 'Wrote '.count(value: $strings).' strings to l10n/launchpad.pot'
- );
-
- return CommandService::EXIT_SUCCESS;
- }//end handle()
-
- /**
- * Recursively scan `$directory` for translatable markers and
- * accumulate them into `$sink` (using the string as key for
- * dedup).
- *
- * @param string $directory Directory to scan.
- * @param array $sink Accumulator (modified by reference).
- *
- * @return void
- */
- private function collectFromDir(string $directory, array &$sink): void
- {
- $iterator = new RecursiveIteratorIterator(
- iterator: new RecursiveDirectoryIterator(
- $directory,
- RecursiveDirectoryIterator::SKIP_DOTS
- )
- );
-
- foreach ($iterator as $file) {
- if ($file->isFile() === false) {
- continue;
- }
-
- $ext = strtolower(string: $file->getExtension());
- if (in_array(needle: $ext, haystack: ['php', 'vue', 'js', 'ts'], strict: true) === false) {
- continue;
- }
-
- $contents = (string) file_get_contents(filename: $file->getPathname());
- foreach (self::MARKER_PATTERNS as $pattern) {
- $matches = [];
- $found = preg_match_all(pattern: $pattern, subject: $contents, matches: $matches);
- if ($found === false || $found === 0) {
- continue;
- }
-
- foreach ($matches[1] as $string) {
- $sink[(string) $string] = true;
- }
- }
- }//end foreach
- }//end collectFromDir()
-
- /**
- * Write the POT file at `$outPath`. Missing parent directories
- * are created.
- *
- * @param list $strings Sorted, unique source strings.
- * @param string $outPath Target file path.
- *
- * @return void
- */
- private function writePot(array $strings, string $outPath): void
- {
- $dir = dirname(path: $outPath);
- if (is_dir(filename: $dir) === false) {
- mkdir(directory: $dir, permissions: 0775, recursive: true);
- }
-
- $lines = [];
- $lines[] = '# LaunchPad translatable strings — generated by `launchpad:i18n:export-strings`.';
- $lines[] = 'msgid ""';
- $lines[] = 'msgstr ""';
- $lines[] = '"Content-Type: text/plain; charset=UTF-8\n"';
- $lines[] = '';
- foreach ($strings as $string) {
- $escaped = str_replace(search: ['\\', '"'], replace: ['\\\\', '\\"'], subject: $string);
- $lines[] = 'msgid "'.$escaped.'"';
- $lines[] = 'msgstr ""';
- $lines[] = '';
- }
-
- file_put_contents(filename: $outPath, data: implode(separator: "\n", array: $lines));
- }//end writePot()
+class I18nExportStringsCommand extends CommandBase {
+ /**
+ * Translation marker patterns. Each entry produces a captured
+ * single- or double-quoted string literal.
+ *
+ * @var list
+ */
+ private const MARKER_PATTERNS = [
+ '/->t\(\s*[\'"]([^\'"]+)[\'"]/',
+ '/\bt\(\s*[\'"]([^\'"]+)[\'"]/',
+ '/\bn\(\s*[\'"]([^\'"]+)[\'"]/',
+ ];
+
+ /**
+ * Constructor.
+ *
+ * @param CommandService $commandService Shared CLI helper.
+ * @param IUserSession $userSession Caller resolution.
+ */
+ public function __construct(
+ CommandService $commandService,
+ IUserSession $userSession,
+ ) {
+ parent::__construct(commandService: $commandService, userSession: $userSession);
+ }//end __construct()
+
+ /**
+ * Wire command name, description, and per-command options.
+ *
+ * @return void
+ *
+ * @spec openspec/specs/cli-commands/spec.md
+ */
+ protected function configureCommand(): void {
+ $this->setName(name: 'launchpad:i18n:export-strings')
+ ->setDescription(description: 'Extract translatable strings to l10n/launchpad.pot.')
+ ->setHelp(
+ help: implode(
+ separator: "\n",
+ array: [
+ 'Scan lib/ and src/ for translatable strings and write them to l10n/launchpad.pot.',
+ 'Idempotent — overwrites the existing POT file.',
+ '',
+ 'Examples:',
+ ' php occ launchpad:i18n:export-strings',
+ ' php occ launchpad:i18n:export-strings --json',
+ ]
+ )
+ );
+ }//end configureCommand()
+
+ /**
+ * Execute the extraction.
+ *
+ * @param InputInterface $input CLI input.
+ * @param OutputInterface $output CLI output.
+ *
+ * @return int
+ *
+ * @spec openspec/specs/cli-commands/spec.md
+ */
+ protected function handle(
+ InputInterface $input,
+ OutputInterface $output,
+ ): int {
+ $appRoot = (string)realpath(path: __DIR__ . '/../..');
+ if ($appRoot === '') {
+ return $this->emitError(
+ input: $input,
+ output: $output,
+ exitCode: CommandService::EXIT_ERROR,
+ code: 'INTERNAL_ERROR',
+ message: 'Could not resolve app root directory.'
+ );
+ }
+
+ $strings = [];
+ foreach (['lib', 'src'] as $sub) {
+ $path = $appRoot . '/' . $sub;
+ if (is_dir(filename: $path) === false) {
+ continue;
+ }
+
+ $this->collectFromDir(directory: $path, sink: $strings);
+ }
+
+ ksort(array: $strings);
+ $this->writePot(strings: array_keys(array: $strings), outPath: $appRoot . '/l10n/launchpad.pot');
+
+ $this->emitSuccess(
+ input: $input,
+ output: $output,
+ data: ['count' => count(value: $strings), 'output' => 'l10n/launchpad.pot'],
+ human: 'Wrote ' . count(value: $strings) . ' strings to l10n/launchpad.pot'
+ );
+
+ return CommandService::EXIT_SUCCESS;
+ }//end handle()
+
+ /**
+ * Recursively scan `$directory` for translatable markers and
+ * accumulate them into `$sink` (using the string as key for
+ * dedup).
+ *
+ * @param string $directory Directory to scan.
+ * @param array $sink Accumulator (modified by reference).
+ *
+ * @return void
+ */
+ private function collectFromDir(string $directory, array &$sink): void {
+ $iterator = new RecursiveIteratorIterator(
+ iterator: new RecursiveDirectoryIterator(
+ $directory,
+ RecursiveDirectoryIterator::SKIP_DOTS
+ )
+ );
+
+ foreach ($iterator as $file) {
+ if ($file->isFile() === false) {
+ continue;
+ }
+
+ $ext = strtolower(string: $file->getExtension());
+ if (in_array(needle: $ext, haystack: ['php', 'vue', 'js', 'ts'], strict: true) === false) {
+ continue;
+ }
+
+ $contents = (string)file_get_contents(filename: $file->getPathname());
+ foreach (self::MARKER_PATTERNS as $pattern) {
+ $matches = [];
+ $found = preg_match_all(pattern: $pattern, subject: $contents, matches: $matches);
+ if ($found === false || $found === 0) {
+ continue;
+ }
+
+ foreach ($matches[1] as $string) {
+ $sink[(string)$string] = true;
+ }
+ }
+ }//end foreach
+ }//end collectFromDir()
+
+ /**
+ * Write the POT file at `$outPath`. Missing parent directories
+ * are created.
+ *
+ * @param list $strings Sorted, unique source strings.
+ * @param string $outPath Target file path.
+ *
+ * @return void
+ */
+ private function writePot(array $strings, string $outPath): void {
+ $dir = dirname(path: $outPath);
+ if (is_dir(filename: $dir) === false) {
+ mkdir(directory: $dir, permissions: 0775, recursive: true);
+ }
+
+ $lines = [];
+ $lines[] = '# LaunchPad translatable strings — generated by `launchpad:i18n:export-strings`.';
+ $lines[] = 'msgid ""';
+ $lines[] = 'msgstr ""';
+ $lines[] = '"Content-Type: text/plain; charset=UTF-8\n"';
+ $lines[] = '';
+ foreach ($strings as $string) {
+ $escaped = str_replace(search: ['\\', '"'], replace: ['\\\\', '\\"'], subject: $string);
+ $lines[] = 'msgid "' . $escaped . '"';
+ $lines[] = 'msgstr ""';
+ $lines[] = '';
+ }
+
+ file_put_contents(filename: $outPath, data: implode(separator: "\n", array: $lines));
+ }//end writePot()
}//end class
diff --git a/lib/Command/I18nMigrateLanguageStructureCommand.php b/lib/Command/I18nMigrateLanguageStructureCommand.php
index 78f8f412..2f14fdf2 100644
--- a/lib/Command/I18nMigrateLanguageStructureCommand.php
+++ b/lib/Command/I18nMigrateLanguageStructureCommand.php
@@ -38,115 +38,113 @@
/**
* `launchpad:i18n:migrate-language-structure` console command.
*/
-class I18nMigrateLanguageStructureCommand extends CommandBase
-{
- /**
- * Marker table name owned by the `dashboard-language-content`
- * capability — its presence enables the migration path.
- *
- * @var string
- */
- private const TARGET_TABLE = 'launchpad_language_content';
+class I18nMigrateLanguageStructureCommand extends CommandBase {
+ /**
+ * Marker table name owned by the `dashboard-language-content`
+ * capability — its presence enables the migration path.
+ *
+ * @var string
+ */
+ private const TARGET_TABLE = 'launchpad_language_content';
- /**
- * Constructor.
- *
- * @param CommandService $commandService Shared CLI helper.
- * @param IUserSession $userSession Caller resolution.
- * @param IDBConnection $db Database connection.
- */
- public function __construct(
- CommandService $commandService,
- IUserSession $userSession,
- private readonly IDBConnection $db
- ) {
- parent::__construct(commandService: $commandService, userSession: $userSession);
- }//end __construct()
+ /**
+ * Constructor.
+ *
+ * @param CommandService $commandService Shared CLI helper.
+ * @param IUserSession $userSession Caller resolution.
+ * @param IDBConnection $db Database connection.
+ */
+ public function __construct(
+ CommandService $commandService,
+ IUserSession $userSession,
+ private readonly IDBConnection $db,
+ ) {
+ parent::__construct(commandService: $commandService, userSession: $userSession);
+ }//end __construct()
- /**
- * Wire command name, description, and per-command options.
- *
- * @return void
- *
- * @spec openspec/specs/cli-commands/spec.md
- */
- protected function configureCommand(): void
- {
- $this->setName(name: 'launchpad:i18n:migrate-language-structure')
- ->setDescription(description: 'Migrate flat-language rows to per-language tables.')
- ->setHelp(
- help: implode(
- separator: "\n",
- array: [
- 'One-time migration of legacy flat-language rows to the per-language-table layout.',
- 'Requires the `dashboard-language-content` capability to be installed.',
- 'Idempotent — already-migrated rows are skipped.',
- '',
- 'Examples:',
- ' php occ launchpad:i18n:migrate-language-structure --no-interaction',
- ' php occ launchpad:i18n:migrate-language-structure --json',
- ]
- )
- );
- }//end configureCommand()
+ /**
+ * Wire command name, description, and per-command options.
+ *
+ * @return void
+ *
+ * @spec openspec/specs/cli-commands/spec.md
+ */
+ protected function configureCommand(): void {
+ $this->setName(name: 'launchpad:i18n:migrate-language-structure')
+ ->setDescription(description: 'Migrate flat-language rows to per-language tables.')
+ ->setHelp(
+ help: implode(
+ separator: "\n",
+ array: [
+ 'One-time migration of legacy flat-language rows to the per-language-table layout.',
+ 'Requires the `dashboard-language-content` capability to be installed.',
+ 'Idempotent — already-migrated rows are skipped.',
+ '',
+ 'Examples:',
+ ' php occ launchpad:i18n:migrate-language-structure --no-interaction',
+ ' php occ launchpad:i18n:migrate-language-structure --json',
+ ]
+ )
+ );
+ }//end configureCommand()
- /**
- * Execute the migration.
- *
- * @param InputInterface $input CLI input.
- * @param OutputInterface $output CLI output.
- *
- * @return int
- *
- * @spec openspec/specs/cli-commands/spec.md
- */
- protected function handle(
- InputInterface $input,
- OutputInterface $output
- ): int {
- if ($this->db->tableExists(table: self::TARGET_TABLE) === false) {
- return $this->emitError(
- input: $input,
- output: $output,
- exitCode: CommandService::EXIT_ERROR,
- code: 'CAPABILITY_MISSING',
- message: 'dashboard-language-content capability is required',
- context: ['expectedTable' => self::TARGET_TABLE]
- );
- }
+ /**
+ * Execute the migration.
+ *
+ * @param InputInterface $input CLI input.
+ * @param OutputInterface $output CLI output.
+ *
+ * @return int
+ *
+ * @spec openspec/specs/cli-commands/spec.md
+ */
+ protected function handle(
+ InputInterface $input,
+ OutputInterface $output,
+ ): int {
+ if ($this->db->tableExists(table: self::TARGET_TABLE) === false) {
+ return $this->emitError(
+ input: $input,
+ output: $output,
+ exitCode: CommandService::EXIT_ERROR,
+ code: 'CAPABILITY_MISSING',
+ message: 'dashboard-language-content capability is required',
+ context: ['expectedTable' => self::TARGET_TABLE]
+ );
+ }
- if ($this->isNoInteraction(input: $input) === false
- && $this->isJson(input: $input) === false
- ) {
- $helper = new QuestionHelper();
- $question = new ConfirmationQuestion(
- question: 'Run the language-structure migration? [y/N] ',
- default: false
- );
- if ((bool) $helper->ask(input: $input, output: $output, question: $question) === false) {
- return $this->emitError(
- input: $input,
- output: $output,
- exitCode: CommandService::EXIT_INVALID_ARGS,
- code: 'ABORTED',
- message: 'Migration aborted by user.'
- );
- }
- }
+ if ($this->isNoInteraction(input: $input) === false
+ && $this->isJson(input: $input) === false
+ ) {
+ $helper = new QuestionHelper();
+ $question = new ConfirmationQuestion(
+ question: 'Run the language-structure migration? [y/N] ',
+ default: false
+ );
+ if ((bool)$helper->ask(input: $input, output: $output, question: $question) === false) {
+ return $this->emitError(
+ input: $input,
+ output: $output,
+ exitCode: CommandService::EXIT_INVALID_ARGS,
+ code: 'ABORTED',
+ message: 'Migration aborted by user.'
+ );
+ }
+ }
- // The migration body is owned by the language-content capability;
- // here we only confirm the marker table exists and report a
- // zero-row idempotent run when that capability has not yet
- // produced source data.
- $migrated = 0;
+ // The migration body is owned by the language-content capability;
+ // here we only confirm the marker table exists and report a
+ // zero-row idempotent run when that capability has not yet
+ // produced source data.
+ $migrated = 0;
- $this->emitSuccess(
- input: $input,
- output: $output,
- data: ['migrated' => $migrated, 'idempotent' => true],
- human: 'Migration finished — '.$migrated.' rows migrated.'
- );
+ $this->emitSuccess(
+ input: $input,
+ output: $output,
+ data: ['migrated' => $migrated, 'idempotent' => true],
+ human: 'Migration finished — ' . $migrated . ' rows migrated.'
+ );
- return CommandService::EXIT_SUCCESS;
- }//end handle()
+ return CommandService::EXIT_SUCCESS;
+ }//end handle()
}//end class
diff --git a/lib/Command/ImportCommand.php b/lib/Command/ImportCommand.php
index c52b23a9..f801c900 100644
--- a/lib/Command/ImportCommand.php
+++ b/lib/Command/ImportCommand.php
@@ -33,118 +33,116 @@
/**
* `launchpad:import` console command.
*/
-class ImportCommand extends Command
-{
- /**
- * Constructor.
- *
- * @param ImportService $importService Import service.
- */
- public function __construct(
- private readonly ImportService $importService,
- ) {
- parent::__construct();
- }//end __construct()
-
- /**
- * Configure CLI options.
- *
- * @return void
- *
- * @spec openspec/specs/dashboard-export-import/spec.md
- */
- protected function configure(): void
- {
- $this->setName(name: 'launchpad:import')
- ->setDescription(description: 'Import LaunchPad dashboards from a versioned ZIP archive.')
- ->addOption(
- name: 'file',
- shortcut: 'f',
- mode: InputOption::VALUE_REQUIRED,
- description: 'Path to the ZIP archive to import.'
- )
- ->addOption(
- name: 'preserve-uuids',
- shortcut: null,
- mode: InputOption::VALUE_NONE,
- description: 'Preserve dashboard UUIDs (fail on collision).'
- )
- ->addOption(
- name: 'user',
- shortcut: 'u',
- mode: InputOption::VALUE_REQUIRED,
- description: 'User ID to attribute the import to (defaults to "cli").',
- default: 'cli'
- );
- }//end configure()
-
- /**
- * Execute the import.
- *
- * @param InputInterface $input CLI input.
- * @param OutputInterface $output CLI output.
- *
- * @return int Exit code (0 success, 1 error).
- *
- * @spec openspec/specs/dashboard-export-import/spec.md
- */
- protected function execute(
- InputInterface $input,
- OutputInterface $output
- ): int {
- $file = (string) ($input->getOption(name: 'file') ?? '');
- $preserveUuids = (bool) $input->getOption(name: 'preserve-uuids');
- $user = (string) ($input->getOption(name: 'user') ?? 'cli');
-
- if ($file === '') {
- $output->writeln(messages: '--file parameter is required');
- return self::FAILURE;
- }
-
- if (file_exists(filename: $file) === false) {
- $output->writeln(messages: 'File not found: '.$file.'');
- return self::FAILURE;
- }
-
- try {
- $result = $this->importService->import(
- zipPath: $file,
- preserveUuids: $preserveUuids,
- currentUserId: $user
- );
- } catch (InvalidArgumentException $e) {
- $output->writeln(messages: ''.$e->getMessage().'');
- return self::FAILURE;
- } catch (Throwable $e) {
- $output->writeln(messages: 'Import failed: '.$e->getMessage().'');
- return self::FAILURE;
- }
-
- if ($result['status'] === ImportService::ERR_UUID_COLLISION) {
- $output->writeln(messages: 'UUID collisions detected (--preserve-uuids):');
- foreach ($result['errors'] as $err) {
- $msg = (string) ($err['message'] ?? 'collision');
- $output->writeln(messages: ' - '.$msg);
- }
-
- return self::FAILURE;
- }
-
- $imported = $result['importedDashboardCount'];
- $skipped = $result['skippedDashboardCount'];
- $errors = $result['errors'];
-
- $head = 'Imported '.(string) $imported.' dashboards, ';
- $tail = 'skipped '.(string) $skipped.', errors: '.(string) count($errors);
- $output->writeln(messages: ($head.$tail));
-
- foreach ($errors as $err) {
- $msg = (string) ($err['message'] ?? '');
- if ($msg !== '') {
- $output->writeln(messages: ' - '.$msg);
- }
- }
-
- return self::SUCCESS;
- }//end execute()
+class ImportCommand extends Command {
+ /**
+ * Constructor.
+ *
+ * @param ImportService $importService Import service.
+ */
+ public function __construct(
+ private readonly ImportService $importService,
+ ) {
+ parent::__construct();
+ }//end __construct()
+
+ /**
+ * Configure CLI options.
+ *
+ * @return void
+ *
+ * @spec openspec/specs/dashboard-export-import/spec.md
+ */
+ protected function configure(): void {
+ $this->setName(name: 'launchpad:import')
+ ->setDescription(description: 'Import LaunchPad dashboards from a versioned ZIP archive.')
+ ->addOption(
+ name: 'file',
+ shortcut: 'f',
+ mode: InputOption::VALUE_REQUIRED,
+ description: 'Path to the ZIP archive to import.'
+ )
+ ->addOption(
+ name: 'preserve-uuids',
+ shortcut: null,
+ mode: InputOption::VALUE_NONE,
+ description: 'Preserve dashboard UUIDs (fail on collision).'
+ )
+ ->addOption(
+ name: 'user',
+ shortcut: 'u',
+ mode: InputOption::VALUE_REQUIRED,
+ description: 'User ID to attribute the import to (defaults to "cli").',
+ default: 'cli'
+ );
+ }//end configure()
+
+ /**
+ * Execute the import.
+ *
+ * @param InputInterface $input CLI input.
+ * @param OutputInterface $output CLI output.
+ *
+ * @return int Exit code (0 success, 1 error).
+ *
+ * @spec openspec/specs/dashboard-export-import/spec.md
+ */
+ protected function execute(
+ InputInterface $input,
+ OutputInterface $output,
+ ): int {
+ $file = (string)($input->getOption(name: 'file') ?? '');
+ $preserveUuids = (bool)$input->getOption(name: 'preserve-uuids');
+ $user = (string)($input->getOption(name: 'user') ?? 'cli');
+
+ if ($file === '') {
+ $output->writeln(messages: '--file parameter is required');
+ return self::FAILURE;
+ }
+
+ if (file_exists(filename: $file) === false) {
+ $output->writeln(messages: 'File not found: ' . $file . '');
+ return self::FAILURE;
+ }
+
+ try {
+ $result = $this->importService->import(
+ zipPath: $file,
+ preserveUuids: $preserveUuids,
+ currentUserId: $user
+ );
+ } catch (InvalidArgumentException $e) {
+ $output->writeln(messages: '' . $e->getMessage() . '');
+ return self::FAILURE;
+ } catch (Throwable $e) {
+ $output->writeln(messages: 'Import failed: ' . $e->getMessage() . '');
+ return self::FAILURE;
+ }
+
+ if ($result['status'] === ImportService::ERR_UUID_COLLISION) {
+ $output->writeln(messages: 'UUID collisions detected (--preserve-uuids):');
+ foreach ($result['errors'] as $err) {
+ $msg = (string)($err['message'] ?? 'collision');
+ $output->writeln(messages: ' - ' . $msg);
+ }
+
+ return self::FAILURE;
+ }
+
+ $imported = $result['importedDashboardCount'];
+ $skipped = $result['skippedDashboardCount'];
+ $errors = $result['errors'];
+
+ $head = 'Imported ' . (string)$imported . ' dashboards, ';
+ $tail = 'skipped ' . (string)$skipped . ', errors: ' . (string)count($errors);
+ $output->writeln(messages: ($head . $tail));
+
+ foreach ($errors as $err) {
+ $msg = (string)($err['message'] ?? '');
+ if ($msg !== '') {
+ $output->writeln(messages: ' - ' . $msg);
+ }
+ }
+
+ return self::SUCCESS;
+ }//end execute()
}//end class
diff --git a/lib/Command/ImportConfluenceCommand.php b/lib/Command/ImportConfluenceCommand.php
index 871222cd..48925ac8 100644
--- a/lib/Command/ImportConfluenceCommand.php
+++ b/lib/Command/ImportConfluenceCommand.php
@@ -35,192 +35,189 @@
/**
* `launchpad:import:confluence` console command.
*/
-class ImportConfluenceCommand extends Command
-{
- /**
- * Constructor.
- *
- * @param ConfluenceImportService $importService Importer.
- * @param DashboardTreeService $treeService Path resolver.
- */
- public function __construct(
- private readonly ConfluenceImportService $importService,
- private readonly DashboardTreeService $treeService,
- ) {
- parent::__construct();
- }//end __construct()
-
- /**
- * Configure CLI options.
- *
- * @return void
- *
- * @spec openspec/specs/confluence-html-import/spec.md
- */
- protected function configure(): void
- {
- $this->setName(name: 'launchpad:import:confluence')
- ->setDescription(description: 'Import a Confluence HTML export ZIP into LaunchPad dashboards.')
- ->addOption(
- name: 'file',
- shortcut: 'f',
- mode: InputOption::VALUE_REQUIRED,
- description: 'Path to the Confluence HTML export ZIP archive.'
- )
- ->addOption(
- name: 'parent-path',
- shortcut: 'p',
- mode: InputOption::VALUE_REQUIRED,
- description: 'Slug-chain path under which root pages should be slotted.'
- )
- ->addOption(
- name: 'user',
- shortcut: 'u',
- mode: InputOption::VALUE_REQUIRED,
- description: 'User ID to attribute the imported dashboards to.',
- default: 'cli'
- )
- ->addOption(
- name: 'dry-run',
- shortcut: null,
- mode: InputOption::VALUE_NONE,
- description: 'Inspect the archive without creating any dashboards.'
- );
- }//end configure()
-
- /**
- * Execute the command.
- *
- * @param InputInterface $input CLI input.
- * @param OutputInterface $output CLI output.
- *
- * @return int Exit code (0 success, 1 failure).
- *
- * @spec openspec/specs/confluence-html-import/spec.md
- */
- protected function execute(
- InputInterface $input,
- OutputInterface $output
- ): int {
- $file = (string) ($input->getOption(name: 'file') ?? '');
- $parentPath = (string) ($input->getOption(name: 'parent-path') ?? '');
- $userId = (string) ($input->getOption(name: 'user') ?? 'cli');
- $isDryRun = (bool) $input->getOption(name: 'dry-run');
-
- if ($file === '') {
- $output->writeln(messages: '--file parameter is required');
- return self::FAILURE;
- }
-
- if (file_exists(filename: $file) === false) {
- $output->writeln(messages: 'File not found: '.$file.'');
- return self::FAILURE;
- }
-
- $parentUuid = null;
- if ($parentPath !== '') {
- $parent = $this->treeService->resolvePath(path: $parentPath);
- if ($parent === null) {
- $output->writeln(
- messages: 'Parent path not found: '.$parentPath.''
- );
- return self::FAILURE;
- }
-
- $parentUuid = $parent->getUuid();
- }
-
- try {
- if ($isDryRun === true) {
- return $this->runDryRun(file: $file, output: $output);
- }
-
- return $this->runImport(
- file: $file,
- userId: $userId,
- parentUuid: $parentUuid,
- output: $output
- );
- } catch (InvalidArgumentException $e) {
- $output->writeln(messages: ''.$e->getMessage().'');
- return self::FAILURE;
- } catch (Throwable $e) {
- $output->writeln(messages: 'Import failed: '.$e->getMessage().'');
- return self::FAILURE;
- }
- }//end execute()
-
- /**
- * Run a dry-run preview.
- *
- * @param string $file ZIP path.
- * @param OutputInterface $output CLI output.
- *
- * @return int Exit code.
- */
- private function runDryRun(string $file, OutputInterface $output): int
- {
- $result = $this->importService->dryRun(zipPath: $file);
-
- $summary = sprintf(
- 'Pages: %d, attachments: %d, estimated dashboards: %d, asset folder: %s',
- (int) $result['pageCount'],
- (int) $result['attachmentCount'],
- (int) $result['estimatedDashboards'],
- (string) $result['assetFolder']
- );
-
- $output->writeln(messages: $summary);
-
- foreach ($result['warnings'] as $warning) {
- $output->writeln(messages: 'warning: '.$warning.'');
- }
-
- return self::SUCCESS;
- }//end runDryRun()
-
- /**
- * Run a full import.
- *
- * @param string $file ZIP path.
- * @param string $userId Importing user UID.
- * @param string|null $parentUuid Optional parent dashboard UUID.
- * @param OutputInterface $output CLI output.
- *
- * @return int Exit code.
- */
- private function runImport(
- string $file,
- string $userId,
- ?string $parentUuid,
- OutputInterface $output
- ): int {
- $result = $this->importService->import(
- zipPath: $file,
- currentUserId: $userId,
- parentUuid: $parentUuid
- );
-
- $summary = sprintf(
- 'Imported %d dashboards, skipped %d, errors: %d, asset folder: %s',
- (int) $result['createdDashboardCount'],
- (int) $result['skippedPageCount'],
- count(value: $result['errors']),
- (string) $result['assetFolder']
- );
-
- $output->writeln(messages: $summary);
-
- foreach ($result['errors'] as $err) {
- $output->writeln(
- messages: ' - '.$err['pageId'].': '.$err['reason']
- );
- }
-
- foreach ($result['warnings'] as $warning) {
- $output->writeln(messages: 'warning: '.$warning.'');
- }
-
- return self::SUCCESS;
- }//end runImport()
+class ImportConfluenceCommand extends Command {
+ /**
+ * Constructor.
+ *
+ * @param ConfluenceImportService $importService Importer.
+ * @param DashboardTreeService $treeService Path resolver.
+ */
+ public function __construct(
+ private readonly ConfluenceImportService $importService,
+ private readonly DashboardTreeService $treeService,
+ ) {
+ parent::__construct();
+ }//end __construct()
+
+ /**
+ * Configure CLI options.
+ *
+ * @return void
+ *
+ * @spec openspec/specs/confluence-html-import/spec.md
+ */
+ protected function configure(): void {
+ $this->setName(name: 'launchpad:import:confluence')
+ ->setDescription(description: 'Import a Confluence HTML export ZIP into LaunchPad dashboards.')
+ ->addOption(
+ name: 'file',
+ shortcut: 'f',
+ mode: InputOption::VALUE_REQUIRED,
+ description: 'Path to the Confluence HTML export ZIP archive.'
+ )
+ ->addOption(
+ name: 'parent-path',
+ shortcut: 'p',
+ mode: InputOption::VALUE_REQUIRED,
+ description: 'Slug-chain path under which root pages should be slotted.'
+ )
+ ->addOption(
+ name: 'user',
+ shortcut: 'u',
+ mode: InputOption::VALUE_REQUIRED,
+ description: 'User ID to attribute the imported dashboards to.',
+ default: 'cli'
+ )
+ ->addOption(
+ name: 'dry-run',
+ shortcut: null,
+ mode: InputOption::VALUE_NONE,
+ description: 'Inspect the archive without creating any dashboards.'
+ );
+ }//end configure()
+
+ /**
+ * Execute the command.
+ *
+ * @param InputInterface $input CLI input.
+ * @param OutputInterface $output CLI output.
+ *
+ * @return int Exit code (0 success, 1 failure).
+ *
+ * @spec openspec/specs/confluence-html-import/spec.md
+ */
+ protected function execute(
+ InputInterface $input,
+ OutputInterface $output,
+ ): int {
+ $file = (string)($input->getOption(name: 'file') ?? '');
+ $parentPath = (string)($input->getOption(name: 'parent-path') ?? '');
+ $userId = (string)($input->getOption(name: 'user') ?? 'cli');
+ $isDryRun = (bool)$input->getOption(name: 'dry-run');
+
+ if ($file === '') {
+ $output->writeln(messages: '--file parameter is required');
+ return self::FAILURE;
+ }
+
+ if (file_exists(filename: $file) === false) {
+ $output->writeln(messages: 'File not found: ' . $file . '');
+ return self::FAILURE;
+ }
+
+ $parentUuid = null;
+ if ($parentPath !== '') {
+ $parent = $this->treeService->resolvePath(path: $parentPath);
+ if ($parent === null) {
+ $output->writeln(
+ messages: 'Parent path not found: ' . $parentPath . ''
+ );
+ return self::FAILURE;
+ }
+
+ $parentUuid = $parent->getUuid();
+ }
+
+ try {
+ if ($isDryRun === true) {
+ return $this->runDryRun(file: $file, output: $output);
+ }
+
+ return $this->runImport(
+ file: $file,
+ userId: $userId,
+ parentUuid: $parentUuid,
+ output: $output
+ );
+ } catch (InvalidArgumentException $e) {
+ $output->writeln(messages: '' . $e->getMessage() . '');
+ return self::FAILURE;
+ } catch (Throwable $e) {
+ $output->writeln(messages: 'Import failed: ' . $e->getMessage() . '');
+ return self::FAILURE;
+ }
+ }//end execute()
+
+ /**
+ * Run a dry-run preview.
+ *
+ * @param string $file ZIP path.
+ * @param OutputInterface $output CLI output.
+ *
+ * @return int Exit code.
+ */
+ private function runDryRun(string $file, OutputInterface $output): int {
+ $result = $this->importService->dryRun(zipPath: $file);
+
+ $summary = sprintf(
+ 'Pages: %d, attachments: %d, estimated dashboards: %d, asset folder: %s',
+ (int)$result['pageCount'],
+ (int)$result['attachmentCount'],
+ (int)$result['estimatedDashboards'],
+ (string)$result['assetFolder']
+ );
+
+ $output->writeln(messages: $summary);
+
+ foreach ($result['warnings'] as $warning) {
+ $output->writeln(messages: 'warning: ' . $warning . '');
+ }
+
+ return self::SUCCESS;
+ }//end runDryRun()
+
+ /**
+ * Run a full import.
+ *
+ * @param string $file ZIP path.
+ * @param string $userId Importing user UID.
+ * @param string|null $parentUuid Optional parent dashboard UUID.
+ * @param OutputInterface $output CLI output.
+ *
+ * @return int Exit code.
+ */
+ private function runImport(
+ string $file,
+ string $userId,
+ ?string $parentUuid,
+ OutputInterface $output,
+ ): int {
+ $result = $this->importService->import(
+ zipPath: $file,
+ currentUserId: $userId,
+ parentUuid: $parentUuid
+ );
+
+ $summary = sprintf(
+ 'Imported %d dashboards, skipped %d, errors: %d, asset folder: %s',
+ (int)$result['createdDashboardCount'],
+ (int)$result['skippedPageCount'],
+ count(value: $result['errors']),
+ (string)$result['assetFolder']
+ );
+
+ $output->writeln(messages: $summary);
+
+ foreach ($result['errors'] as $err) {
+ $output->writeln(
+ messages: ' - ' . $err['pageId'] . ': ' . $err['reason']
+ );
+ }
+
+ foreach ($result['warnings'] as $warning) {
+ $output->writeln(messages: 'warning: ' . $warning . '');
+ }
+
+ return self::SUCCESS;
+ }//end runImport()
}//end class
diff --git a/lib/Command/SetupCommand.php b/lib/Command/SetupCommand.php
index 668ac4e3..f462f93d 100644
--- a/lib/Command/SetupCommand.php
+++ b/lib/Command/SetupCommand.php
@@ -39,233 +39,229 @@
/**
* `launchpad:setup` console command.
*/
-class SetupCommand extends Command
-{
- /**
- * Constructor.
- *
- * @param SetupWizardService $wizardService Wizard orchestrator.
- * @param AdminSettingsService $settings Group-order persistence
- * (Step 3 in the YAML
- * schema).
- */
- public function __construct(
- private readonly SetupWizardService $wizardService,
- private readonly AdminSettingsService $settings,
- ) {
- parent::__construct();
- }//end __construct()
+class SetupCommand extends Command {
+ /**
+ * Constructor.
+ *
+ * @param SetupWizardService $wizardService Wizard orchestrator.
+ * @param AdminSettingsService $settings Group-order persistence
+ * (Step 3 in the YAML
+ * schema).
+ */
+ public function __construct(
+ private readonly SetupWizardService $wizardService,
+ private readonly AdminSettingsService $settings,
+ ) {
+ parent::__construct();
+ }//end __construct()
- /**
- * Configure CLI options.
- *
- * @return void
- *
- * @spec openspec/specs/setup-wizard/spec.md
- */
- protected function configure(): void
- {
- $this->setName(name: 'launchpad:setup')
- ->setDescription(
- description: 'Run the LaunchPad setup wizard non-interactively from a YAML config (REQ-WIZ-010).'
- )
- ->addOption(
- name: 'config',
- shortcut: 'c',
- mode: InputOption::VALUE_REQUIRED,
- description: 'Path to the YAML config file describing every step.'
- );
- }//end configure()
+ /**
+ * Configure CLI options.
+ *
+ * @return void
+ *
+ * @spec openspec/specs/setup-wizard/spec.md
+ */
+ protected function configure(): void {
+ $this->setName(name: 'launchpad:setup')
+ ->setDescription(
+ description: 'Run the LaunchPad setup wizard non-interactively from a YAML config (REQ-WIZ-010).'
+ )
+ ->addOption(
+ name: 'config',
+ shortcut: 'c',
+ mode: InputOption::VALUE_REQUIRED,
+ description: 'Path to the YAML config file describing every step.'
+ );
+ }//end configure()
- /**
- * Execute the wizard from a YAML file.
- *
- * @param InputInterface $input CLI input.
- * @param OutputInterface $output CLI output.
- *
- * @return int Exit code (0 success, 1 error).
- *
- * @spec openspec/specs/setup-wizard/spec.md
- */
- protected function execute(
- InputInterface $input,
- OutputInterface $output
- ): int {
- $configPath = (string) ($input->getOption(name: 'config') ?? '');
+ /**
+ * Execute the wizard from a YAML file.
+ *
+ * @param InputInterface $input CLI input.
+ * @param OutputInterface $output CLI output.
+ *
+ * @return int Exit code (0 success, 1 error).
+ *
+ * @spec openspec/specs/setup-wizard/spec.md
+ */
+ protected function execute(
+ InputInterface $input,
+ OutputInterface $output,
+ ): int {
+ $configPath = (string)($input->getOption(name: 'config') ?? '');
- if ($configPath === '') {
- $output->writeln(messages: '--config parameter is required');
- return self::FAILURE;
- }
+ if ($configPath === '') {
+ $output->writeln(messages: '--config parameter is required');
+ return self::FAILURE;
+ }
- if (file_exists(filename: $configPath) === false) {
- $output->writeln(messages: 'File not found: '.$configPath.'');
- return self::FAILURE;
- }
+ if (file_exists(filename: $configPath) === false) {
+ $output->writeln(messages: 'File not found: ' . $configPath . '');
+ return self::FAILURE;
+ }
- try {
- $config = Yaml::parseFile(filename: $configPath);
- } catch (ParseException $e) {
- $output->writeln(
- messages: 'Invalid setup.yaml: '.$e->getMessage().''
- );
- return self::FAILURE;
- }
+ try {
+ $config = Yaml::parseFile(filename: $configPath);
+ } catch (ParseException $e) {
+ $output->writeln(
+ messages: 'Invalid setup.yaml: ' . $e->getMessage() . ''
+ );
+ return self::FAILURE;
+ }
- if (is_array($config) === false) {
- $output->writeln(
- messages: 'Invalid setup.yaml: top-level structure must be a map.'
- );
- return self::FAILURE;
- }
+ if (is_array($config) === false) {
+ $output->writeln(
+ messages: 'Invalid setup.yaml: top-level structure must be a map.'
+ );
+ return self::FAILURE;
+ }
- if (isset($config['storage_backend']) === false
- || is_string($config['storage_backend']) === false
- ) {
- $output->writeln(
- messages: "Invalid setup.yaml: missing field 'storage_backend'"
- );
- return self::FAILURE;
- }
+ if (isset($config['storage_backend']) === false
+ || is_string($config['storage_backend']) === false
+ ) {
+ $output->writeln(
+ messages: "Invalid setup.yaml: missing field 'storage_backend'"
+ );
+ return self::FAILURE;
+ }
- try {
- $this->applySteps(config: $config, output: $output);
- } catch (InvalidArgumentException $e) {
- $output->writeln(messages: ''.$e->getMessage().'');
- return self::FAILURE;
- } catch (Throwable $e) {
- $output->writeln(
- messages: 'Setup failed: '.$e->getMessage().''
- );
- return self::FAILURE;
- }
+ try {
+ $this->applySteps(config: $config, output: $output);
+ } catch (InvalidArgumentException $e) {
+ $output->writeln(messages: '' . $e->getMessage() . '');
+ return self::FAILURE;
+ } catch (Throwable $e) {
+ $output->writeln(
+ messages: 'Setup failed: ' . $e->getMessage() . ''
+ );
+ return self::FAILURE;
+ }
- $this->wizardService->markWizardComplete();
- $output->writeln(messages: 'Setup wizard completed successfully.');
- return self::SUCCESS;
- }//end execute()
+ $this->wizardService->markWizardComplete();
+ $output->writeln(messages: 'Setup wizard completed successfully.');
+ return self::SUCCESS;
+ }//end execute()
- /**
- * Apply each non-Welcome step in order, logging progress + idempotency.
- *
- * @param array $config Parsed YAML config.
- * @param OutputInterface $output CLI output for progress logging.
- *
- * @return void
- */
- private function applySteps(array $config, OutputInterface $output): void
- {
- $output->writeln(messages: 'Step 1: Welcome... done');
+ /**
+ * Apply each non-Welcome step in order, logging progress + idempotency.
+ *
+ * @param array $config Parsed YAML config.
+ * @param OutputInterface $output CLI output for progress logging.
+ *
+ * @return void
+ */
+ private function applySteps(array $config, OutputInterface $output): void {
+ $output->writeln(messages: 'Step 1: Welcome... done');
- $this->applyStorageStep(config: $config, output: $output);
- $this->applyGroupOrderStep(config: $config, output: $output);
- $this->skipUnimplementedStep(
- stepNumber: 4,
- stepName: 'Demo data',
- present: array_key_exists(key: 'demo_packages', array: $config),
- output: $output
- );
- $this->skipUnimplementedStep(
- stepNumber: 5,
- stepName: 'Admin roles',
- present: array_key_exists(key: 'admin_role_group', array: $config),
- output: $output
- );
- $this->skipUnimplementedStep(
- stepNumber: 6,
- stepName: 'Footer config',
- present: array_key_exists(key: 'footer_config', array: $config),
- output: $output
- );
+ $this->applyStorageStep(config: $config, output: $output);
+ $this->applyGroupOrderStep(config: $config, output: $output);
+ $this->skipUnimplementedStep(
+ stepNumber: 4,
+ stepName: 'Demo data',
+ present: array_key_exists(key: 'demo_packages', array: $config),
+ output: $output
+ );
+ $this->skipUnimplementedStep(
+ stepNumber: 5,
+ stepName: 'Admin roles',
+ present: array_key_exists(key: 'admin_role_group', array: $config),
+ output: $output
+ );
+ $this->skipUnimplementedStep(
+ stepNumber: 6,
+ stepName: 'Footer config',
+ present: array_key_exists(key: 'footer_config', array: $config),
+ output: $output
+ );
- $output->writeln(messages: 'Step 7: Done... done');
- }//end applySteps()
+ $output->writeln(messages: 'Step 7: Done... done');
+ }//end applySteps()
- /**
- * Apply Step 2 — storage backend (REQ-WIZ-003).
- *
- * @param array $config Parsed YAML.
- * @param OutputInterface $output CLI output.
- *
- * @return void
- */
- private function applyStorageStep(array $config, OutputInterface $output): void
- {
- $current = $this->wizardService->getContentStorage();
- $target = (string) $config['storage_backend'];
+ /**
+ * Apply Step 2 — storage backend (REQ-WIZ-003).
+ *
+ * @param array $config Parsed YAML.
+ * @param OutputInterface $output CLI output.
+ *
+ * @return void
+ */
+ private function applyStorageStep(array $config, OutputInterface $output): void {
+ $current = $this->wizardService->getContentStorage();
+ $target = (string)$config['storage_backend'];
- if ($current === $target) {
- $output->writeln(
- messages: 'Step 2: Storage backend... already configured, skipping'
- );
- return;
- }
+ if ($current === $target) {
+ $output->writeln(
+ messages: 'Step 2: Storage backend... already configured, skipping'
+ );
+ return;
+ }
- $this->wizardService->setContentStorage(value: $target);
- $output->writeln(messages: 'Step 2: Storage backend... done');
- }//end applyStorageStep()
+ $this->wizardService->setContentStorage(value: $target);
+ $output->writeln(messages: 'Step 2: Storage backend... done');
+ }//end applyStorageStep()
- /**
- * Apply Step 3 — group priority order (REQ-WIZ-004).
- *
- * @param array $config Parsed YAML.
- * @param OutputInterface $output CLI output.
- *
- * @return void
- */
- private function applyGroupOrderStep(
- array $config,
- OutputInterface $output
- ): void {
- if (array_key_exists(key: 'group_priority_order', array: $config) === false) {
- $output->writeln(messages: 'Step 3: Group order... skipped (not in config)');
- return;
- }
+ /**
+ * Apply Step 3 — group priority order (REQ-WIZ-004).
+ *
+ * @param array $config Parsed YAML.
+ * @param OutputInterface $output CLI output.
+ *
+ * @return void
+ */
+ private function applyGroupOrderStep(
+ array $config,
+ OutputInterface $output,
+ ): void {
+ if (array_key_exists(key: 'group_priority_order', array: $config) === false) {
+ $output->writeln(messages: 'Step 3: Group order... skipped (not in config)');
+ return;
+ }
- $groups = $config['group_priority_order'];
- if (is_array($groups) === false) {
- throw new InvalidArgumentException(
- message: "Invalid setup.yaml: 'group_priority_order' must be a list of strings"
- );
- }
+ $groups = $config['group_priority_order'];
+ if (is_array($groups) === false) {
+ throw new InvalidArgumentException(
+ message: "Invalid setup.yaml: 'group_priority_order' must be a list of strings"
+ );
+ }
- $current = $this->settings->getGroupOrder();
- if ($current === array_values(array: $groups)) {
- $output->writeln(
- messages: 'Step 3: Group order... already configured, skipping'
- );
- return;
- }
+ $current = $this->settings->getGroupOrder();
+ if ($current === array_values(array: $groups)) {
+ $output->writeln(
+ messages: 'Step 3: Group order... already configured, skipping'
+ );
+ return;
+ }
- $this->settings->setGroupOrder(groupIds: $groups);
- $output->writeln(messages: 'Step 3: Group order... done');
- }//end applyGroupOrderStep()
+ $this->settings->setGroupOrder(groupIds: $groups);
+ $output->writeln(messages: 'Step 3: Group order... done');
+ }//end applyGroupOrderStep()
- /**
- * Log a placeholder for steps whose sibling capabilities ship later.
- *
- * @param int $stepNumber Step index for the log line.
- * @param string $stepName Human-readable step name.
- * @param bool $present Whether the YAML key was provided.
- * @param OutputInterface $output CLI output.
- *
- * @return void
- */
- private function skipUnimplementedStep(
- int $stepNumber,
- string $stepName,
- bool $present,
- OutputInterface $output
- ): void {
- if ($present === false) {
- $output->writeln(
- messages: 'Step '.$stepNumber.': '.$stepName.'... skipped (not in config)'
- );
- return;
- }
+ /**
+ * Log a placeholder for steps whose sibling capabilities ship later.
+ *
+ * @param int $stepNumber Step index for the log line.
+ * @param string $stepName Human-readable step name.
+ * @param bool $present Whether the YAML key was provided.
+ * @param OutputInterface $output CLI output.
+ *
+ * @return void
+ */
+ private function skipUnimplementedStep(
+ int $stepNumber,
+ string $stepName,
+ bool $present,
+ OutputInterface $output,
+ ): void {
+ if ($present === false) {
+ $output->writeln(
+ messages: 'Step ' . $stepNumber . ': ' . $stepName . '... skipped (not in config)'
+ );
+ return;
+ }
- $output->writeln(
- messages: 'Step '.$stepNumber.': '.$stepName.'... skipped (capability pending)'
- );
- }//end skipUnimplementedStep()
+ $output->writeln(
+ messages: 'Step ' . $stepNumber . ': ' . $stepName . '... skipped (capability pending)'
+ );
+ }//end skipUnimplementedStep()
}//end class
diff --git a/lib/Controller/AcknowledgementController.php b/lib/Controller/AcknowledgementController.php
index 7d54e4eb..c9c9a128 100644
--- a/lib/Controller/AcknowledgementController.php
+++ b/lib/Controller/AcknowledgementController.php
@@ -51,289 +51,281 @@
*
* @spec openspec/changes/dashboard-acknowledgements/specs/dashboard-acknowledgements/spec.md
*/
-class AcknowledgementController extends Controller
-{
- /**
- * Constructor
- *
- * @param IRequest $request The request.
- * @param AcknowledgementService $acknowledgementService The service.
- * @param RoleService $roleService LaunchPad role gate.
- * @param IGroupManager $groupManager NC admin check.
- * @param LoggerInterface $logger PSR logger.
- * @param string|null $userId Acting user ID.
- */
- public function __construct(
- IRequest $request,
- private readonly AcknowledgementService $acknowledgementService,
- private readonly RoleService $roleService,
- private readonly IGroupManager $groupManager,
- private readonly LoggerInterface $logger,
- private readonly ?string $userId,
- ) {
- parent::__construct(
- appName: Application::APP_ID,
- request: $request
- );
- }//end __construct()
+class AcknowledgementController extends Controller {
+ /**
+ * Constructor
+ *
+ * @param IRequest $request The request.
+ * @param AcknowledgementService $acknowledgementService The service.
+ * @param RoleService $roleService LaunchPad role gate.
+ * @param IGroupManager $groupManager NC admin check.
+ * @param LoggerInterface $logger PSR logger.
+ * @param string|null $userId Acting user ID.
+ */
+ public function __construct(
+ IRequest $request,
+ private readonly AcknowledgementService $acknowledgementService,
+ private readonly RoleService $roleService,
+ private readonly IGroupManager $groupManager,
+ private readonly LoggerInterface $logger,
+ private readonly ?string $userId,
+ ) {
+ parent::__construct(
+ appName: Application::APP_ID,
+ request: $request
+ );
+ }//end __construct()
- /**
- * POST /api/acknowledgements — record the calling user's receipt.
- * Idempotent (REQ-ACK-003). A body `userId` that names another user is
- * rejected with 403 (no IDOR, ADR-005 / REQ-ACK-003).
- *
- * @return JSONResponse The stored receipt.
- *
- * @spec openspec/changes/dashboard-acknowledgements/specs/dashboard-acknowledgements/spec.md
- */
- #[NoAdminRequired]
- public function acknowledge(): JSONResponse
- {
- if ($this->userId === null) {
- return ResponseHelper::unauthorized();
- }
+ /**
+ * POST /api/acknowledgements — record the calling user's receipt.
+ * Idempotent (REQ-ACK-003). A body `userId` that names another user is
+ * rejected with 403 (no IDOR, ADR-005 / REQ-ACK-003).
+ *
+ * @return JSONResponse The stored receipt.
+ *
+ * @spec openspec/changes/dashboard-acknowledgements/specs/dashboard-acknowledgements/spec.md
+ */
+ #[NoAdminRequired]
+ public function acknowledge(): JSONResponse {
+ if ($this->userId === null) {
+ return ResponseHelper::unauthorized();
+ }
- $announcementKey = (string) $this->request->getParam(key: 'announcementKey', default: '');
- if ($announcementKey === '') {
- return new JSONResponse(
- data: ['error' => 'announcementKey is required'],
- statusCode: Http::STATUS_BAD_REQUEST
- );
- }
+ $announcementKey = (string)$this->request->getParam(key: 'announcementKey', default: '');
+ if ($announcementKey === '') {
+ return new JSONResponse(
+ data: ['error' => 'announcementKey is required'],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ }
- // Reject any attempt to acknowledge on behalf of another user
- // (REQ-ACK-003 scenario "A user cannot acknowledge on behalf of
- // another user").
- $bodyUserId = $this->request->getParam(key: 'userId');
- if ($bodyUserId !== null && (string) $bodyUserId !== $this->userId) {
- return ResponseHelper::forbidden(
- message: 'Cannot acknowledge on behalf of another user'
- );
- }
+ // Reject any attempt to acknowledge on behalf of another user
+ // (REQ-ACK-003 scenario "A user cannot acknowledge on behalf of
+ // another user").
+ $bodyUserId = $this->request->getParam(key: 'userId');
+ if ($bodyUserId !== null && (string)$bodyUserId !== $this->userId) {
+ return ResponseHelper::forbidden(
+ message: 'Cannot acknowledge on behalf of another user'
+ );
+ }
- $contentVersion = (int) $this->request->getParam(key: 'contentVersion', default: 1);
- if ($contentVersion < 1) {
- $contentVersion = 1;
- }
+ $contentVersion = (int)$this->request->getParam(key: 'contentVersion', default: 1);
+ if ($contentVersion < 1) {
+ $contentVersion = 1;
+ }
- try {
- $receipt = $this->acknowledgementService->acknowledge(
- announcementKey: $announcementKey,
- userId: $this->userId,
- contentVersion: $contentVersion
- );
- } catch (Throwable $e) {
- $this->logger->error(
- message: 'acknowledge failed: '.$e->getMessage(),
- context: ['exception' => $e]
- );
- return new JSONResponse(
- data: ['error' => 'Operation failed'],
- statusCode: Http::STATUS_INTERNAL_SERVER_ERROR
- );
- }
+ try {
+ $receipt = $this->acknowledgementService->acknowledge(
+ announcementKey: $announcementKey,
+ userId: $this->userId,
+ contentVersion: $contentVersion
+ );
+ } catch (Throwable $e) {
+ $this->logger->error(
+ message: 'acknowledge failed: ' . $e->getMessage(),
+ context: ['exception' => $e]
+ );
+ return new JSONResponse(
+ data: ['error' => 'Operation failed'],
+ statusCode: Http::STATUS_INTERNAL_SERVER_ERROR
+ );
+ }
- return ResponseHelper::success(data: $receipt->jsonSerialize());
- }//end acknowledge()
+ return ResponseHelper::success(data: $receipt->jsonSerialize());
+ }//end acknowledge()
- /**
- * GET /api/acknowledgements/pending — the current user's outstanding
- * mandatory items and count. REQ-ACK-002.
- *
- * @return JSONResponse The `{count, items}` payload.
- *
- * @spec openspec/changes/dashboard-acknowledgements/specs/dashboard-acknowledgements/spec.md
- */
- #[NoAdminRequired]
- public function pending(): JSONResponse
- {
- if ($this->userId === null) {
- return ResponseHelper::unauthorized();
- }
+ /**
+ * GET /api/acknowledgements/pending — the current user's outstanding
+ * mandatory items and count. REQ-ACK-002.
+ *
+ * @return JSONResponse The `{count, items}` payload.
+ *
+ * @spec openspec/changes/dashboard-acknowledgements/specs/dashboard-acknowledgements/spec.md
+ */
+ #[NoAdminRequired]
+ public function pending(): JSONResponse {
+ if ($this->userId === null) {
+ return ResponseHelper::unauthorized();
+ }
- try {
- $result = $this->acknowledgementService->getPending(userId: $this->userId);
- } catch (Throwable $e) {
- $this->logger->error(
- message: 'pending failed: '.$e->getMessage(),
- context: ['exception' => $e]
- );
- return new JSONResponse(
- data: ['error' => 'Operation failed'],
- statusCode: Http::STATUS_INTERNAL_SERVER_ERROR
- );
- }
+ try {
+ $result = $this->acknowledgementService->getPending(userId: $this->userId);
+ } catch (Throwable $e) {
+ $this->logger->error(
+ message: 'pending failed: ' . $e->getMessage(),
+ context: ['exception' => $e]
+ );
+ return new JSONResponse(
+ data: ['error' => 'Operation failed'],
+ statusCode: Http::STATUS_INTERNAL_SERVER_ERROR
+ );
+ }
- return ResponseHelper::success(data: $result);
- }//end pending()
+ return ResponseHelper::success(data: $result);
+ }//end pending()
- /**
- * GET /api/acknowledgements/report/{announcementKey} — the
- * audience-scoped read-receipt report. Admin / template owner only
- * (REQ-ACK-004).
- *
- * @param string $announcementKey The announcement identity.
- *
- * @return JSONResponse The report payload or 403 / 404.
- *
- * @spec openspec/changes/dashboard-acknowledgements/specs/dashboard-acknowledgements/spec.md
- */
- #[NoAdminRequired]
- public function report(string $announcementKey): JSONResponse
- {
- if ($this->userId === null) {
- return ResponseHelper::unauthorized();
- }
+ /**
+ * GET /api/acknowledgements/report/{announcementKey} — the
+ * audience-scoped read-receipt report. Admin / template owner only
+ * (REQ-ACK-004).
+ *
+ * @param string $announcementKey The announcement identity.
+ *
+ * @return JSONResponse The report payload or 403 / 404.
+ *
+ * @spec openspec/changes/dashboard-acknowledgements/specs/dashboard-acknowledgements/spec.md
+ */
+ #[NoAdminRequired]
+ public function report(string $announcementKey): JSONResponse {
+ if ($this->userId === null) {
+ return ResponseHelper::unauthorized();
+ }
- if ($this->isManager(announcementKey: $announcementKey) === false) {
- return ResponseHelper::forbidden(
- message: 'Only an admin or the template owner may read this report'
- );
- }
+ if ($this->isManager(announcementKey: $announcementKey) === false) {
+ return ResponseHelper::forbidden(
+ message: 'Only an admin or the template owner may read this report'
+ );
+ }
- try {
- $report = $this->acknowledgementService->report(
- announcementKey: $announcementKey
- );
- } catch (DoesNotExistException) {
- return new JSONResponse(
- data: ['error' => 'Unknown announcement'],
- statusCode: Http::STATUS_NOT_FOUND
- );
- } catch (Throwable $e) {
- $this->logger->error(
- message: 'report failed: '.$e->getMessage(),
- context: ['exception' => $e]
- );
- return new JSONResponse(
- data: ['error' => 'Operation failed'],
- statusCode: Http::STATUS_INTERNAL_SERVER_ERROR
- );
- }
+ try {
+ $report = $this->acknowledgementService->report(
+ announcementKey: $announcementKey
+ );
+ } catch (DoesNotExistException) {
+ return new JSONResponse(
+ data: ['error' => 'Unknown announcement'],
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ } catch (Throwable $e) {
+ $this->logger->error(
+ message: 'report failed: ' . $e->getMessage(),
+ context: ['exception' => $e]
+ );
+ return new JSONResponse(
+ data: ['error' => 'Operation failed'],
+ statusCode: Http::STATUS_INTERNAL_SERVER_ERROR
+ );
+ }
- return ResponseHelper::success(data: $report);
- }//end report()
+ return ResponseHelper::success(data: $report);
+ }//end report()
- /**
- * GET /api/acknowledgements/report/{announcementKey}/csv — the report
- * as a downloadable CSV compliance file. Admin / template owner only
- * (REQ-ACK-004 / REQ-ACK-006).
- *
- * @param string $announcementKey The announcement identity.
- *
- * @return DataDownloadResponse|JSONResponse The CSV download or 403 / 404.
- *
- * @spec openspec/changes/dashboard-acknowledgements/specs/dashboard-acknowledgements/spec.md
- */
- #[NoAdminRequired]
- #[NoCSRFRequired]
- public function reportCsv(string $announcementKey)
- {
- if ($this->userId === null) {
- return ResponseHelper::unauthorized();
- }
+ /**
+ * GET /api/acknowledgements/report/{announcementKey}/csv — the report
+ * as a downloadable CSV compliance file. Admin / template owner only
+ * (REQ-ACK-004 / REQ-ACK-006).
+ *
+ * @param string $announcementKey The announcement identity.
+ *
+ * @return DataDownloadResponse|JSONResponse The CSV download or 403 / 404.
+ *
+ * @spec openspec/changes/dashboard-acknowledgements/specs/dashboard-acknowledgements/spec.md
+ */
+ #[NoAdminRequired]
+ #[NoCSRFRequired]
+ public function reportCsv(string $announcementKey) {
+ if ($this->userId === null) {
+ return ResponseHelper::unauthorized();
+ }
- if ($this->isManager(announcementKey: $announcementKey) === false) {
- return ResponseHelper::forbidden(
- message: 'Only an admin or the template owner may export this report'
- );
- }
+ if ($this->isManager(announcementKey: $announcementKey) === false) {
+ return ResponseHelper::forbidden(
+ message: 'Only an admin or the template owner may export this report'
+ );
+ }
- try {
- $report = $this->acknowledgementService->report(
- announcementKey: $announcementKey
- );
- } catch (DoesNotExistException) {
- return new JSONResponse(
- data: ['error' => 'Unknown announcement'],
- statusCode: Http::STATUS_NOT_FOUND
- );
- } catch (Throwable $e) {
- $this->logger->error(
- message: 'reportCsv failed: '.$e->getMessage(),
- context: ['exception' => $e]
- );
- return new JSONResponse(
- data: ['error' => 'Operation failed'],
- statusCode: Http::STATUS_INTERNAL_SERVER_ERROR
- );
- }
+ try {
+ $report = $this->acknowledgementService->report(
+ announcementKey: $announcementKey
+ );
+ } catch (DoesNotExistException) {
+ return new JSONResponse(
+ data: ['error' => 'Unknown announcement'],
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ } catch (Throwable $e) {
+ $this->logger->error(
+ message: 'reportCsv failed: ' . $e->getMessage(),
+ context: ['exception' => $e]
+ );
+ return new JSONResponse(
+ data: ['error' => 'Operation failed'],
+ statusCode: Http::STATUS_INTERNAL_SERVER_ERROR
+ );
+ }
- $csv = $this->buildCsv(report: $report);
+ $csv = $this->buildCsv(report: $report);
- return new DataDownloadResponse(
- data: $csv,
- filename: 'acknowledgement-report-'.$announcementKey.'.csv',
- contentType: 'text/csv'
- );
- }//end reportCsv()
+ return new DataDownloadResponse(
+ data: $csv,
+ filename: 'acknowledgement-report-' . $announcementKey . '.csv',
+ contentType: 'text/csv'
+ );
+ }//end reportCsv()
- /**
- * Build the CSV body from a report payload — one row per audience
- * member with status and (for acknowledged rows) timestamp.
- * REQ-ACK-006.
- *
- * @param array $report The report payload from the service.
- *
- * @return string The CSV text.
- */
- private function buildCsv(array $report): string
- {
- $lines = [];
- $lines[] = 'user_id,status,acknowledged_at';
- foreach (($report['rows'] ?? []) as $row) {
- $lines[] = implode(
- separator: ',',
- array: [
- $this->csvField(value: (string) ($row['userId'] ?? '')),
- $this->csvField(value: (string) ($row['status'] ?? '')),
- $this->csvField(value: (string) ($row['acknowledgedAt'] ?? '')),
- ]
- );
- }
+ /**
+ * Build the CSV body from a report payload — one row per audience
+ * member with status and (for acknowledged rows) timestamp.
+ * REQ-ACK-006.
+ *
+ * @param array $report The report payload from the service.
+ *
+ * @return string The CSV text.
+ */
+ private function buildCsv(array $report): string {
+ $lines = [];
+ $lines[] = 'user_id,status,acknowledged_at';
+ foreach (($report['rows'] ?? []) as $row) {
+ $lines[] = implode(
+ separator: ',',
+ array: [
+ $this->csvField(value: (string)($row['userId'] ?? '')),
+ $this->csvField(value: (string)($row['status'] ?? '')),
+ $this->csvField(value: (string)($row['acknowledgedAt'] ?? '')),
+ ]
+ );
+ }
- return implode(separator: "\r\n", array: $lines)."\r\n";
- }//end buildCsv()
+ return implode(separator: "\r\n", array: $lines) . "\r\n";
+ }//end buildCsv()
- /**
- * Quote and escape a single CSV field per RFC 4180.
- *
- * @param string $value The raw field value.
- *
- * @return string The quoted field.
- */
- private function csvField(string $value): string
- {
- return '"'.str_replace(search: '"', replace: '""', subject: $value).'"';
- }//end csvField()
+ /**
+ * Quote and escape a single CSV field per RFC 4180.
+ *
+ * @param string $value The raw field value.
+ *
+ * @return string The quoted field.
+ */
+ private function csvField(string $value): string {
+ return '"' . str_replace(search: '"', replace: '""', subject: $value) . '"';
+ }//end csvField()
- /**
- * Whether the current user may manage (report on) the announcement —
- * a Nextcloud admin, a LaunchPad admin, or the announcement's template
- * owner (REQ-ACK-004, design "Authorization").
- *
- * @param string $announcementKey The announcement identity.
- *
- * @return bool True when authorized.
- */
- private function isManager(string $announcementKey): bool
- {
- if ($this->userId === null) {
- return false;
- }
+ /**
+ * Whether the current user may manage (report on) the announcement —
+ * a Nextcloud admin, a LaunchPad admin, or the announcement's template
+ * owner (REQ-ACK-004, design "Authorization").
+ *
+ * @param string $announcementKey The announcement identity.
+ *
+ * @return bool True when authorized.
+ */
+ private function isManager(string $announcementKey): bool {
+ if ($this->userId === null) {
+ return false;
+ }
- if ($this->groupManager->isAdmin(userId: $this->userId) === true) {
- return true;
- }
+ if ($this->groupManager->isAdmin(userId: $this->userId) === true) {
+ return true;
+ }
- if ($this->roleService->isAdmin(userId: $this->userId) === true) {
- return true;
- }
+ if ($this->roleService->isAdmin(userId: $this->userId) === true) {
+ return true;
+ }
- $owner = $this->acknowledgementService->resolveOwnerUserId(
- announcementKey: $announcementKey
- );
+ $owner = $this->acknowledgementService->resolveOwnerUserId(
+ announcementKey: $announcementKey
+ );
- return $owner !== null && $owner === $this->userId;
- }//end isManager()
+ return $owner !== null && $owner === $this->userId;
+ }//end isManager()
}//end class
diff --git a/lib/Controller/ActionMatrixController.php b/lib/Controller/ActionMatrixController.php
index ea8294a6..0a818755 100644
--- a/lib/Controller/ActionMatrixController.php
+++ b/lib/Controller/ActionMatrixController.php
@@ -37,134 +37,128 @@
*
* @spec openspec/architecture/adr-023-action-authorization.md
*/
-class ActionMatrixController extends Controller
-{
- private const SEED_PATH = __DIR__.'/../actions.seed.json';
-
- /**
- * Constructor.
- *
- * @param IRequest $request The request.
- * @param ActionAuthService $actionAuth The action authorization service.
- * @param IGroupManager $groupManager The group manager.
- */
- public function __construct(
- IRequest $request,
- private readonly ActionAuthService $actionAuth,
- private readonly IGroupManager $groupManager,
- ) {
- parent::__construct(
- appName: Application::APP_ID,
- request: $request
- );
- }//end __construct()
-
- /**
- * Get the full action matrix, the complete action key list, and all groups.
- *
- * The action key list is the union of the keys currently in the matrix and
- * the keys declared in the seed file, so the admin sees every declared
- * action even before any customization.
- *
- * @return JSONResponse The matrix, action keys, and group IDs.
- *
- * @spec openspec/architecture/adr-023-action-authorization.md
- */
- #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
- public function getMatrix(): JSONResponse
- {
- $matrix = $this->actionAuth->getMatrix();
-
- $actionKeys = array_keys($matrix);
- foreach ($this->seedActionKeys() as $key) {
- if (in_array($key, $actionKeys, true) === false) {
- $actionKeys[] = $key;
- }
- }
-
- sort($actionKeys);
-
- $groups = [];
- foreach ($this->groupManager->search('') as $group) {
- $groups[] = $group->getGID();
- }
-
- return new JSONResponse(
- [
- 'matrix' => $matrix,
- 'actions' => $actionKeys,
- 'groups' => $groups,
- ]
- );
-
- }//end getMatrix()
-
- /**
- * Persist the action matrix.
- *
- * Reads the `matrix` parameter from the request body and writes it through
- * the action authorization service (which normalizes the shape).
- *
- * @return JSONResponse The normalized matrix after the write.
- *
- * @spec openspec/architecture/adr-023-action-authorization.md
- */
- #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
- public function setMatrix(): JSONResponse
- {
- $matrix = $this->request->getParam('matrix');
- if (is_array($matrix) === false) {
- $matrix = [];
- }
-
- try {
- $this->actionAuth->setMatrix($matrix);
- } catch (\JsonException $e) {
- return new JSONResponse(
- ['error' => 'Could not encode the action matrix: '.$e->getMessage()],
- \OCP\AppFramework\Http::STATUS_BAD_REQUEST
- );
- }
-
- return new JSONResponse(['matrix' => $this->actionAuth->getMatrix()]);
-
- }//end setMatrix()
-
- /**
- * Read the action keys declared in the seed file.
- *
- * @return array
- */
- private function seedActionKeys(): array
- {
- if (file_exists(self::SEED_PATH) === false) {
- return [];
- }
-
- $raw = file_get_contents(self::SEED_PATH);
- if ($raw === false) {
- return [];
- }
-
- try {
- $parsed = json_decode($raw, associative: true, depth: 512, flags: JSON_THROW_ON_ERROR);
- } catch (\JsonException $e) {
- return [];
- }
-
- $actions = ($parsed['actions'] ?? null);
- if (is_array($actions) === false) {
- return [];
- }
-
- $keys = [];
- foreach (array_keys($actions) as $key) {
- if (is_string($key) === true) {
- $keys[] = $key;
- }
- }
-
- return $keys;
-
- }//end seedActionKeys()
+class ActionMatrixController extends Controller {
+ private const SEED_PATH = __DIR__ . '/../actions.seed.json';
+
+ /**
+ * Constructor.
+ *
+ * @param IRequest $request The request.
+ * @param ActionAuthService $actionAuth The action authorization service.
+ * @param IGroupManager $groupManager The group manager.
+ */
+ public function __construct(
+ IRequest $request,
+ private readonly ActionAuthService $actionAuth,
+ private readonly IGroupManager $groupManager,
+ ) {
+ parent::__construct(
+ appName: Application::APP_ID,
+ request: $request
+ );
+ }//end __construct()
+
+ /**
+ * Get the full action matrix, the complete action key list, and all groups.
+ *
+ * The action key list is the union of the keys currently in the matrix and
+ * the keys declared in the seed file, so the admin sees every declared
+ * action even before any customization.
+ *
+ * @return JSONResponse The matrix, action keys, and group IDs.
+ *
+ * @spec openspec/architecture/adr-023-action-authorization.md
+ */
+ #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
+ public function getMatrix(): JSONResponse {
+ $matrix = $this->actionAuth->getMatrix();
+
+ $actionKeys = array_keys($matrix);
+ foreach ($this->seedActionKeys() as $key) {
+ if (in_array($key, $actionKeys, true) === false) {
+ $actionKeys[] = $key;
+ }
+ }
+
+ sort($actionKeys);
+
+ $groups = [];
+ foreach ($this->groupManager->search('') as $group) {
+ $groups[] = $group->getGID();
+ }
+
+ return new JSONResponse(
+ [
+ 'matrix' => $matrix,
+ 'actions' => $actionKeys,
+ 'groups' => $groups,
+ ]
+ );
+
+ }//end getMatrix()
+
+ /**
+ * Persist the action matrix.
+ *
+ * Reads the `matrix` parameter from the request body and writes it through
+ * the action authorization service (which normalizes the shape).
+ *
+ * @return JSONResponse The normalized matrix after the write.
+ *
+ * @spec openspec/architecture/adr-023-action-authorization.md
+ */
+ #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
+ public function setMatrix(): JSONResponse {
+ $matrix = $this->request->getParam('matrix');
+ if (is_array($matrix) === false) {
+ $matrix = [];
+ }
+
+ try {
+ $this->actionAuth->setMatrix($matrix);
+ } catch (\JsonException $e) {
+ return new JSONResponse(
+ ['error' => 'Could not encode the action matrix: ' . $e->getMessage()],
+ \OCP\AppFramework\Http::STATUS_BAD_REQUEST
+ );
+ }
+
+ return new JSONResponse(['matrix' => $this->actionAuth->getMatrix()]);
+ }//end setMatrix()
+
+ /**
+ * Read the action keys declared in the seed file.
+ *
+ * @return array
+ */
+ private function seedActionKeys(): array {
+ if (file_exists(self::SEED_PATH) === false) {
+ return [];
+ }
+
+ $raw = file_get_contents(self::SEED_PATH);
+ if ($raw === false) {
+ return [];
+ }
+
+ try {
+ $parsed = json_decode($raw, associative: true, depth: 512, flags: JSON_THROW_ON_ERROR);
+ } catch (\JsonException $e) {
+ return [];
+ }
+
+ $actions = ($parsed['actions'] ?? null);
+ if (is_array($actions) === false) {
+ return [];
+ }
+
+ $keys = [];
+ foreach (array_keys($actions) as $key) {
+ if (is_string($key) === true) {
+ $keys[] = $key;
+ }
+ }
+
+ return $keys;
+ }//end seedActionKeys()
}//end class
diff --git a/lib/Controller/AdminBulkController.php b/lib/Controller/AdminBulkController.php
index 2df10359..26fa654f 100644
--- a/lib/Controller/AdminBulkController.php
+++ b/lib/Controller/AdminBulkController.php
@@ -48,349 +48,345 @@
/**
* Bulk admin endpoints for dashboards (REQ-BULK-001..011).
*/
-class AdminBulkController extends Controller
-{
- /**
- * Constructor.
- *
- * @param IRequest $request The current request.
- * @param BulkOperationService $bulkService The bulk service.
- * @param IUserSession $userSession The user session.
- * @param IGroupManager $groupManager NC group manager for inline admin guard.
- */
- public function __construct(
- IRequest $request,
- private readonly BulkOperationService $bulkService,
- private readonly IUserSession $userSession,
- private readonly IGroupManager $groupManager,
- ) {
- parent::__construct(
- appName: Application::APP_ID,
- request: $request
- );
- }//end __construct()
-
- /**
- * Inline admin guard — returns a 401/403 JSONResponse when the caller
- * is not authenticated or not an NC admin, or null when the guard passes.
- *
- * @return JSONResponse|null Non-null means the request must be rejected.
- */
- private function assertAdmin(): ?JSONResponse
- {
- $user = $this->userSession->getUser();
- if ($user === null) {
- return new JSONResponse(
- data: ['error' => 'Not authenticated'],
- statusCode: Http::STATUS_UNAUTHORIZED
- );
- }
-
- if ($this->groupManager->isAdmin(userId: $user->getUID()) === false) {
- return new JSONResponse(
- data: ['error' => 'Admin required'],
- statusCode: Http::STATUS_FORBIDDEN
- );
- }
-
- return null;
- }//end assertAdmin()
-
- /**
- * `POST /api/admin/dashboards/bulk-delete` — REQ-BULK-001.
- *
- * @param mixed $dashboardUuids The UUID array.
- * @param bool|null $dryRun When true, preview only.
- * @param bool|null $cascade When true, cascade into children.
- *
- * @return JSONResponse The bulk-delete envelope.
- *
- * @spec openspec/specs/dashboard-bulk-operations/spec.md
- */
- #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
- public function bulkDelete(
- mixed $dashboardUuids=null,
- ?bool $dryRun=null,
- ?bool $cascade=null
- ): JSONResponse {
- $guard = $this->assertAdmin();
- if ($guard !== null) {
- return $guard;
- }
-
- $uuids = $this->extractUuids(value: $dashboardUuids);
- if ($uuids === null) {
- return new JSONResponse(
- data: ['error' => 'dashboardUuids must be an array of strings'],
- statusCode: Http::STATUS_BAD_REQUEST
- );
- }
-
- $userId = (string) $this->userSession->getUser()?->getUID();
- $isDryRun = $this->resolveBool(value: $dryRun, queryKey: 'dryRun');
- $doCascade = $this->resolveBool(value: $cascade, queryKey: 'cascade');
-
- try {
- $result = $this->bulkService->bulkDelete(
- dashboardUuids: $uuids,
- userId: $userId,
- dryRun: $isDryRun,
- cascade: $doCascade
- );
- } catch (PermissionDeniedException $e) {
- return new JSONResponse(
- data: [
- 'error' => $e->getMessage(),
- 'deniedUuids' => $e->getDeniedUuids(),
- ],
- statusCode: Http::STATUS_FORBIDDEN
- );
- } catch (InvalidArgumentException $e) {
- return new JSONResponse(
- data: ['error' => $e->getMessage()],
- statusCode: Http::STATUS_BAD_REQUEST
- );
- }//end try
-
- return new JSONResponse(data: $result, statusCode: Http::STATUS_OK);
- }//end bulkDelete()
-
- /**
- * `POST /api/admin/dashboards/bulk-move` — REQ-BULK-002.
- *
- * @param mixed $dashboardUuids The UUID array.
- * @param string|null $parentUuid The new parent UUID
- * (NULL ⇒ root).
- * @param bool|null $dryRun When true, preview only.
- *
- * @return JSONResponse The bulk-move envelope.
- *
- * @spec openspec/specs/dashboard-bulk-operations/spec.md
- */
- #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
- public function bulkMove(
- mixed $dashboardUuids=null,
- ?string $parentUuid=null,
- ?bool $dryRun=null
- ): JSONResponse {
- $guard = $this->assertAdmin();
- if ($guard !== null) {
- return $guard;
- }
-
- $uuids = $this->extractUuids(value: $dashboardUuids);
- if ($uuids === null) {
- return new JSONResponse(
- data: ['error' => 'dashboardUuids must be an array of strings'],
- statusCode: Http::STATUS_BAD_REQUEST
- );
- }
-
- $userId = (string) $this->userSession->getUser()?->getUID();
- $isDryRun = $this->resolveBool(value: $dryRun, queryKey: 'dryRun');
-
- try {
- $result = $this->bulkService->bulkMove(
- dashboardUuids: $uuids,
- parentUuid: $parentUuid,
- userId: $userId,
- dryRun: $isDryRun
- );
- } catch (PermissionDeniedException $e) {
- return new JSONResponse(
- data: [
- 'error' => $e->getMessage(),
- 'deniedUuids' => $e->getDeniedUuids(),
- ],
- statusCode: Http::STATUS_FORBIDDEN
- );
- } catch (InvalidArgumentException $e) {
- return new JSONResponse(
- data: ['error' => $e->getMessage()],
- statusCode: Http::STATUS_BAD_REQUEST
- );
- }//end try
-
- return new JSONResponse(data: $result, statusCode: Http::STATUS_OK);
- }//end bulkMove()
-
- /**
- * `POST /api/admin/dashboards/bulk-status` — REQ-BULK-003.
- *
- * @param mixed $dashboardUuids The UUID array.
- * @param string|null $publicationStatus The target status enum value.
- * @param string|null $publishAt Future ISO-8601 timestamp.
- * @param bool|null $dryRun When true, preview only.
- *
- * @return JSONResponse The bulk-status envelope.
- *
- * @spec openspec/specs/dashboard-bulk-operations/spec.md
- */
- #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
- public function bulkStatus(
- mixed $dashboardUuids=null,
- ?string $publicationStatus=null,
- ?string $publishAt=null,
- ?bool $dryRun=null
- ): JSONResponse {
- $guard = $this->assertAdmin();
- if ($guard !== null) {
- return $guard;
- }
-
- $uuids = $this->extractUuids(value: $dashboardUuids);
- if ($uuids === null) {
- return new JSONResponse(
- data: ['error' => 'dashboardUuids must be an array of strings'],
- statusCode: Http::STATUS_BAD_REQUEST
- );
- }
-
- if ($publicationStatus === null || trim($publicationStatus) === '') {
- return new JSONResponse(
- data: ['error' => 'publicationStatus is required'],
- statusCode: Http::STATUS_BAD_REQUEST
- );
- }
-
- $userId = (string) $this->userSession->getUser()?->getUID();
- $isDryRun = $this->resolveBool(value: $dryRun, queryKey: 'dryRun');
-
- try {
- $result = $this->bulkService->bulkStatus(
- dashboardUuids: $uuids,
- publicationStatus: $publicationStatus,
- publishAt: $publishAt,
- userId: $userId,
- dryRun: $isDryRun
- );
- } catch (PermissionDeniedException $e) {
- return new JSONResponse(
- data: [
- 'error' => $e->getMessage(),
- 'deniedUuids' => $e->getDeniedUuids(),
- ],
- statusCode: Http::STATUS_FORBIDDEN
- );
- } catch (InvalidArgumentException $e) {
- return new JSONResponse(
- data: ['error' => $e->getMessage()],
- statusCode: Http::STATUS_BAD_REQUEST
- );
- }//end try
-
- return new JSONResponse(data: $result, statusCode: Http::STATUS_OK);
- }//end bulkStatus()
-
- /**
- * `POST /api/admin/dashboards/bulk-reindex` — REQ-BULK-004.
- *
- * @param mixed $dashboardUuids The UUID array.
- * @param bool|null $dryRun When true, preview only.
- *
- * @return JSONResponse The bulk-reindex envelope.
- *
- * @spec openspec/specs/dashboard-bulk-operations/spec.md
- */
- #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
- public function bulkReindex(
- mixed $dashboardUuids=null,
- ?bool $dryRun=null
- ): JSONResponse {
- $guard = $this->assertAdmin();
- if ($guard !== null) {
- return $guard;
- }
-
- $uuids = $this->extractUuids(value: $dashboardUuids);
- if ($uuids === null) {
- return new JSONResponse(
- data: ['error' => 'dashboardUuids must be an array of strings'],
- statusCode: Http::STATUS_BAD_REQUEST
- );
- }
-
- $userId = (string) $this->userSession->getUser()?->getUID();
- $isDryRun = $this->resolveBool(value: $dryRun, queryKey: 'dryRun');
-
- try {
- $result = $this->bulkService->bulkReindex(
- dashboardUuids: $uuids,
- userId: $userId,
- dryRun: $isDryRun
- );
- } catch (PermissionDeniedException $e) {
- return new JSONResponse(
- data: [
- 'error' => $e->getMessage(),
- 'deniedUuids' => $e->getDeniedUuids(),
- ],
- statusCode: Http::STATUS_FORBIDDEN
- );
- } catch (InvalidArgumentException $e) {
- return new JSONResponse(
- data: ['error' => $e->getMessage()],
- statusCode: Http::STATUS_BAD_REQUEST
- );
- }
-
- return new JSONResponse(data: $result, statusCode: Http::STATUS_OK);
- }//end bulkReindex()
-
- /**
- * Validate and unwrap a `dashboardUuids` body field into a string
- * list. Returns null when the value is not a list of strings.
- *
- * @param mixed $value The raw decoded value.
- *
- * @return string[]|null The extracted UUID list, or null on
- * validation failure.
- */
- private function extractUuids(mixed $value): ?array
- {
- if (is_array($value) === false) {
- return null;
- }
-
- $uuids = [];
- foreach ($value as $item) {
- if (is_string($item) === false) {
- return null;
- }
-
- $trimmed = trim($item);
- if ($trimmed === '') {
- return null;
- }
-
- $uuids[] = $trimmed;
- }
-
- return $uuids;
- }//end extractUuids()
-
- /**
- * Resolve a boolean parameter that may arrive either in the body
- * (`bool`) or via the query string (`?dryRun=true`). The query
- * string takes precedence when the body parameter is null.
- *
- * @param bool|null $value The body-parsed value.
- * @param string $queryKey The query string key.
- *
- * @return bool The resolved boolean.
- */
- private function resolveBool(?bool $value, string $queryKey): bool
- {
- if ($value !== null) {
- return $value;
- }
-
- $raw = $this->request->getParam(key: $queryKey);
- if ($raw === null || $raw === '') {
- return false;
- }
-
- $lower = strtolower((string) $raw);
- return in_array(needle: $lower, haystack: ['1', 'true', 'yes', 'on'], strict: true);
- }//end resolveBool()
+class AdminBulkController extends Controller {
+ /**
+ * Constructor.
+ *
+ * @param IRequest $request The current request.
+ * @param BulkOperationService $bulkService The bulk service.
+ * @param IUserSession $userSession The user session.
+ * @param IGroupManager $groupManager NC group manager for inline admin guard.
+ */
+ public function __construct(
+ IRequest $request,
+ private readonly BulkOperationService $bulkService,
+ private readonly IUserSession $userSession,
+ private readonly IGroupManager $groupManager,
+ ) {
+ parent::__construct(
+ appName: Application::APP_ID,
+ request: $request
+ );
+ }//end __construct()
+
+ /**
+ * Inline admin guard — returns a 401/403 JSONResponse when the caller
+ * is not authenticated or not an NC admin, or null when the guard passes.
+ *
+ * @return JSONResponse|null Non-null means the request must be rejected.
+ */
+ private function assertAdmin(): ?JSONResponse {
+ $user = $this->userSession->getUser();
+ if ($user === null) {
+ return new JSONResponse(
+ data: ['error' => 'Not authenticated'],
+ statusCode: Http::STATUS_UNAUTHORIZED
+ );
+ }
+
+ if ($this->groupManager->isAdmin(userId: $user->getUID()) === false) {
+ return new JSONResponse(
+ data: ['error' => 'Admin required'],
+ statusCode: Http::STATUS_FORBIDDEN
+ );
+ }
+
+ return null;
+ }//end assertAdmin()
+
+ /**
+ * `POST /api/admin/dashboards/bulk-delete` — REQ-BULK-001.
+ *
+ * @param mixed $dashboardUuids The UUID array.
+ * @param bool|null $dryRun When true, preview only.
+ * @param bool|null $cascade When true, cascade into children.
+ *
+ * @return JSONResponse The bulk-delete envelope.
+ *
+ * @spec openspec/specs/dashboard-bulk-operations/spec.md
+ */
+ #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
+ public function bulkDelete(
+ mixed $dashboardUuids = null,
+ ?bool $dryRun = null,
+ ?bool $cascade = null,
+ ): JSONResponse {
+ $guard = $this->assertAdmin();
+ if ($guard !== null) {
+ return $guard;
+ }
+
+ $uuids = $this->extractUuids(value: $dashboardUuids);
+ if ($uuids === null) {
+ return new JSONResponse(
+ data: ['error' => 'dashboardUuids must be an array of strings'],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ }
+
+ $userId = (string)$this->userSession->getUser()?->getUID();
+ $isDryRun = $this->resolveBool(value: $dryRun, queryKey: 'dryRun');
+ $doCascade = $this->resolveBool(value: $cascade, queryKey: 'cascade');
+
+ try {
+ $result = $this->bulkService->bulkDelete(
+ dashboardUuids: $uuids,
+ userId: $userId,
+ dryRun: $isDryRun,
+ cascade: $doCascade
+ );
+ } catch (PermissionDeniedException $e) {
+ return new JSONResponse(
+ data: [
+ 'error' => $e->getMessage(),
+ 'deniedUuids' => $e->getDeniedUuids(),
+ ],
+ statusCode: Http::STATUS_FORBIDDEN
+ );
+ } catch (InvalidArgumentException $e) {
+ return new JSONResponse(
+ data: ['error' => $e->getMessage()],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ }//end try
+
+ return new JSONResponse(data: $result, statusCode: Http::STATUS_OK);
+ }//end bulkDelete()
+
+ /**
+ * `POST /api/admin/dashboards/bulk-move` — REQ-BULK-002.
+ *
+ * @param mixed $dashboardUuids The UUID array.
+ * @param string|null $parentUuid The new parent UUID
+ * (NULL ⇒ root).
+ * @param bool|null $dryRun When true, preview only.
+ *
+ * @return JSONResponse The bulk-move envelope.
+ *
+ * @spec openspec/specs/dashboard-bulk-operations/spec.md
+ */
+ #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
+ public function bulkMove(
+ mixed $dashboardUuids = null,
+ ?string $parentUuid = null,
+ ?bool $dryRun = null,
+ ): JSONResponse {
+ $guard = $this->assertAdmin();
+ if ($guard !== null) {
+ return $guard;
+ }
+
+ $uuids = $this->extractUuids(value: $dashboardUuids);
+ if ($uuids === null) {
+ return new JSONResponse(
+ data: ['error' => 'dashboardUuids must be an array of strings'],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ }
+
+ $userId = (string)$this->userSession->getUser()?->getUID();
+ $isDryRun = $this->resolveBool(value: $dryRun, queryKey: 'dryRun');
+
+ try {
+ $result = $this->bulkService->bulkMove(
+ dashboardUuids: $uuids,
+ parentUuid: $parentUuid,
+ userId: $userId,
+ dryRun: $isDryRun
+ );
+ } catch (PermissionDeniedException $e) {
+ return new JSONResponse(
+ data: [
+ 'error' => $e->getMessage(),
+ 'deniedUuids' => $e->getDeniedUuids(),
+ ],
+ statusCode: Http::STATUS_FORBIDDEN
+ );
+ } catch (InvalidArgumentException $e) {
+ return new JSONResponse(
+ data: ['error' => $e->getMessage()],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ }//end try
+
+ return new JSONResponse(data: $result, statusCode: Http::STATUS_OK);
+ }//end bulkMove()
+
+ /**
+ * `POST /api/admin/dashboards/bulk-status` — REQ-BULK-003.
+ *
+ * @param mixed $dashboardUuids The UUID array.
+ * @param string|null $publicationStatus The target status enum value.
+ * @param string|null $publishAt Future ISO-8601 timestamp.
+ * @param bool|null $dryRun When true, preview only.
+ *
+ * @return JSONResponse The bulk-status envelope.
+ *
+ * @spec openspec/specs/dashboard-bulk-operations/spec.md
+ */
+ #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
+ public function bulkStatus(
+ mixed $dashboardUuids = null,
+ ?string $publicationStatus = null,
+ ?string $publishAt = null,
+ ?bool $dryRun = null,
+ ): JSONResponse {
+ $guard = $this->assertAdmin();
+ if ($guard !== null) {
+ return $guard;
+ }
+
+ $uuids = $this->extractUuids(value: $dashboardUuids);
+ if ($uuids === null) {
+ return new JSONResponse(
+ data: ['error' => 'dashboardUuids must be an array of strings'],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ }
+
+ if ($publicationStatus === null || trim($publicationStatus) === '') {
+ return new JSONResponse(
+ data: ['error' => 'publicationStatus is required'],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ }
+
+ $userId = (string)$this->userSession->getUser()?->getUID();
+ $isDryRun = $this->resolveBool(value: $dryRun, queryKey: 'dryRun');
+
+ try {
+ $result = $this->bulkService->bulkStatus(
+ dashboardUuids: $uuids,
+ publicationStatus: $publicationStatus,
+ publishAt: $publishAt,
+ userId: $userId,
+ dryRun: $isDryRun
+ );
+ } catch (PermissionDeniedException $e) {
+ return new JSONResponse(
+ data: [
+ 'error' => $e->getMessage(),
+ 'deniedUuids' => $e->getDeniedUuids(),
+ ],
+ statusCode: Http::STATUS_FORBIDDEN
+ );
+ } catch (InvalidArgumentException $e) {
+ return new JSONResponse(
+ data: ['error' => $e->getMessage()],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ }//end try
+
+ return new JSONResponse(data: $result, statusCode: Http::STATUS_OK);
+ }//end bulkStatus()
+
+ /**
+ * `POST /api/admin/dashboards/bulk-reindex` — REQ-BULK-004.
+ *
+ * @param mixed $dashboardUuids The UUID array.
+ * @param bool|null $dryRun When true, preview only.
+ *
+ * @return JSONResponse The bulk-reindex envelope.
+ *
+ * @spec openspec/specs/dashboard-bulk-operations/spec.md
+ */
+ #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
+ public function bulkReindex(
+ mixed $dashboardUuids = null,
+ ?bool $dryRun = null,
+ ): JSONResponse {
+ $guard = $this->assertAdmin();
+ if ($guard !== null) {
+ return $guard;
+ }
+
+ $uuids = $this->extractUuids(value: $dashboardUuids);
+ if ($uuids === null) {
+ return new JSONResponse(
+ data: ['error' => 'dashboardUuids must be an array of strings'],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ }
+
+ $userId = (string)$this->userSession->getUser()?->getUID();
+ $isDryRun = $this->resolveBool(value: $dryRun, queryKey: 'dryRun');
+
+ try {
+ $result = $this->bulkService->bulkReindex(
+ dashboardUuids: $uuids,
+ userId: $userId,
+ dryRun: $isDryRun
+ );
+ } catch (PermissionDeniedException $e) {
+ return new JSONResponse(
+ data: [
+ 'error' => $e->getMessage(),
+ 'deniedUuids' => $e->getDeniedUuids(),
+ ],
+ statusCode: Http::STATUS_FORBIDDEN
+ );
+ } catch (InvalidArgumentException $e) {
+ return new JSONResponse(
+ data: ['error' => $e->getMessage()],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ }
+
+ return new JSONResponse(data: $result, statusCode: Http::STATUS_OK);
+ }//end bulkReindex()
+
+ /**
+ * Validate and unwrap a `dashboardUuids` body field into a string
+ * list. Returns null when the value is not a list of strings.
+ *
+ * @param mixed $value The raw decoded value.
+ *
+ * @return string[]|null The extracted UUID list, or null on
+ * validation failure.
+ */
+ private function extractUuids(mixed $value): ?array {
+ if (is_array($value) === false) {
+ return null;
+ }
+
+ $uuids = [];
+ foreach ($value as $item) {
+ if (is_string($item) === false) {
+ return null;
+ }
+
+ $trimmed = trim($item);
+ if ($trimmed === '') {
+ return null;
+ }
+
+ $uuids[] = $trimmed;
+ }
+
+ return $uuids;
+ }//end extractUuids()
+
+ /**
+ * Resolve a boolean parameter that may arrive either in the body
+ * (`bool`) or via the query string (`?dryRun=true`). The query
+ * string takes precedence when the body parameter is null.
+ *
+ * @param bool|null $value The body-parsed value.
+ * @param string $queryKey The query string key.
+ *
+ * @return bool The resolved boolean.
+ */
+ private function resolveBool(?bool $value, string $queryKey): bool {
+ if ($value !== null) {
+ return $value;
+ }
+
+ $raw = $this->request->getParam(key: $queryKey);
+ if ($raw === null || $raw === '') {
+ return false;
+ }
+
+ $lower = strtolower((string)$raw);
+ return in_array(needle: $lower, haystack: ['1', 'true', 'yes', 'on'], strict: true);
+ }//end resolveBool()
}//end class
diff --git a/lib/Controller/AdminCleanupController.php b/lib/Controller/AdminCleanupController.php
index 6756d936..dfde0f41 100644
--- a/lib/Controller/AdminCleanupController.php
+++ b/lib/Controller/AdminCleanupController.php
@@ -47,199 +47,194 @@
/**
* Admin endpoints for scan + purge.
*/
-class AdminCleanupController extends Controller
-{
- /**
- * Constructor.
- *
- * @param IRequest $request The request.
- * @param OrphanedDataCleanupService $cleanupService The orchestrator.
- * @param CategoryRegistryService $registry Category registry
- * (for unknown-name
- * error messages).
- * @param IUserSession $userSession Current user.
- * @param IGroupManager $groupManager Admin check.
- */
- public function __construct(
- IRequest $request,
- private readonly OrphanedDataCleanupService $cleanupService,
- private readonly CategoryRegistryService $registry,
- private readonly IUserSession $userSession,
- private readonly IGroupManager $groupManager,
- ) {
- parent::__construct(
- appName: Application::APP_ID,
- request: $request
- );
- }//end __construct()
-
- /**
- * Inline admin guard.
- *
- * @return JSONResponse|null Non-null = caller must be rejected.
- */
- private function assertAdmin(): ?JSONResponse
- {
- $user = $this->userSession->getUser();
- if ($user === null) {
- return new JSONResponse(
- data: ['error' => 'Not authenticated'],
- statusCode: Http::STATUS_FORBIDDEN
- );
- }
-
- if ($this->groupManager->isAdmin(userId: $user->getUID()) === false) {
- return new JSONResponse(
- data: ['error' => 'Admin required'],
- statusCode: Http::STATUS_FORBIDDEN
- );
- }
-
- return null;
- }//end assertAdmin()
-
- /**
- * `GET /api/admin/cleanup/scan` — REQ-CLN-004.
- *
- * Returns a JSON envelope describing the per-category orphan
- * counts. Reads from the distributed cache when available
- * (REQ-CLN-010) and surfaces `cached`/`cachedAt` hints so the UI
- * can display "last refreshed" badges.
- *
- * @return JSONResponse The scan result.
- *
- * @spec openspec/specs/orphaned-data-cleanup/spec.md
- */
- #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
- public function scan(): JSONResponse
- {
- $guard = $this->assertAdmin();
- if ($guard !== null) {
- return $guard;
- }
-
- $cached = $this->cleanupService->getCachedScanResult();
- if ($cached !== null) {
- return new JSONResponse(
- data: array_merge(
- $cached->jsonSerialize(),
- [
- 'cached' => true,
- 'cachedAt' => $cached->getScannedAt(),
- ]
- ),
- statusCode: Http::STATUS_OK
- );
- }
-
- $result = $this->cleanupService->scan();
-
- return new JSONResponse(
- data: array_merge(
- $result->jsonSerialize(),
- [
- 'cached' => false,
- 'cachedAt' => null,
- ]
- ),
- statusCode: Http::STATUS_OK
- );
- }//end scan()
-
- /**
- * `POST /api/admin/cleanup/purge` — REQ-CLN-005.
- *
- * Body shape:
- * {
- * "categories": ["expired_locks", ...], // optional, []=all
- * "dryRun": true|false // optional, false default
- * }
- *
- * Returns the per-category breakdown plus total, duration, and
- * dryRun flag. Unknown categories receive HTTP 400 with the list
- * of valid names so the caller can correct the request.
- *
- * @param array|null $categories Per-category filter.
- * @param bool|null $dryRun Dry-run flag.
- *
- * @return JSONResponse The purge result.
- *
- * @spec openspec/specs/orphaned-data-cleanup/spec.md
- */
- #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
- public function purge(?array $categories=null, ?bool $dryRun=null): JSONResponse
- {
- $guard = $this->assertAdmin();
- if ($guard !== null) {
- return $guard;
- }
-
- $names = $this->normaliseCategories(input: ($categories ?? []));
- if ($names === null) {
- return new JSONResponse(
- data: [
- 'error' => 'Unknown cleanup category in request',
- 'validCategories' => $this->registry->getCategoryNames(),
- ],
- statusCode: Http::STATUS_BAD_REQUEST
- );
- }
-
- $userId = '';
- $user = $this->userSession->getUser();
- if ($user !== null) {
- $userId = $user->getUID();
- }
-
- $result = $this->cleanupService->purge(
- categoryNames: $names,
- dryRun: ($dryRun ?? false),
- userId: $userId,
- source: 'api',
- );
-
- return new JSONResponse(
- data: [
- 'purgedByCategory' => $result->getByCategory(),
- 'totalRows' => $result->getTotalRows(),
- 'durationMs' => $result->getDurationMs(),
- 'dryRun' => $result->isDryRun(),
- 'skipped' => $result->getSkipped(),
- ],
- statusCode: Http::STATUS_OK
- );
- }//end purge()
-
- /**
- * Normalise the API-supplied categories list.
- *
- * Filters non-string entries silently (defence in depth — the
- * controller is reached after framework JSON parsing) and
- * returns `null` if any of the remaining names are not registered.
- * An empty list is returned as `[]` (which the orchestrator
- * treats as "all categories").
- *
- * @param array $input The raw input list.
- *
- * @return array|null The validated names or null.
- */
- private function normaliseCategories(array $input): ?array
- {
- $known = $this->registry->getCategoryNames();
- $normalised = [];
-
- foreach ($input as $value) {
- if (is_string(value: $value) === false || $value === '') {
- continue;
- }
-
- if (in_array(needle: $value, haystack: $known, strict: true) === false) {
- return null;
- }
-
- $normalised[] = $value;
- }
-
- return $normalised;
- }//end normaliseCategories()
+class AdminCleanupController extends Controller {
+ /**
+ * Constructor.
+ *
+ * @param IRequest $request The request.
+ * @param OrphanedDataCleanupService $cleanupService The orchestrator.
+ * @param CategoryRegistryService $registry Category registry
+ * (for unknown-name
+ * error messages).
+ * @param IUserSession $userSession Current user.
+ * @param IGroupManager $groupManager Admin check.
+ */
+ public function __construct(
+ IRequest $request,
+ private readonly OrphanedDataCleanupService $cleanupService,
+ private readonly CategoryRegistryService $registry,
+ private readonly IUserSession $userSession,
+ private readonly IGroupManager $groupManager,
+ ) {
+ parent::__construct(
+ appName: Application::APP_ID,
+ request: $request
+ );
+ }//end __construct()
+
+ /**
+ * Inline admin guard.
+ *
+ * @return JSONResponse|null Non-null = caller must be rejected.
+ */
+ private function assertAdmin(): ?JSONResponse {
+ $user = $this->userSession->getUser();
+ if ($user === null) {
+ return new JSONResponse(
+ data: ['error' => 'Not authenticated'],
+ statusCode: Http::STATUS_FORBIDDEN
+ );
+ }
+
+ if ($this->groupManager->isAdmin(userId: $user->getUID()) === false) {
+ return new JSONResponse(
+ data: ['error' => 'Admin required'],
+ statusCode: Http::STATUS_FORBIDDEN
+ );
+ }
+
+ return null;
+ }//end assertAdmin()
+
+ /**
+ * `GET /api/admin/cleanup/scan` — REQ-CLN-004.
+ *
+ * Returns a JSON envelope describing the per-category orphan
+ * counts. Reads from the distributed cache when available
+ * (REQ-CLN-010) and surfaces `cached`/`cachedAt` hints so the UI
+ * can display "last refreshed" badges.
+ *
+ * @return JSONResponse The scan result.
+ *
+ * @spec openspec/specs/orphaned-data-cleanup/spec.md
+ */
+ #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
+ public function scan(): JSONResponse {
+ $guard = $this->assertAdmin();
+ if ($guard !== null) {
+ return $guard;
+ }
+
+ $cached = $this->cleanupService->getCachedScanResult();
+ if ($cached !== null) {
+ return new JSONResponse(
+ data: array_merge(
+ $cached->jsonSerialize(),
+ [
+ 'cached' => true,
+ 'cachedAt' => $cached->getScannedAt(),
+ ]
+ ),
+ statusCode: Http::STATUS_OK
+ );
+ }
+
+ $result = $this->cleanupService->scan();
+
+ return new JSONResponse(
+ data: array_merge(
+ $result->jsonSerialize(),
+ [
+ 'cached' => false,
+ 'cachedAt' => null,
+ ]
+ ),
+ statusCode: Http::STATUS_OK
+ );
+ }//end scan()
+
+ /**
+ * `POST /api/admin/cleanup/purge` — REQ-CLN-005.
+ *
+ * Body shape:
+ * {
+ * "categories": ["expired_locks", ...], // optional, []=all
+ * "dryRun": true|false // optional, false default
+ * }
+ *
+ * Returns the per-category breakdown plus total, duration, and
+ * dryRun flag. Unknown categories receive HTTP 400 with the list
+ * of valid names so the caller can correct the request.
+ *
+ * @param array|null $categories Per-category filter.
+ * @param bool|null $dryRun Dry-run flag.
+ *
+ * @return JSONResponse The purge result.
+ *
+ * @spec openspec/specs/orphaned-data-cleanup/spec.md
+ */
+ #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
+ public function purge(?array $categories = null, ?bool $dryRun = null): JSONResponse {
+ $guard = $this->assertAdmin();
+ if ($guard !== null) {
+ return $guard;
+ }
+
+ $names = $this->normaliseCategories(input: ($categories ?? []));
+ if ($names === null) {
+ return new JSONResponse(
+ data: [
+ 'error' => 'Unknown cleanup category in request',
+ 'validCategories' => $this->registry->getCategoryNames(),
+ ],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ }
+
+ $userId = '';
+ $user = $this->userSession->getUser();
+ if ($user !== null) {
+ $userId = $user->getUID();
+ }
+
+ $result = $this->cleanupService->purge(
+ categoryNames: $names,
+ dryRun: ($dryRun ?? false),
+ userId: $userId,
+ source: 'api',
+ );
+
+ return new JSONResponse(
+ data: [
+ 'purgedByCategory' => $result->getByCategory(),
+ 'totalRows' => $result->getTotalRows(),
+ 'durationMs' => $result->getDurationMs(),
+ 'dryRun' => $result->isDryRun(),
+ 'skipped' => $result->getSkipped(),
+ ],
+ statusCode: Http::STATUS_OK
+ );
+ }//end purge()
+
+ /**
+ * Normalise the API-supplied categories list.
+ *
+ * Filters non-string entries silently (defence in depth — the
+ * controller is reached after framework JSON parsing) and
+ * returns `null` if any of the remaining names are not registered.
+ * An empty list is returned as `[]` (which the orchestrator
+ * treats as "all categories").
+ *
+ * @param array $input The raw input list.
+ *
+ * @return array|null The validated names or null.
+ */
+ private function normaliseCategories(array $input): ?array {
+ $known = $this->registry->getCategoryNames();
+ $normalised = [];
+
+ foreach ($input as $value) {
+ if (is_string(value: $value) === false || $value === '') {
+ continue;
+ }
+
+ if (in_array(needle: $value, haystack: $known, strict: true) === false) {
+ return null;
+ }
+
+ $normalised[] = $value;
+ }
+
+ return $normalised;
+ }//end normaliseCategories()
}//end class
diff --git a/lib/Controller/AdminController.php b/lib/Controller/AdminController.php
index 7ef12e64..51657756 100644
--- a/lib/Controller/AdminController.php
+++ b/lib/Controller/AdminController.php
@@ -97,1042 +97,1027 @@
* any single method.
* @spec openspec/changes/retrofit-2026-05-24-annotate-launchpad/tasks.md#task-4
*/
-class AdminController extends Controller
-{
- /**
- * Constructor
- *
- * @param IRequest $request The request.
- * @param AdminTemplateService $templateService The admin template service.
- * @param AdminSettingsService $settingsService The admin settings service.
- * @param IGroupManager $groupManager The Nextcloud group manager.
- * @param IUserSession $userSession The current user session.
- * @param ExportService $exportService ZIP export service
- * (REQ-EXIM-001..003).
- * @param ImportService $importService ZIP import service
- * (REQ-EXIM-004..008).
- * @param RoleService $roleService The LaunchPad role service
- * (REQ-ROLE-001..011).
- * @param FeedRefreshService $feedRefresh The background feed
- * refresh service
- * used by the
- * on-demand admin
- * `refreshFeeds`
- * action
- * (REQ-BGJOB-FEED-005).
- * @param FooterService $footerService Global footer settings + sanitiser
- * (REQ-FTR-001..010).
- * @param SetupWizardService $setupWizardService Setup-wizard
- * orchestrator
- * (REQ-WIZ-001..011).
- * @param ActionAuthService $actionAuth ADR-023 action authorization.
- * @param TemplateResyncService $resyncService Admin template
- * re-sync
- * orchestrator
- * (REQ-RESYNC-001..005).
- */
- public function __construct(
- IRequest $request,
- private readonly AdminTemplateService $templateService,
- private readonly AdminSettingsService $settingsService,
- private readonly IGroupManager $groupManager,
- private readonly IUserSession $userSession,
- private readonly ExportService $exportService,
- private readonly ImportService $importService,
- private readonly RoleService $roleService,
- private readonly FeedRefreshService $feedRefresh,
- private readonly FooterService $footerService,
- private readonly SetupWizardService $setupWizardService,
- private readonly ActionAuthService $actionAuth,
- private readonly TemplateResyncService $resyncService,
- ) {
- parent::__construct(
- appName: Application::APP_ID,
- request: $request
- );
- }//end __construct()
-
- /**
- * Inline admin guard — checks session and group membership.
- *
- * Returns a 401/403 JSONResponse when the caller is not authenticated or
- * not an NC admin, or null when the guard passes.
- *
- * @return JSONResponse|null Non-null means the request must be rejected.
- */
- private function assertAdmin(): ?JSONResponse
- {
- $user = $this->userSession->getUser();
- if ($user === null) {
- return new JSONResponse(
- data: ['error' => 'Not authenticated'],
- statusCode: Http::STATUS_UNAUTHORIZED
- );
- }
-
- if ($this->groupManager->isAdmin(userId: $user->getUID()) === false) {
- return new JSONResponse(
- data: ['error' => 'Admin required'],
- statusCode: Http::STATUS_FORBIDDEN
- );
- }
-
- return null;
- }//end assertAdmin()
-
- /**
- * List all admin dashboard templates.
- *
- * @return JSONResponse The list of templates.
- *
- * @spec openspec/changes/retrofit-2026-05-24-annotate-launchpad/tasks.md#task-4
- */
- #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
- public function listTemplates(): JSONResponse
- {
- $templates = $this->templateService->listTemplates();
-
- return ResponseHelper::success(
- data: ResponseHelper::serializeList(entities: $templates)
- );
- }//end listTemplates()
-
- /**
- * Get a specific admin template.
- *
- * @param int $id The template ID.
- *
- * @return JSONResponse The template data.
- *
- * @spec openspec/specs/admin-templates/spec.md
- */
- #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
- public function getTemplate(int $id): JSONResponse
- {
- try {
- $result = $this->templateService->getTemplateWithPlacements(
- id: $id
- );
- $placements = ResponseHelper::serializeList(
- entities: $result['placements']
- );
-
- return ResponseHelper::success(
- data: [
- 'template' => $result['template']->jsonSerialize(),
- 'placements' => $placements,
- ]
- );
- } catch (\Exception $e) {
- return ResponseHelper::error(
- exception: $e,
- statusCode: Http::STATUS_NOT_FOUND
- );
- }//end try
- }//end getTemplate()
-
- /**
- * Create a new admin template.
- *
- * @param string $name The template name.
- * @param string|null $description The description.
- * @param array|null $targetGroups The target groups.
- * @param string $permissionLevel The permission level.
- * @param bool $isDefault Whether default.
- *
- * @return JSONResponse The created template.
- *
- * @spec openspec/changes/retrofit-2026-05-24-annotate-launchpad/tasks.md#task-3
- */
- #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
- public function createTemplate(
- string $name,
- ?string $description=null,
- ?array $targetGroups=null,
- string $permissionLevel=Dashboard::PERMISSION_ADD_ONLY,
- bool $isDefault=false
- ): JSONResponse {
- try {
- $template = $this->templateService->createTemplate(
- name: $name,
- description: $description,
- targetGroups: $targetGroups,
- permissionLevel: $permissionLevel,
- isDefault: $isDefault
- );
-
- return ResponseHelper::success(
- data: $template->jsonSerialize(),
- statusCode: Http::STATUS_CREATED
- );
- } catch (\Exception $e) {
- return ResponseHelper::error(exception: $e);
- }//end try
- }//end createTemplate()
-
- /**
- * Update an admin template.
- *
- * @param int $id The template ID.
- * @param string|null $name The name.
- * @param string|null $description The description.
- * @param array|null $targetGroups The target groups.
- * @param string|null $permissionLevel The permission level.
- * @param bool|null $isDefault Whether default.
- * @param int|null $gridColumns The grid columns.
- *
- * @return JSONResponse The updated template.
- *
- * @spec openspec/changes/retrofit-2026-05-24-annotate-launchpad/tasks.md#task-5
- */
- #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
- public function updateTemplate(
- int $id,
- ?string $name=null,
- ?string $description=null,
- ?array $targetGroups=null,
- ?string $permissionLevel=null,
- ?bool $isDefault=null,
- ?int $gridColumns=null
- ): JSONResponse {
- try {
- $data = $this->buildUpdateData(
- name: $name,
- description: $description,
- targetGroups: $targetGroups,
- permissionLevel: $permissionLevel,
- isDefault: $isDefault,
- gridColumns: $gridColumns
- );
-
- $template = $this->templateService->updateTemplate(
- id: $id,
- data: $data
- );
-
- return ResponseHelper::success(
- data: $template->jsonSerialize()
- );
- } catch (\Exception $e) {
- return ResponseHelper::error(exception: $e);
- }//end try
- }//end updateTemplate()
-
- /**
- * Delete an admin template.
- *
- * @param int $id The template ID.
- *
- * @return JSONResponse The deletion confirmation.
- *
- * @spec openspec/changes/retrofit-2026-05-24-annotate-launchpad/tasks.md#task-6
- */
- #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
- public function deleteTemplate(int $id): JSONResponse
- {
- try {
- $this->templateService->deleteTemplate(id: $id);
-
- return ResponseHelper::success(data: ['status' => 'ok']);
- } catch (\Exception $e) {
- return ResponseHelper::error(exception: $e);
- }//end try
- }//end deleteTemplate()
-
- /**
- * Push an updated admin template to its already-provisioned user
- * copies (REQ-RESYNC-001).
- *
- * Body: `{strategy: "overwrite"|"merge", dryRun: bool}`. Dry-run
- * (the default) computes and returns the plan — affected copies plus
- * per-copy add/update/remove/preserve counts — without mutating
- * anything. A real run (`dryRun: false`) applies inline for small
- * target groups or enqueues {@see \OCA\LaunchPad\BackgroundJob\TemplateResyncJob}
- * for large ones, writes one audit record, and notifies every
- * affected user.
- *
- * Admin-guarded twice over — the `AuthorizedAdminSetting` attribute
- * plus the explicit {@see self::assertAdmin()} guard — matching this
- * controller's other mutating admin actions (export/import/footer).
- *
- * @param int $id The admin template's dashboard ID.
- * @param string $strategy `'overwrite'` or `'merge'`.
- * @param bool $dryRun When true (default), report without
- * mutating.
- *
- * @return JSONResponse The plan, the applied result, or the
- * async-accepted envelope. 400 on an invalid
- * strategy or a non-template dashboard; 401/403
- * on guard failure.
- *
- * @spec openspec/specs/admin-templates/spec.md#requirement-req-resync-001-re-sync-action-pushes-template-updates-to-existing-copies
- */
- #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
- public function resyncTemplate(
- int $id,
- string $strategy='',
- bool $dryRun=true
- ): JSONResponse {
- $guard = $this->assertAdmin();
- if ($guard !== null) {
- return $guard;
- }
-
- $user = $this->userSession->getUser();
- $actingAdminId = '';
- if ($user !== null) {
- $actingAdminId = $user->getUID();
- }
-
- try {
- $result = $this->resyncService->resync(
- templateId: $id,
- strategy: $strategy,
- dryRun: $dryRun,
- actingAdminId: $actingAdminId
- );
-
- return ResponseHelper::success(data: $result);
- } catch (InvalidArgumentException $e) {
- return ResponseHelper::error(
- exception: $e,
- statusCode: Http::STATUS_BAD_REQUEST,
- message: $e->getMessage()
- );
- } catch (\Exception $e) {
- return ResponseHelper::error(exception: $e);
- }//end try
- }//end resyncTemplate()
-
- /**
- * Get admin settings.
- *
- * @return JSONResponse The admin settings.
- *
- * @spec openspec/changes/retrofit-2026-05-24-annotate-launchpad/tasks.md#task-1
- */
- #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
- public function getSettings(): JSONResponse
- {
- return ResponseHelper::success(
- data: $this->settingsService->getSettings()
- );
- }//end getSettings()
-
- /**
- * Update admin settings.
- *
- * @param string|null $defaultPermLevel Default permission level.
- * @param bool|null $allowUserDash Allow user dashboards.
- * @param bool|null $allowMultiDash Allow multiple dashboards.
- * @param int|null $defaultGridCols Default grid columns.
- * @param array|null $linkCreateFileExts link-button-widget createFile
- * extension allow-list
- * (REQ-LBN-004).
- * @param string|null $launchpadContentStorage Content storage backend
- * (`database` or
- * `groupfolder`).
- * REQ-GFSB-006.
- * @param string|null $defaultSharePermissionLevel Org-wide default share
- * permission level
- * (dashboard-sharing spec).
- * @param array|null $forcedShareGroups Groups every new dashboard
- * is force-shared with
- * (dashboard-sharing spec).
- * @param bool|null $legacyWidgetBridgeEnabled Enable / disable the
- * legacy widget bridge
- * (legacy-widget-bridge
- * spec).
- * @param int|null $maxDashboardsPerUser Maximum personal
- * dashboards per user
- * (`0` = unlimited).
- * dashboard-quota-limits
- * REQ-QUOTA-001.
- * @param int|null $maxWidgetsPerDashboard Maximum placements per
- * dashboard (`0` =
- * unlimited).
- * dashboard-quota-limits
- * REQ-QUOTA-001.
- * @param string|null $quicksearchFallbackTarget On-dashboard quick-search
- * no-match fallback:
- * `'none'`,
- * `'unified-search'`, or
- * an `https` URL
- * template containing
- * `{query}`.
- * tile-quick-search
- * REQ-QSEARCH-004.
- *
- * @return JSONResponse The update confirmation.
- *
- * @spec openspec/changes/retrofit-2026-05-24-annotate-launchpad/tasks.md#task-2
- */
- #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
- public function updateSettings(
- ?string $defaultPermLevel=null,
- ?bool $allowUserDash=null,
- ?bool $allowMultiDash=null,
- ?int $defaultGridCols=null,
- ?array $linkCreateFileExts=null,
- ?string $launchpadContentStorage=null,
- ?string $defaultSharePermissionLevel=null,
- ?array $forcedShareGroups=null,
- ?bool $legacyWidgetBridgeEnabled=null,
- ?int $maxDashboardsPerUser=null,
- ?int $maxWidgetsPerDashboard=null,
- ?string $quicksearchFallbackTarget=null
- ): JSONResponse {
- try {
- $this->settingsService->updateSettings(
- defaultPermLevel: $defaultPermLevel,
- allowUserDash: $allowUserDash,
- allowMultiDash: $allowMultiDash,
- defaultGridCols: $defaultGridCols,
- linkCreateFileExts: $linkCreateFileExts,
- contentStorage: $launchpadContentStorage,
- defaultSharePermissionLevel: $defaultSharePermissionLevel,
- forcedShareGroups: $forcedShareGroups,
- legacyWidgetBridgeEnabled: $legacyWidgetBridgeEnabled,
- maxDashboardsPerUser: $maxDashboardsPerUser,
- maxWidgetsPerDashboard: $maxWidgetsPerDashboard,
- quicksearchFallbackTarget: $quicksearchFallbackTarget
- );
-
- return ResponseHelper::success(data: ['status' => 'ok']);
- } catch (\InvalidArgumentException $e) {
- return new JSONResponse(
- data: ['error' => $e->getMessage()],
- statusCode: Http::STATUS_BAD_REQUEST
- );
- } catch (\Exception $e) {
- return ResponseHelper::error(exception: $e);
- }//end try
- }//end updateSettings()
-
- /**
- * Read the global footer settings (REQ-FTR-001, REQ-FTR-010).
- *
- * Returns the five footer keys as a flat camelCase object so the
- * admin UI can render the form with one round-trip. Admin-only —
- * non-admins receive HTTP 403 because even the read path discloses
- * potentially-sensitive draft footer copy.
- *
- * @return JSONResponse The settings object, or 401/403 on guard failure.
- *
- * @spec openspec/specs/admin-templates/spec.md
- */
- #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
- public function getFooterSettings(): JSONResponse
- {
- $guard = $this->assertAdmin();
- if ($guard !== null) {
- return $guard;
- }
-
- return ResponseHelper::success(
- data: $this->footerService->getGlobalSettings()
- );
- }//end getFooterSettings()
-
- /**
- * Patch the global footer settings (REQ-FTR-001..003, REQ-FTR-009,
- * REQ-FTR-010).
- *
- * Body: any subset of `{footerEnabled, footerHtml, footerConfig,
- * footerBackgroundColor, footerTextColor}`. The service sanitises
- * HTML, validates the structured-config schema, and validates hex
- * colour strings before persistence. Validation failures map to
- * HTTP 400 (or 413 when the HTML exceeds the 8 KB cap).
- *
- * @param bool|null $footerEnabled Master toggle.
- * @param string|array|null $footerHtml Raw HTML or
- * language-variant
- * map.
- * @param array|null $footerConfig Structured config.
- * @param string|null $footerBackgroundColor Hex (#rrggbb) or null.
- * @param string|null $footerTextColor Hex (#rrggbb) or null.
- *
- * @return JSONResponse Status 200 on success, 400/413 on validation,
- * 401/403 on guard failure.
- *
- * @SuppressWarnings(PHPMD.UnusedFormalParameter) — NC reads params from route declaration;
- * body uses getParams() for array_key_exists semantics.
- *
- * @spec openspec/specs/admin-templates/spec.md
- */
- #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
- public function updateFooterSettings(
- ?bool $footerEnabled=null,
- mixed $footerHtml=null,
- ?array $footerConfig=null,
- ?string $footerBackgroundColor=null,
- ?string $footerTextColor=null
- ): JSONResponse {
- $guard = $this->assertAdmin();
- if ($guard !== null) {
- return $guard;
- }
-
- // Build the patch from only those args that the caller actually
- // supplied — `array_key_exists` semantics on the body let admins
- // explicitly clear a colour by sending `null`.
- $body = $this->request->getParams();
- $patch = [];
- foreach (['footerEnabled', 'footerHtml', 'footerConfig', 'footerBackgroundColor', 'footerTextColor'] as $key) {
- if (array_key_exists(key: $key, array: $body) === true) {
- $patch[$key] = $body[$key];
- }
- }
-
- try {
- $this->footerService->updateGlobalSettings(patch: $patch);
- } catch (InvalidArgumentException $e) {
- $isOversize = str_contains(
- haystack: $e->getMessage(),
- needle: '8 KB limit'
- );
- $status = Http::STATUS_BAD_REQUEST;
- if ($isOversize === true) {
- $status = Http::STATUS_REQUEST_ENTITY_TOO_LARGE;
- }
-
- return new JSONResponse(
- data: ['error' => $e->getMessage()],
- statusCode: $status
- );
- }
-
- return ResponseHelper::success(data: ['status' => 'ok']);
- }//end updateFooterSettings()
-
- /**
- * Export a single dashboard or the entire site as a ZIP archive.
- *
- * Implements REQ-EXIM-002 (single-dashboard export) and REQ-EXIM-003
- * (site export). Admin-only — non-admins receive HTTP 403.
- *
- * Query parameters:
- * - `scope` (string, required): `dashboard` or `site`.
- * - `dashboardUuid` (string, required when scope=dashboard).
- *
- * @param string $scope The export scope.
- * @param string|null $dashboardUuid The dashboard UUID for scope=dashboard.
- *
- * @return StreamResponse|JSONResponse The streamed ZIP, or a JSON error.
- *
- * @spec openspec/specs/admin-templates/spec.md
- */
- #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
- public function export(
- string $scope='site',
- ?string $dashboardUuid=null
- ): StreamResponse|JSONResponse {
- $guard = $this->assertAdmin();
- if ($guard !== null) {
- return $guard;
- }
-
- if (in_array(needle: $scope, haystack: ['site', 'dashboard'], strict: true) === false) {
- return new JSONResponse(
- data: ['error' => 'Unsupported scope: '.$scope],
- statusCode: Http::STATUS_BAD_REQUEST
- );
- }
-
- $userId = (string) $this->userSession->getUser()?->getUID();
-
- if ($scope === 'site') {
- return $this->exportService->exportSite(currentUserId: $userId);
- }
-
- if ($dashboardUuid === null || $dashboardUuid === '') {
- return new JSONResponse(
- data: ['error' => 'dashboardUuid parameter is required when scope=dashboard'],
- statusCode: Http::STATUS_BAD_REQUEST
- );
- }
-
- if (preg_match(pattern: '/^[A-Za-z0-9\-]{8,}$/', subject: $dashboardUuid) !== 1) {
- return new JSONResponse(
- data: ['error' => 'Invalid dashboard UUID format'],
- statusCode: Http::STATUS_BAD_REQUEST
- );
- }
-
- try {
- return $this->exportService->exportDashboard(
- dashboardUuid: $dashboardUuid,
- currentUserId: $userId
- );
- } catch (DoesNotExistException) {
- return new JSONResponse(
- data: ['error' => 'Dashboard not found'],
- statusCode: Http::STATUS_NOT_FOUND
- );
- }
- }//end export()
-
- /**
- * Import a previously-exported ZIP archive.
- *
- * Implements REQ-EXIM-004..008. Admin-only.
- *
- * Multipart body: a `file` field containing the ZIP archive.
- * Query parameter: `preserveUuids` (default false).
- *
- * @param bool $preserveUuids When true, fail on UUID collision.
- *
- * @return JSONResponse The import summary, or an error response.
- *
- * @spec openspec/specs/admin-templates/spec.md
- */
- #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
- public function import(bool $preserveUuids=false): JSONResponse
- {
- $guard = $this->assertAdmin();
- if ($guard !== null) {
- return $guard;
- }
-
- // Multipart uploads bind to $_FILES; PHP only populates this for
- // POST requests, which is what the route declares (REQ-EXIM-004).
- $upload = $_FILES['file'] ?? null;
- if (is_array($upload) === false
- || isset($upload['tmp_name']) === false
- || (string) $upload['tmp_name'] === ''
- ) {
- return new JSONResponse(
- data: ['error' => 'No file uploaded under field "file".'],
- statusCode: Http::STATUS_BAD_REQUEST
- );
- }
-
- $tmpName = (string) $upload['tmp_name'];
-
- $userId = (string) $this->userSession->getUser()?->getUID();
-
- try {
- $result = $this->importService->import(
- zipPath: $tmpName,
- preserveUuids: $preserveUuids,
- currentUserId: $userId
- );
- } catch (InvalidArgumentException $e) {
- return new JSONResponse(
- data: ['error' => $e->getMessage()],
- statusCode: Http::STATUS_BAD_REQUEST
- );
- }
-
- if ($result['status'] === ImportService::ERR_UUID_COLLISION) {
- return new JSONResponse(
- data: [
- 'importedDashboardCount' => 0,
- 'skippedDashboardCount' => 0,
- 'errors' => $result['errors'],
- ],
- statusCode: Http::STATUS_CONFLICT
- );
- }
-
- return ResponseHelper::success(
- data: [
- 'importedDashboardCount' => $result['importedDashboardCount'],
- 'skippedDashboardCount' => $result['skippedDashboardCount'],
- 'errors' => $result['errors'],
- ]
- );
- }//end import()
-
- /**
- * List every role assignment in the system (REQ-ROLE-006). NC-admin only.
- *
- * Returns a JSON array of role-assignment rows with their persisted
- * fields (id, userId, groupId, role, assignedBy, assignedAt). The
- * caller MUST be a Nextcloud admin; non-admins receive HTTP 403.
- *
- * @return JSONResponse The list of role assignments, or 401/403.
- *
- * @spec openspec/specs/admin-templates/spec.md
- */
- #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
- public function listRoles(): JSONResponse
- {
-
- $assignments = $this->roleService->listAssignments();
-
- return ResponseHelper::success(
- data: ResponseHelper::serializeList(entities: $assignments)
- );
- }//end listRoles()
-
- /**
- * Create a new role assignment (REQ-ROLE-004). NC-admin only.
- *
- * Accepts a JSON body `{userId?: string, groupId?: string, role: string}`.
- * Exactly one of `userId` / `groupId` MUST be set. Returns the new
- * assignment with HTTP 201 on success. Returns 400 on structural
- * failure, 409 on duplicate, 401/403 on auth failure.
- *
- * @param string|null $userId The target user ID (XOR with groupId).
- * @param string|null $groupId The target group ID (XOR with userId).
- * @param string|null $role The role name (admin / editor / viewer).
- *
- * @return JSONResponse The created assignment, or an error envelope.
- *
- * @spec openspec/specs/admin-templates/spec.md
- */
- #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
- public function createRole(
- ?string $userId=null,
- ?string $groupId=null,
- ?string $role=null
- ): JSONResponse {
-
- $assignedBy = (string) $this->userSession->getUser()->getUID();
-
- try {
- $assignment = $this->roleService->assignRole(
- userId: $userId,
- groupId: $groupId,
- role: (string) $role,
- assignedBy: $assignedBy
- );
- } catch (DuplicateRoleAssignmentException $e) {
- return new JSONResponse(
- data: [
- 'error' => $e->getDisplayMessage(),
- 'errorCode' => $e->getErrorCode(),
- ],
- statusCode: Http::STATUS_CONFLICT
- );
- } catch (InvalidRoleAssignmentException $e) {
- return new JSONResponse(
- data: [
- 'error' => $e->getDisplayMessage(),
- 'errorCode' => $e->getErrorCode(),
- ],
- statusCode: Http::STATUS_BAD_REQUEST
- );
- }//end try
-
- return ResponseHelper::success(
- data: $assignment->jsonSerialize(),
- statusCode: Http::STATUS_CREATED
- );
- }//end createRole()
-
- /**
- * Delete a role assignment by ID (REQ-ROLE-004). NC-admin only.
- *
- * Returns 204 on success, 404 when no row matches, 401/403 on auth.
- *
- * @param int $id The role assignment ID.
- *
- * @return JSONResponse Empty success or error envelope.
- *
- * @spec openspec/specs/admin-templates/spec.md
- */
- #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
- public function deleteRole(int $id): JSONResponse
- {
-
- try {
- $this->roleService->removeRole(id: $id);
- } catch (DoesNotExistException) {
- return ResponseHelper::forbidden(
- message: 'Role assignment not found'
- )->setStatus(status: Http::STATUS_NOT_FOUND);
- }
-
- return new JSONResponse(
- data: [],
- statusCode: Http::STATUS_NO_CONTENT
- );
- }//end deleteRole()
-
- /**
- * Return the calling user's effective LaunchPad role and source
- * (REQ-ROLE-006). Available to any authenticated user.
- *
- * Response shape: `{role: string|null, source: string|null}`.
- *
- * @return JSONResponse The role / source envelope, or 401.
- *
- * @spec openspec/specs/admin-templates/spec.md
- */
- #[NoAdminRequired]
- public function getMyRole(): JSONResponse
- {
- $user = $this->userSession->getUser();
- if ($user === null) {
- return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED);
- }
-
- try {
- $this->actionAuth->requireAction($user, 'admin.get-my-role');
- } catch (OCSForbiddenException) {
- return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN);
- }
-
- $userId = (string) $user->getUID();
-
- return ResponseHelper::success(
- data: [
- 'role' => $this->roleService->getEffectiveRole(userId: $userId),
- 'source' => $this->roleService->getRoleSource(userId: $userId),
- ]
- );
- }//end getMyRole()
-
- /**
- * Trigger an immediate background feed refresh (REQ-FRJ-010).
- *
- * Admin-only — guarded by {@see self::requireAdmin()}. Optionally
- * scope the refresh to a single feed URL (must be HTTP/HTTPS).
- * Returns `{processedCount, successCount, failureCount, durationMs}`.
- *
- * @param string|null $feedUrl Optional single URL to refresh.
- *
- * @return JSONResponse The aggregate refresh summary.
- *
- * @spec openspec/specs/admin-templates/spec.md
- */
- #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
- public function refreshFeedsNow(?string $feedUrl=null): JSONResponse
- {
- $guard = $this->assertAdmin();
- if ($guard !== null) {
- return $guard;
- }
-
- if ($feedUrl !== null && $feedUrl !== '') {
- $scheme = strtolower(
- string: (string) parse_url(
- url: $feedUrl,
- component: PHP_URL_SCHEME
- )
- );
- if (in_array(needle: $scheme, haystack: ['http', 'https'], strict: true) === false) {
- return new JSONResponse(
- data: [
- 'error' => 'feedUrl must use http:// or https:// scheme.',
- ],
- statusCode: Http::STATUS_BAD_REQUEST
- );
- }
- }
-
- $summary = $this->feedRefresh->refreshAll(onlyUrl: $feedUrl);
-
- return new JSONResponse(data: $summary, statusCode: Http::STATUS_OK);
- }//end refreshFeedsNow()
-
- /**
- * `POST /api/admin/templates/{uuid}/preview-image` — admin-only
- * preview-image upload (REQ-TMPL-017).
- *
- * Body (JSON): `{base64: 'data:image/;base64,'}`. The
- * payload is delegated to {@see ResourceService::upload()} (the
- * "custom-icon-upload pattern"); the returned URL is written to the
- * template's `templatePreviewImage` column. Allowed image types:
- * PNG, JPG, GIF, WebP, SVG (sanitised). Maximum decoded size: 5 MB.
- *
- * @param string $uuid The template UUID.
- * @param string $base64 The base64 data URL.
- *
- * @return JSONResponse `{status: 'success', previewImage: '...'}`
- * on success.
- *
- * @spec openspec/specs/admin-templates/spec.md
- */
- #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
- public function uploadTemplatePreviewImage(
- string $uuid,
- string $base64=''
- ): JSONResponse {
-
- if ($base64 === '') {
- return new JSONResponse(
- data: [
- 'status' => 'error',
- 'error' => 'invalid_payload',
- 'message' => 'Field "base64" is required',
- ],
- statusCode: Http::STATUS_BAD_REQUEST
- );
- }
-
- try {
- $url = $this->templateService->uploadPreviewImage(
- templateUuid: $uuid,
- base64DataUrl: $base64
- );
- } catch (DoesNotExistException $e) {
- return new JSONResponse(
- data: [
- 'status' => 'error',
- 'error' => 'not_found',
- 'message' => 'Template not found',
- ],
- statusCode: Http::STATUS_NOT_FOUND
- );
- } catch (ResourceException $e) {
- // Catches every typed ResourceException subclass (bad data URL,
- // disallowed image format, oversized payload, SVG sanitiser
- // rejection, storage failure) returned by ResourceService::upload
- // — all collapse to a single 400 envelope per REQ-TMPL-017.
- return new JSONResponse(
- data: [
- 'status' => 'error',
- 'error' => 'invalid_image',
- 'message' => $e->getMessage(),
- ],
- statusCode: Http::STATUS_BAD_REQUEST
- );
- }//end try
-
- return new JSONResponse(
- data: [
- 'status' => 'success',
- 'previewImage' => $url,
- ],
- statusCode: Http::STATUS_OK
- );
- }//end uploadTemplatePreviewImage()
-
- /**
- * Get the setup-wizard state (REQ-WIZ-008).
- *
- * Admin-only — non-admins receive HTTP 403. Returns
- * `{complete, currentRecommendedStep, stepStatuses}`.
- *
- * @return JSONResponse The wizard state, or 401/403.
- *
- * @spec openspec/specs/admin-templates/spec.md
- */
- #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
- public function getWizardState(): JSONResponse
- {
- $guard = $this->assertAdmin();
- if ($guard !== null) {
- return $guard;
- }
-
- return ResponseHelper::success(
- data: $this->setupWizardService->getWizardState()
- );
- }//end getWizardState()
-
- /**
- * Mark the setup-wizard complete (REQ-WIZ-009).
- *
- * Idempotent — calling on a completed instance returns 200 with the
- * same payload. Admin-only.
- *
- * @return JSONResponse The post-completion wizard state, or 401/403.
- *
- * @spec openspec/specs/admin-templates/spec.md
- */
- #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
- public function completeWizard(): JSONResponse
- {
- $guard = $this->assertAdmin();
- if ($guard !== null) {
- return $guard;
- }
-
- return ResponseHelper::success(
- data: $this->setupWizardService->markWizardComplete()
- );
- }//end completeWizard()
-
- /**
- * Persist the storage backend choice from Step 2 (REQ-WIZ-003).
- *
- * Validates the selection and writes `launchpad.content_storage`. The
- * GroupFolder option is server-side gated by the `groupfolders` app
- * dependency — selecting it without the app installed returns 400.
- * Admin-only.
- *
- * @param string|null $storage The chosen backend.
- *
- * @return JSONResponse The post-write wizard state, or 400/401/403.
- *
- * @spec openspec/specs/admin-templates/spec.md
- */
- #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
- public function setWizardStorage(?string $storage=null): JSONResponse
- {
- $guard = $this->assertAdmin();
- if ($guard !== null) {
- return $guard;
- }
-
- if ($storage === null || $storage === '') {
- return new JSONResponse(
- data: ['error' => 'Field "storage" is required.'],
- statusCode: Http::STATUS_BAD_REQUEST
- );
- }
-
- if ($storage === SetupWizardService::STORAGE_GROUPFOLDER
- && $this->setupWizardService->hasGroupfolderApp() === false
- ) {
- return new JSONResponse(
- data: ['error' => 'GroupFolder app is not installed.'],
- statusCode: Http::STATUS_BAD_REQUEST
- );
- }
-
- try {
- $this->setupWizardService->setContentStorage(value: $storage);
- } catch (InvalidArgumentException) {
- return new JSONResponse(
- data: ['error' => 'Unsupported storage backend.'],
- statusCode: Http::STATUS_BAD_REQUEST
- );
- }
-
- return ResponseHelper::success(
- data: $this->setupWizardService->getWizardState()
- );
- }//end setWizardStorage()
-
- /**
- * Build the update data array from nullable parameters.
- *
- * @param string|null $name The name.
- * @param string|null $description The description.
- * @param array|null $targetGroups The target groups.
- * @param string|null $permissionLevel The permission level.
- * @param bool|null $isDefault Whether default.
- * @param int|null $gridColumns The grid columns.
- *
- * @return array The non-null update data.
- */
- private function buildUpdateData(
- ?string $name,
- ?string $description,
- ?array $targetGroups,
- ?string $permissionLevel,
- ?bool $isDefault,
- ?int $gridColumns
- ): array {
- $fields = [
- 'name' => $name,
- 'description' => $description,
- 'targetGroups' => $targetGroups,
- 'permissionLevel' => $permissionLevel,
- 'isDefault' => $isDefault,
- 'gridColumns' => $gridColumns,
- ];
-
- return array_filter(
- array: $fields,
- callback: function ($value) {
- return $value !== null;
- }
- );
- }//end buildUpdateData()
+class AdminController extends Controller {
+ /**
+ * Constructor
+ *
+ * @param IRequest $request The request.
+ * @param AdminTemplateService $templateService The admin template service.
+ * @param AdminSettingsService $settingsService The admin settings service.
+ * @param IGroupManager $groupManager The Nextcloud group manager.
+ * @param IUserSession $userSession The current user session.
+ * @param ExportService $exportService ZIP export service
+ * (REQ-EXIM-001..003).
+ * @param ImportService $importService ZIP import service
+ * (REQ-EXIM-004..008).
+ * @param RoleService $roleService The LaunchPad role service
+ * (REQ-ROLE-001..011).
+ * @param FeedRefreshService $feedRefresh The background feed
+ * refresh service
+ * used by the
+ * on-demand admin
+ * `refreshFeeds`
+ * action
+ * (REQ-BGJOB-FEED-005).
+ * @param FooterService $footerService Global footer settings + sanitiser
+ * (REQ-FTR-001..010).
+ * @param SetupWizardService $setupWizardService Setup-wizard
+ * orchestrator
+ * (REQ-WIZ-001..011).
+ * @param ActionAuthService $actionAuth ADR-023 action authorization.
+ * @param TemplateResyncService $resyncService Admin template
+ * re-sync
+ * orchestrator
+ * (REQ-RESYNC-001..005).
+ */
+ public function __construct(
+ IRequest $request,
+ private readonly AdminTemplateService $templateService,
+ private readonly AdminSettingsService $settingsService,
+ private readonly IGroupManager $groupManager,
+ private readonly IUserSession $userSession,
+ private readonly ExportService $exportService,
+ private readonly ImportService $importService,
+ private readonly RoleService $roleService,
+ private readonly FeedRefreshService $feedRefresh,
+ private readonly FooterService $footerService,
+ private readonly SetupWizardService $setupWizardService,
+ private readonly ActionAuthService $actionAuth,
+ private readonly TemplateResyncService $resyncService,
+ ) {
+ parent::__construct(
+ appName: Application::APP_ID,
+ request: $request
+ );
+ }//end __construct()
+
+ /**
+ * Inline admin guard — checks session and group membership.
+ *
+ * Returns a 401/403 JSONResponse when the caller is not authenticated or
+ * not an NC admin, or null when the guard passes.
+ *
+ * @return JSONResponse|null Non-null means the request must be rejected.
+ */
+ private function assertAdmin(): ?JSONResponse {
+ $user = $this->userSession->getUser();
+ if ($user === null) {
+ return new JSONResponse(
+ data: ['error' => 'Not authenticated'],
+ statusCode: Http::STATUS_UNAUTHORIZED
+ );
+ }
+
+ if ($this->groupManager->isAdmin(userId: $user->getUID()) === false) {
+ return new JSONResponse(
+ data: ['error' => 'Admin required'],
+ statusCode: Http::STATUS_FORBIDDEN
+ );
+ }
+
+ return null;
+ }//end assertAdmin()
+
+ /**
+ * List all admin dashboard templates.
+ *
+ * @return JSONResponse The list of templates.
+ *
+ * @spec openspec/changes/retrofit-2026-05-24-annotate-launchpad/tasks.md#task-4
+ */
+ #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
+ public function listTemplates(): JSONResponse {
+ $templates = $this->templateService->listTemplates();
+
+ return ResponseHelper::success(
+ data: ResponseHelper::serializeList(entities: $templates)
+ );
+ }//end listTemplates()
+
+ /**
+ * Get a specific admin template.
+ *
+ * @param int $id The template ID.
+ *
+ * @return JSONResponse The template data.
+ *
+ * @spec openspec/specs/admin-templates/spec.md
+ */
+ #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
+ public function getTemplate(int $id): JSONResponse {
+ try {
+ $result = $this->templateService->getTemplateWithPlacements(
+ id: $id
+ );
+ $placements = ResponseHelper::serializeList(
+ entities: $result['placements']
+ );
+
+ return ResponseHelper::success(
+ data: [
+ 'template' => $result['template']->jsonSerialize(),
+ 'placements' => $placements,
+ ]
+ );
+ } catch (\Exception $e) {
+ return ResponseHelper::error(
+ exception: $e,
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ }//end try
+ }//end getTemplate()
+
+ /**
+ * Create a new admin template.
+ *
+ * @param string $name The template name.
+ * @param string|null $description The description.
+ * @param array|null $targetGroups The target groups.
+ * @param string $permissionLevel The permission level.
+ * @param bool $isDefault Whether default.
+ *
+ * @return JSONResponse The created template.
+ *
+ * @spec openspec/changes/retrofit-2026-05-24-annotate-launchpad/tasks.md#task-3
+ */
+ #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
+ public function createTemplate(
+ string $name,
+ ?string $description = null,
+ ?array $targetGroups = null,
+ string $permissionLevel = Dashboard::PERMISSION_ADD_ONLY,
+ bool $isDefault = false,
+ ): JSONResponse {
+ try {
+ $template = $this->templateService->createTemplate(
+ name: $name,
+ description: $description,
+ targetGroups: $targetGroups,
+ permissionLevel: $permissionLevel,
+ isDefault: $isDefault
+ );
+
+ return ResponseHelper::success(
+ data: $template->jsonSerialize(),
+ statusCode: Http::STATUS_CREATED
+ );
+ } catch (\Exception $e) {
+ return ResponseHelper::error(exception: $e);
+ }//end try
+ }//end createTemplate()
+
+ /**
+ * Update an admin template.
+ *
+ * @param int $id The template ID.
+ * @param string|null $name The name.
+ * @param string|null $description The description.
+ * @param array|null $targetGroups The target groups.
+ * @param string|null $permissionLevel The permission level.
+ * @param bool|null $isDefault Whether default.
+ * @param int|null $gridColumns The grid columns.
+ *
+ * @return JSONResponse The updated template.
+ *
+ * @spec openspec/changes/retrofit-2026-05-24-annotate-launchpad/tasks.md#task-5
+ */
+ #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
+ public function updateTemplate(
+ int $id,
+ ?string $name = null,
+ ?string $description = null,
+ ?array $targetGroups = null,
+ ?string $permissionLevel = null,
+ ?bool $isDefault = null,
+ ?int $gridColumns = null,
+ ): JSONResponse {
+ try {
+ $data = $this->buildUpdateData(
+ name: $name,
+ description: $description,
+ targetGroups: $targetGroups,
+ permissionLevel: $permissionLevel,
+ isDefault: $isDefault,
+ gridColumns: $gridColumns
+ );
+
+ $template = $this->templateService->updateTemplate(
+ id: $id,
+ data: $data
+ );
+
+ return ResponseHelper::success(
+ data: $template->jsonSerialize()
+ );
+ } catch (\Exception $e) {
+ return ResponseHelper::error(exception: $e);
+ }//end try
+ }//end updateTemplate()
+
+ /**
+ * Delete an admin template.
+ *
+ * @param int $id The template ID.
+ *
+ * @return JSONResponse The deletion confirmation.
+ *
+ * @spec openspec/changes/retrofit-2026-05-24-annotate-launchpad/tasks.md#task-6
+ */
+ #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
+ public function deleteTemplate(int $id): JSONResponse {
+ try {
+ $this->templateService->deleteTemplate(id: $id);
+
+ return ResponseHelper::success(data: ['status' => 'ok']);
+ } catch (\Exception $e) {
+ return ResponseHelper::error(exception: $e);
+ }//end try
+ }//end deleteTemplate()
+
+ /**
+ * Push an updated admin template to its already-provisioned user
+ * copies (REQ-RESYNC-001).
+ *
+ * Body: `{strategy: "overwrite"|"merge", dryRun: bool}`. Dry-run
+ * (the default) computes and returns the plan — affected copies plus
+ * per-copy add/update/remove/preserve counts — without mutating
+ * anything. A real run (`dryRun: false`) applies inline for small
+ * target groups or enqueues {@see \OCA\LaunchPad\BackgroundJob\TemplateResyncJob}
+ * for large ones, writes one audit record, and notifies every
+ * affected user.
+ *
+ * Admin-guarded twice over — the `AuthorizedAdminSetting` attribute
+ * plus the explicit {@see self::assertAdmin()} guard — matching this
+ * controller's other mutating admin actions (export/import/footer).
+ *
+ * @param int $id The admin template's dashboard ID.
+ * @param string $strategy `'overwrite'` or `'merge'`.
+ * @param bool $dryRun When true (default), report without
+ * mutating.
+ *
+ * @return JSONResponse The plan, the applied result, or the
+ * async-accepted envelope. 400 on an invalid
+ * strategy or a non-template dashboard; 401/403
+ * on guard failure.
+ *
+ * @spec openspec/specs/admin-templates/spec.md#requirement-req-resync-001-re-sync-action-pushes-template-updates-to-existing-copies
+ */
+ #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
+ public function resyncTemplate(
+ int $id,
+ string $strategy = '',
+ bool $dryRun = true,
+ ): JSONResponse {
+ $guard = $this->assertAdmin();
+ if ($guard !== null) {
+ return $guard;
+ }
+
+ $user = $this->userSession->getUser();
+ $actingAdminId = '';
+ if ($user !== null) {
+ $actingAdminId = $user->getUID();
+ }
+
+ try {
+ $result = $this->resyncService->resync(
+ templateId: $id,
+ strategy: $strategy,
+ dryRun: $dryRun,
+ actingAdminId: $actingAdminId
+ );
+
+ return ResponseHelper::success(data: $result);
+ } catch (InvalidArgumentException $e) {
+ return ResponseHelper::error(
+ exception: $e,
+ statusCode: Http::STATUS_BAD_REQUEST,
+ message: $e->getMessage()
+ );
+ } catch (\Exception $e) {
+ return ResponseHelper::error(exception: $e);
+ }//end try
+ }//end resyncTemplate()
+
+ /**
+ * Get admin settings.
+ *
+ * @return JSONResponse The admin settings.
+ *
+ * @spec openspec/changes/retrofit-2026-05-24-annotate-launchpad/tasks.md#task-1
+ */
+ #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
+ public function getSettings(): JSONResponse {
+ return ResponseHelper::success(
+ data: $this->settingsService->getSettings()
+ );
+ }//end getSettings()
+
+ /**
+ * Update admin settings.
+ *
+ * @param string|null $defaultPermLevel Default permission level.
+ * @param bool|null $allowUserDash Allow user dashboards.
+ * @param bool|null $allowMultiDash Allow multiple dashboards.
+ * @param int|null $defaultGridCols Default grid columns.
+ * @param array|null $linkCreateFileExts link-button-widget createFile
+ * extension allow-list
+ * (REQ-LBN-004).
+ * @param string|null $launchpadContentStorage Content storage backend
+ * (`database` or
+ * `groupfolder`).
+ * REQ-GFSB-006.
+ * @param string|null $defaultSharePermissionLevel Org-wide default share
+ * permission level
+ * (dashboard-sharing spec).
+ * @param array|null $forcedShareGroups Groups every new dashboard
+ * is force-shared with
+ * (dashboard-sharing spec).
+ * @param bool|null $legacyWidgetBridgeEnabled Enable / disable the
+ * legacy widget bridge
+ * (legacy-widget-bridge
+ * spec).
+ * @param int|null $maxDashboardsPerUser Maximum personal
+ * dashboards per user
+ * (`0` = unlimited).
+ * dashboard-quota-limits
+ * REQ-QUOTA-001.
+ * @param int|null $maxWidgetsPerDashboard Maximum placements per
+ * dashboard (`0` =
+ * unlimited).
+ * dashboard-quota-limits
+ * REQ-QUOTA-001.
+ * @param string|null $quicksearchFallbackTarget On-dashboard quick-search
+ * no-match fallback:
+ * `'none'`,
+ * `'unified-search'`, or
+ * an `https` URL
+ * template containing
+ * `{query}`.
+ * tile-quick-search
+ * REQ-QSEARCH-004.
+ *
+ * @return JSONResponse The update confirmation.
+ *
+ * @spec openspec/changes/retrofit-2026-05-24-annotate-launchpad/tasks.md#task-2
+ */
+ #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
+ public function updateSettings(
+ ?string $defaultPermLevel = null,
+ ?bool $allowUserDash = null,
+ ?bool $allowMultiDash = null,
+ ?int $defaultGridCols = null,
+ ?array $linkCreateFileExts = null,
+ ?string $launchpadContentStorage = null,
+ ?string $defaultSharePermissionLevel = null,
+ ?array $forcedShareGroups = null,
+ ?bool $legacyWidgetBridgeEnabled = null,
+ ?int $maxDashboardsPerUser = null,
+ ?int $maxWidgetsPerDashboard = null,
+ ?string $quicksearchFallbackTarget = null,
+ ): JSONResponse {
+ try {
+ $this->settingsService->updateSettings(
+ defaultPermLevel: $defaultPermLevel,
+ allowUserDash: $allowUserDash,
+ allowMultiDash: $allowMultiDash,
+ defaultGridCols: $defaultGridCols,
+ linkCreateFileExts: $linkCreateFileExts,
+ contentStorage: $launchpadContentStorage,
+ defaultSharePermissionLevel: $defaultSharePermissionLevel,
+ forcedShareGroups: $forcedShareGroups,
+ legacyWidgetBridgeEnabled: $legacyWidgetBridgeEnabled,
+ maxDashboardsPerUser: $maxDashboardsPerUser,
+ maxWidgetsPerDashboard: $maxWidgetsPerDashboard,
+ quicksearchFallbackTarget: $quicksearchFallbackTarget
+ );
+
+ return ResponseHelper::success(data: ['status' => 'ok']);
+ } catch (\InvalidArgumentException $e) {
+ return new JSONResponse(
+ data: ['error' => $e->getMessage()],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ } catch (\Exception $e) {
+ return ResponseHelper::error(exception: $e);
+ }//end try
+ }//end updateSettings()
+
+ /**
+ * Read the global footer settings (REQ-FTR-001, REQ-FTR-010).
+ *
+ * Returns the five footer keys as a flat camelCase object so the
+ * admin UI can render the form with one round-trip. Admin-only —
+ * non-admins receive HTTP 403 because even the read path discloses
+ * potentially-sensitive draft footer copy.
+ *
+ * @return JSONResponse The settings object, or 401/403 on guard failure.
+ *
+ * @spec openspec/specs/admin-templates/spec.md
+ */
+ #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
+ public function getFooterSettings(): JSONResponse {
+ $guard = $this->assertAdmin();
+ if ($guard !== null) {
+ return $guard;
+ }
+
+ return ResponseHelper::success(
+ data: $this->footerService->getGlobalSettings()
+ );
+ }//end getFooterSettings()
+
+ /**
+ * Patch the global footer settings (REQ-FTR-001..003, REQ-FTR-009,
+ * REQ-FTR-010).
+ *
+ * Body: any subset of `{footerEnabled, footerHtml, footerConfig,
+ * footerBackgroundColor, footerTextColor}`. The service sanitises
+ * HTML, validates the structured-config schema, and validates hex
+ * colour strings before persistence. Validation failures map to
+ * HTTP 400 (or 413 when the HTML exceeds the 8 KB cap).
+ *
+ * @param bool|null $footerEnabled Master toggle.
+ * @param string|array|null $footerHtml Raw HTML or
+ * language-variant
+ * map.
+ * @param array|null $footerConfig Structured config.
+ * @param string|null $footerBackgroundColor Hex (#rrggbb) or null.
+ * @param string|null $footerTextColor Hex (#rrggbb) or null.
+ *
+ * @return JSONResponse Status 200 on success, 400/413 on validation,
+ * 401/403 on guard failure.
+ *
+ * @SuppressWarnings(PHPMD.UnusedFormalParameter) — NC reads params from route declaration;
+ * body uses getParams() for array_key_exists semantics.
+ *
+ * @spec openspec/specs/admin-templates/spec.md
+ */
+ #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
+ public function updateFooterSettings(
+ ?bool $footerEnabled = null,
+ mixed $footerHtml = null,
+ ?array $footerConfig = null,
+ ?string $footerBackgroundColor = null,
+ ?string $footerTextColor = null,
+ ): JSONResponse {
+ $guard = $this->assertAdmin();
+ if ($guard !== null) {
+ return $guard;
+ }
+
+ // Build the patch from only those args that the caller actually
+ // supplied — `array_key_exists` semantics on the body let admins
+ // explicitly clear a colour by sending `null`.
+ $body = $this->request->getParams();
+ $patch = [];
+ foreach (['footerEnabled', 'footerHtml', 'footerConfig', 'footerBackgroundColor', 'footerTextColor'] as $key) {
+ if (array_key_exists(key: $key, array: $body) === true) {
+ $patch[$key] = $body[$key];
+ }
+ }
+
+ try {
+ $this->footerService->updateGlobalSettings(patch: $patch);
+ } catch (InvalidArgumentException $e) {
+ $isOversize = str_contains(
+ haystack: $e->getMessage(),
+ needle: '8 KB limit'
+ );
+ $status = Http::STATUS_BAD_REQUEST;
+ if ($isOversize === true) {
+ $status = Http::STATUS_REQUEST_ENTITY_TOO_LARGE;
+ }
+
+ return new JSONResponse(
+ data: ['error' => $e->getMessage()],
+ statusCode: $status
+ );
+ }
+
+ return ResponseHelper::success(data: ['status' => 'ok']);
+ }//end updateFooterSettings()
+
+ /**
+ * Export a single dashboard or the entire site as a ZIP archive.
+ *
+ * Implements REQ-EXIM-002 (single-dashboard export) and REQ-EXIM-003
+ * (site export). Admin-only — non-admins receive HTTP 403.
+ *
+ * Query parameters:
+ * - `scope` (string, required): `dashboard` or `site`.
+ * - `dashboardUuid` (string, required when scope=dashboard).
+ *
+ * @param string $scope The export scope.
+ * @param string|null $dashboardUuid The dashboard UUID for scope=dashboard.
+ *
+ * @return StreamResponse|JSONResponse The streamed ZIP, or a JSON error.
+ *
+ * @spec openspec/specs/admin-templates/spec.md
+ */
+ #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
+ public function export(
+ string $scope = 'site',
+ ?string $dashboardUuid = null,
+ ): StreamResponse|JSONResponse {
+ $guard = $this->assertAdmin();
+ if ($guard !== null) {
+ return $guard;
+ }
+
+ if (in_array(needle: $scope, haystack: ['site', 'dashboard'], strict: true) === false) {
+ return new JSONResponse(
+ data: ['error' => 'Unsupported scope: ' . $scope],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ }
+
+ $userId = (string)$this->userSession->getUser()?->getUID();
+
+ if ($scope === 'site') {
+ return $this->exportService->exportSite(currentUserId: $userId);
+ }
+
+ if ($dashboardUuid === null || $dashboardUuid === '') {
+ return new JSONResponse(
+ data: ['error' => 'dashboardUuid parameter is required when scope=dashboard'],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ }
+
+ if (preg_match(pattern: '/^[A-Za-z0-9\-]{8,}$/', subject: $dashboardUuid) !== 1) {
+ return new JSONResponse(
+ data: ['error' => 'Invalid dashboard UUID format'],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ }
+
+ try {
+ return $this->exportService->exportDashboard(
+ dashboardUuid: $dashboardUuid,
+ currentUserId: $userId
+ );
+ } catch (DoesNotExistException) {
+ return new JSONResponse(
+ data: ['error' => 'Dashboard not found'],
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ }
+ }//end export()
+
+ /**
+ * Import a previously-exported ZIP archive.
+ *
+ * Implements REQ-EXIM-004..008. Admin-only.
+ *
+ * Multipart body: a `file` field containing the ZIP archive.
+ * Query parameter: `preserveUuids` (default false).
+ *
+ * @param bool $preserveUuids When true, fail on UUID collision.
+ *
+ * @return JSONResponse The import summary, or an error response.
+ *
+ * @spec openspec/specs/admin-templates/spec.md
+ */
+ #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
+ public function import(bool $preserveUuids = false): JSONResponse {
+ $guard = $this->assertAdmin();
+ if ($guard !== null) {
+ return $guard;
+ }
+
+ // Multipart uploads bind to $_FILES; PHP only populates this for
+ // POST requests, which is what the route declares (REQ-EXIM-004).
+ $upload = $_FILES['file'] ?? null;
+ if (is_array($upload) === false
+ || isset($upload['tmp_name']) === false
+ || (string)$upload['tmp_name'] === ''
+ ) {
+ return new JSONResponse(
+ data: ['error' => 'No file uploaded under field "file".'],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ }
+
+ $tmpName = (string)$upload['tmp_name'];
+
+ $userId = (string)$this->userSession->getUser()?->getUID();
+
+ try {
+ $result = $this->importService->import(
+ zipPath: $tmpName,
+ preserveUuids: $preserveUuids,
+ currentUserId: $userId
+ );
+ } catch (InvalidArgumentException $e) {
+ return new JSONResponse(
+ data: ['error' => $e->getMessage()],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ }
+
+ if ($result['status'] === ImportService::ERR_UUID_COLLISION) {
+ return new JSONResponse(
+ data: [
+ 'importedDashboardCount' => 0,
+ 'skippedDashboardCount' => 0,
+ 'errors' => $result['errors'],
+ ],
+ statusCode: Http::STATUS_CONFLICT
+ );
+ }
+
+ return ResponseHelper::success(
+ data: [
+ 'importedDashboardCount' => $result['importedDashboardCount'],
+ 'skippedDashboardCount' => $result['skippedDashboardCount'],
+ 'errors' => $result['errors'],
+ ]
+ );
+ }//end import()
+
+ /**
+ * List every role assignment in the system (REQ-ROLE-006). NC-admin only.
+ *
+ * Returns a JSON array of role-assignment rows with their persisted
+ * fields (id, userId, groupId, role, assignedBy, assignedAt). The
+ * caller MUST be a Nextcloud admin; non-admins receive HTTP 403.
+ *
+ * @return JSONResponse The list of role assignments, or 401/403.
+ *
+ * @spec openspec/specs/admin-templates/spec.md
+ */
+ #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
+ public function listRoles(): JSONResponse {
+
+ $assignments = $this->roleService->listAssignments();
+
+ return ResponseHelper::success(
+ data: ResponseHelper::serializeList(entities: $assignments)
+ );
+ }//end listRoles()
+
+ /**
+ * Create a new role assignment (REQ-ROLE-004). NC-admin only.
+ *
+ * Accepts a JSON body `{userId?: string, groupId?: string, role: string}`.
+ * Exactly one of `userId` / `groupId` MUST be set. Returns the new
+ * assignment with HTTP 201 on success. Returns 400 on structural
+ * failure, 409 on duplicate, 401/403 on auth failure.
+ *
+ * @param string|null $userId The target user ID (XOR with groupId).
+ * @param string|null $groupId The target group ID (XOR with userId).
+ * @param string|null $role The role name (admin / editor / viewer).
+ *
+ * @return JSONResponse The created assignment, or an error envelope.
+ *
+ * @spec openspec/specs/admin-templates/spec.md
+ */
+ #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
+ public function createRole(
+ ?string $userId = null,
+ ?string $groupId = null,
+ ?string $role = null,
+ ): JSONResponse {
+
+ $assignedBy = (string)$this->userSession->getUser()->getUID();
+
+ try {
+ $assignment = $this->roleService->assignRole(
+ userId: $userId,
+ groupId: $groupId,
+ role: (string)$role,
+ assignedBy: $assignedBy
+ );
+ } catch (DuplicateRoleAssignmentException $e) {
+ return new JSONResponse(
+ data: [
+ 'error' => $e->getDisplayMessage(),
+ 'errorCode' => $e->getErrorCode(),
+ ],
+ statusCode: Http::STATUS_CONFLICT
+ );
+ } catch (InvalidRoleAssignmentException $e) {
+ return new JSONResponse(
+ data: [
+ 'error' => $e->getDisplayMessage(),
+ 'errorCode' => $e->getErrorCode(),
+ ],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ }//end try
+
+ return ResponseHelper::success(
+ data: $assignment->jsonSerialize(),
+ statusCode: Http::STATUS_CREATED
+ );
+ }//end createRole()
+
+ /**
+ * Delete a role assignment by ID (REQ-ROLE-004). NC-admin only.
+ *
+ * Returns 204 on success, 404 when no row matches, 401/403 on auth.
+ *
+ * @param int $id The role assignment ID.
+ *
+ * @return JSONResponse Empty success or error envelope.
+ *
+ * @spec openspec/specs/admin-templates/spec.md
+ */
+ #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
+ public function deleteRole(int $id): JSONResponse {
+
+ try {
+ $this->roleService->removeRole(id: $id);
+ } catch (DoesNotExistException) {
+ return ResponseHelper::forbidden(
+ message: 'Role assignment not found'
+ )->setStatus(status: Http::STATUS_NOT_FOUND);
+ }
+
+ return new JSONResponse(
+ data: [],
+ statusCode: Http::STATUS_NO_CONTENT
+ );
+ }//end deleteRole()
+
+ /**
+ * Return the calling user's effective LaunchPad role and source
+ * (REQ-ROLE-006). Available to any authenticated user.
+ *
+ * Response shape: `{role: string|null, source: string|null}`.
+ *
+ * @return JSONResponse The role / source envelope, or 401.
+ *
+ * @spec openspec/specs/admin-templates/spec.md
+ */
+ #[NoAdminRequired]
+ public function getMyRole(): JSONResponse {
+ $user = $this->userSession->getUser();
+ if ($user === null) {
+ return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED);
+ }
+
+ try {
+ $this->actionAuth->requireAction($user, 'admin.get-my-role');
+ } catch (OCSForbiddenException) {
+ return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN);
+ }
+
+ $userId = (string)$user->getUID();
+
+ return ResponseHelper::success(
+ data: [
+ 'role' => $this->roleService->getEffectiveRole(userId: $userId),
+ 'source' => $this->roleService->getRoleSource(userId: $userId),
+ ]
+ );
+ }//end getMyRole()
+
+ /**
+ * Trigger an immediate background feed refresh (REQ-FRJ-010).
+ *
+ * Admin-only — guarded by {@see self::requireAdmin()}. Optionally
+ * scope the refresh to a single feed URL (must be HTTP/HTTPS).
+ * Returns `{processedCount, successCount, failureCount, durationMs}`.
+ *
+ * @param string|null $feedUrl Optional single URL to refresh.
+ *
+ * @return JSONResponse The aggregate refresh summary.
+ *
+ * @spec openspec/specs/admin-templates/spec.md
+ */
+ #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
+ public function refreshFeedsNow(?string $feedUrl = null): JSONResponse {
+ $guard = $this->assertAdmin();
+ if ($guard !== null) {
+ return $guard;
+ }
+
+ if ($feedUrl !== null && $feedUrl !== '') {
+ $scheme = strtolower(
+ string: (string)parse_url(
+ url: $feedUrl,
+ component: PHP_URL_SCHEME
+ )
+ );
+ if (in_array(needle: $scheme, haystack: ['http', 'https'], strict: true) === false) {
+ return new JSONResponse(
+ data: [
+ 'error' => 'feedUrl must use http:// or https:// scheme.',
+ ],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ }
+ }
+
+ $summary = $this->feedRefresh->refreshAll(onlyUrl: $feedUrl);
+
+ return new JSONResponse(data: $summary, statusCode: Http::STATUS_OK);
+ }//end refreshFeedsNow()
+
+ /**
+ * `POST /api/admin/templates/{uuid}/preview-image` — admin-only
+ * preview-image upload (REQ-TMPL-017).
+ *
+ * Body (JSON): `{base64: 'data:image/;base64,'}`. The
+ * payload is delegated to {@see ResourceService::upload()} (the
+ * "custom-icon-upload pattern"); the returned URL is written to the
+ * template's `templatePreviewImage` column. Allowed image types:
+ * PNG, JPG, GIF, WebP, SVG (sanitised). Maximum decoded size: 5 MB.
+ *
+ * @param string $uuid The template UUID.
+ * @param string $base64 The base64 data URL.
+ *
+ * @return JSONResponse `{status: 'success', previewImage: '...'}`
+ * on success.
+ *
+ * @spec openspec/specs/admin-templates/spec.md
+ */
+ #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
+ public function uploadTemplatePreviewImage(
+ string $uuid,
+ string $base64 = '',
+ ): JSONResponse {
+
+ if ($base64 === '') {
+ return new JSONResponse(
+ data: [
+ 'status' => 'error',
+ 'error' => 'invalid_payload',
+ 'message' => 'Field "base64" is required',
+ ],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ }
+
+ try {
+ $url = $this->templateService->uploadPreviewImage(
+ templateUuid: $uuid,
+ base64DataUrl: $base64
+ );
+ } catch (DoesNotExistException $e) {
+ return new JSONResponse(
+ data: [
+ 'status' => 'error',
+ 'error' => 'not_found',
+ 'message' => 'Template not found',
+ ],
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ } catch (ResourceException $e) {
+ // Catches every typed ResourceException subclass (bad data URL,
+ // disallowed image format, oversized payload, SVG sanitiser
+ // rejection, storage failure) returned by ResourceService::upload
+ // — all collapse to a single 400 envelope per REQ-TMPL-017.
+ return new JSONResponse(
+ data: [
+ 'status' => 'error',
+ 'error' => 'invalid_image',
+ 'message' => $e->getMessage(),
+ ],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ }//end try
+
+ return new JSONResponse(
+ data: [
+ 'status' => 'success',
+ 'previewImage' => $url,
+ ],
+ statusCode: Http::STATUS_OK
+ );
+ }//end uploadTemplatePreviewImage()
+
+ /**
+ * Get the setup-wizard state (REQ-WIZ-008).
+ *
+ * Admin-only — non-admins receive HTTP 403. Returns
+ * `{complete, currentRecommendedStep, stepStatuses}`.
+ *
+ * @return JSONResponse The wizard state, or 401/403.
+ *
+ * @spec openspec/specs/admin-templates/spec.md
+ */
+ #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
+ public function getWizardState(): JSONResponse {
+ $guard = $this->assertAdmin();
+ if ($guard !== null) {
+ return $guard;
+ }
+
+ return ResponseHelper::success(
+ data: $this->setupWizardService->getWizardState()
+ );
+ }//end getWizardState()
+
+ /**
+ * Mark the setup-wizard complete (REQ-WIZ-009).
+ *
+ * Idempotent — calling on a completed instance returns 200 with the
+ * same payload. Admin-only.
+ *
+ * @return JSONResponse The post-completion wizard state, or 401/403.
+ *
+ * @spec openspec/specs/admin-templates/spec.md
+ */
+ #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
+ public function completeWizard(): JSONResponse {
+ $guard = $this->assertAdmin();
+ if ($guard !== null) {
+ return $guard;
+ }
+
+ return ResponseHelper::success(
+ data: $this->setupWizardService->markWizardComplete()
+ );
+ }//end completeWizard()
+
+ /**
+ * Persist the storage backend choice from Step 2 (REQ-WIZ-003).
+ *
+ * Validates the selection and writes `launchpad.content_storage`. The
+ * GroupFolder option is server-side gated by the `groupfolders` app
+ * dependency — selecting it without the app installed returns 400.
+ * Admin-only.
+ *
+ * @param string|null $storage The chosen backend.
+ *
+ * @return JSONResponse The post-write wizard state, or 400/401/403.
+ *
+ * @spec openspec/specs/admin-templates/spec.md
+ */
+ #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
+ public function setWizardStorage(?string $storage = null): JSONResponse {
+ $guard = $this->assertAdmin();
+ if ($guard !== null) {
+ return $guard;
+ }
+
+ if ($storage === null || $storage === '') {
+ return new JSONResponse(
+ data: ['error' => 'Field "storage" is required.'],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ }
+
+ if ($storage === SetupWizardService::STORAGE_GROUPFOLDER
+ && $this->setupWizardService->hasGroupfolderApp() === false
+ ) {
+ return new JSONResponse(
+ data: ['error' => 'GroupFolder app is not installed.'],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ }
+
+ try {
+ $this->setupWizardService->setContentStorage(value: $storage);
+ } catch (InvalidArgumentException) {
+ return new JSONResponse(
+ data: ['error' => 'Unsupported storage backend.'],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ }
+
+ return ResponseHelper::success(
+ data: $this->setupWizardService->getWizardState()
+ );
+ }//end setWizardStorage()
+
+ /**
+ * Build the update data array from nullable parameters.
+ *
+ * @param string|null $name The name.
+ * @param string|null $description The description.
+ * @param array|null $targetGroups The target groups.
+ * @param string|null $permissionLevel The permission level.
+ * @param bool|null $isDefault Whether default.
+ * @param int|null $gridColumns The grid columns.
+ *
+ * @return array The non-null update data.
+ */
+ private function buildUpdateData(
+ ?string $name,
+ ?string $description,
+ ?array $targetGroups,
+ ?string $permissionLevel,
+ ?bool $isDefault,
+ ?int $gridColumns,
+ ): array {
+ $fields = [
+ 'name' => $name,
+ 'description' => $description,
+ 'targetGroups' => $targetGroups,
+ 'permissionLevel' => $permissionLevel,
+ 'isDefault' => $isDefault,
+ 'gridColumns' => $gridColumns,
+ ];
+
+ return array_filter(
+ array: $fields,
+ callback: function ($value) {
+ return $value !== null;
+ }
+ );
+ }//end buildUpdateData()
}//end class
diff --git a/lib/Controller/AdminDemoShowcasesController.php b/lib/Controller/AdminDemoShowcasesController.php
index 74151c73..2d0cb62c 100644
--- a/lib/Controller/AdminDemoShowcasesController.php
+++ b/lib/Controller/AdminDemoShowcasesController.php
@@ -48,180 +48,176 @@
/**
* Admin endpoints for managing bundled demo showcase dashboards.
*/
-class AdminDemoShowcasesController extends Controller
-{
- /**
- * Constructor.
- *
- * @param IRequest $request The HTTP request.
- * @param DemoShowcasesService $showcasesSvc Showcase service.
- * @param IUserSession $userSession Active user session.
- * @param IGroupManager $groupManager Admin check.
- * @param LoggerInterface $logger PSR-3 logger.
- */
- public function __construct(
- IRequest $request,
- private readonly DemoShowcasesService $showcasesSvc,
- private readonly IUserSession $userSession,
- private readonly IGroupManager $groupManager,
- private readonly LoggerInterface $logger,
- ) {
- parent::__construct(
- appName: Application::APP_ID,
- request: $request
- );
- }//end __construct()
-
- /**
- * Inline admin guard.
- *
- * @return JSONResponse|null Non-null = caller must be rejected.
- */
- private function assertAdmin(): ?JSONResponse
- {
- $user = $this->userSession->getUser();
- if ($user === null) {
- return new JSONResponse(
- data: ['error' => 'Not authenticated'],
- statusCode: Http::STATUS_UNAUTHORIZED
- );
- }
-
- if ($this->groupManager->isAdmin(userId: $user->getUID()) === false) {
- return new JSONResponse(
- data: ['error' => 'Admin required'],
- statusCode: Http::STATUS_FORBIDDEN
- );
- }
-
- return null;
- }//end assertAdmin()
-
- /**
- * List bundled showcases with installation status (REQ-DEMO-002).
- *
- * @return JSONResponse Showcase descriptors, or 401/403.
- *
- * @spec openspec/specs/demo-data-showcases/spec.md
- */
- #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
- public function index(): JSONResponse
- {
- $guard = $this->assertAdmin();
- if ($guard !== null) {
- return $guard;
- }
-
- return ResponseHelper::success(
- data: $this->showcasesSvc->getAvailableShowcases()
- );
- }//end index()
-
- /**
- * Install a bundled showcase (REQ-DEMO-003, REQ-DEMO-004).
- *
- * Always returns the dashboard UUID — when the showcase is
- * already installed and `force` is unset, the existing UUID is
- * returned with `alreadyInstalled: true` so callers can render an
- * informational banner.
- *
- * @param string $id The showcase ID (path segment).
- * @param string $lang Optional locale (always resolves to `nl`
- * in v1; REQ-DEMO-007).
- * @param bool $force Force reinstallation, removing the existing
- * dashboard if any.
- *
- * @return JSONResponse The install result, or an error.
- *
- * @spec openspec/specs/demo-data-showcases/spec.md
- */
- #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
- public function install(
- string $id,
- string $lang='nl',
- bool $force=false
- ): JSONResponse {
- $guard = $this->assertAdmin();
- if ($guard !== null) {
- return $guard;
- }
-
- try {
- $result = $this->showcasesSvc->installShowcase(
- showcaseId: $id,
- lang: $lang,
- force: $force
- );
- } catch (ShowcaseNotFoundException $e) {
- return new JSONResponse(
- data: ['error' => 'Showcase not found'],
- statusCode: Http::STATUS_NOT_FOUND
- );
- } catch (Throwable $e) {
- $this->logger->error(
- message: 'Showcase install failed',
- context: [
- 'showcaseId' => $id,
- 'exception' => $e,
- ]
- );
- return new JSONResponse(
- data: ['error' => 'Showcase installation failed'],
- statusCode: Http::STATUS_INTERNAL_SERVER_ERROR
- );
- }//end try
-
- $statusCode = Http::STATUS_CREATED;
- if ($result['alreadyInstalled'] === true) {
- $statusCode = Http::STATUS_OK;
- }
-
- return new JSONResponse(
- data: [
- 'installedDashboardUuid' => $result['installedDashboardUuid'],
- 'skippedWidgets' => $result['skippedWidgets'],
- 'alreadyInstalled' => $result['alreadyInstalled'],
- ],
- statusCode: $statusCode
- );
- }//end install()
-
- /**
- * Uninstall a previously-installed showcase (REQ-DEMO-006).
- *
- * Idempotent — returns 204 even when the showcase is not currently
- * installed.
- *
- * @param string $id The showcase ID (path segment).
- *
- * @return JSONResponse Empty 204, or 401/403.
- *
- * @spec openspec/specs/demo-data-showcases/spec.md
- */
- #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
- public function destroy(string $id): JSONResponse
- {
- $guard = $this->assertAdmin();
- if ($guard !== null) {
- return $guard;
- }
-
- try {
- $this->showcasesSvc->uninstallShowcase(showcaseId: $id);
- } catch (Throwable $e) {
- $this->logger->error(
- message: 'Showcase uninstall failed',
- context: [
- 'showcaseId' => $id,
- 'exception' => $e,
- ]
- );
- return new JSONResponse(
- data: ['error' => 'Showcase uninstall failed'],
- statusCode: Http::STATUS_INTERNAL_SERVER_ERROR
- );
- }
-
- return new JSONResponse(data: [], statusCode: Http::STATUS_NO_CONTENT);
- }//end destroy()
+class AdminDemoShowcasesController extends Controller {
+ /**
+ * Constructor.
+ *
+ * @param IRequest $request The HTTP request.
+ * @param DemoShowcasesService $showcasesSvc Showcase service.
+ * @param IUserSession $userSession Active user session.
+ * @param IGroupManager $groupManager Admin check.
+ * @param LoggerInterface $logger PSR-3 logger.
+ */
+ public function __construct(
+ IRequest $request,
+ private readonly DemoShowcasesService $showcasesSvc,
+ private readonly IUserSession $userSession,
+ private readonly IGroupManager $groupManager,
+ private readonly LoggerInterface $logger,
+ ) {
+ parent::__construct(
+ appName: Application::APP_ID,
+ request: $request
+ );
+ }//end __construct()
+
+ /**
+ * Inline admin guard.
+ *
+ * @return JSONResponse|null Non-null = caller must be rejected.
+ */
+ private function assertAdmin(): ?JSONResponse {
+ $user = $this->userSession->getUser();
+ if ($user === null) {
+ return new JSONResponse(
+ data: ['error' => 'Not authenticated'],
+ statusCode: Http::STATUS_UNAUTHORIZED
+ );
+ }
+
+ if ($this->groupManager->isAdmin(userId: $user->getUID()) === false) {
+ return new JSONResponse(
+ data: ['error' => 'Admin required'],
+ statusCode: Http::STATUS_FORBIDDEN
+ );
+ }
+
+ return null;
+ }//end assertAdmin()
+
+ /**
+ * List bundled showcases with installation status (REQ-DEMO-002).
+ *
+ * @return JSONResponse Showcase descriptors, or 401/403.
+ *
+ * @spec openspec/specs/demo-data-showcases/spec.md
+ */
+ #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
+ public function index(): JSONResponse {
+ $guard = $this->assertAdmin();
+ if ($guard !== null) {
+ return $guard;
+ }
+
+ return ResponseHelper::success(
+ data: $this->showcasesSvc->getAvailableShowcases()
+ );
+ }//end index()
+
+ /**
+ * Install a bundled showcase (REQ-DEMO-003, REQ-DEMO-004).
+ *
+ * Always returns the dashboard UUID — when the showcase is
+ * already installed and `force` is unset, the existing UUID is
+ * returned with `alreadyInstalled: true` so callers can render an
+ * informational banner.
+ *
+ * @param string $id The showcase ID (path segment).
+ * @param string $lang Optional locale (always resolves to `nl`
+ * in v1; REQ-DEMO-007).
+ * @param bool $force Force reinstallation, removing the existing
+ * dashboard if any.
+ *
+ * @return JSONResponse The install result, or an error.
+ *
+ * @spec openspec/specs/demo-data-showcases/spec.md
+ */
+ #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
+ public function install(
+ string $id,
+ string $lang = 'nl',
+ bool $force = false,
+ ): JSONResponse {
+ $guard = $this->assertAdmin();
+ if ($guard !== null) {
+ return $guard;
+ }
+
+ try {
+ $result = $this->showcasesSvc->installShowcase(
+ showcaseId: $id,
+ lang: $lang,
+ force: $force
+ );
+ } catch (ShowcaseNotFoundException $e) {
+ return new JSONResponse(
+ data: ['error' => 'Showcase not found'],
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ } catch (Throwable $e) {
+ $this->logger->error(
+ message: 'Showcase install failed',
+ context: [
+ 'showcaseId' => $id,
+ 'exception' => $e,
+ ]
+ );
+ return new JSONResponse(
+ data: ['error' => 'Showcase installation failed'],
+ statusCode: Http::STATUS_INTERNAL_SERVER_ERROR
+ );
+ }//end try
+
+ $statusCode = Http::STATUS_CREATED;
+ if ($result['alreadyInstalled'] === true) {
+ $statusCode = Http::STATUS_OK;
+ }
+
+ return new JSONResponse(
+ data: [
+ 'installedDashboardUuid' => $result['installedDashboardUuid'],
+ 'skippedWidgets' => $result['skippedWidgets'],
+ 'alreadyInstalled' => $result['alreadyInstalled'],
+ ],
+ statusCode: $statusCode
+ );
+ }//end install()
+
+ /**
+ * Uninstall a previously-installed showcase (REQ-DEMO-006).
+ *
+ * Idempotent — returns 204 even when the showcase is not currently
+ * installed.
+ *
+ * @param string $id The showcase ID (path segment).
+ *
+ * @return JSONResponse Empty 204, or 401/403.
+ *
+ * @spec openspec/specs/demo-data-showcases/spec.md
+ */
+ #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
+ public function destroy(string $id): JSONResponse {
+ $guard = $this->assertAdmin();
+ if ($guard !== null) {
+ return $guard;
+ }
+
+ try {
+ $this->showcasesSvc->uninstallShowcase(showcaseId: $id);
+ } catch (Throwable $e) {
+ $this->logger->error(
+ message: 'Showcase uninstall failed',
+ context: [
+ 'showcaseId' => $id,
+ 'exception' => $e,
+ ]
+ );
+ return new JSONResponse(
+ data: ['error' => 'Showcase uninstall failed'],
+ statusCode: Http::STATUS_INTERNAL_SERVER_ERROR
+ );
+ }
+
+ return new JSONResponse(data: [], statusCode: Http::STATUS_NO_CONTENT);
+ }//end destroy()
}//end class
diff --git a/lib/Controller/AdminOrgNavigationController.php b/lib/Controller/AdminOrgNavigationController.php
index f95a679d..1df3eba1 100644
--- a/lib/Controller/AdminOrgNavigationController.php
+++ b/lib/Controller/AdminOrgNavigationController.php
@@ -52,289 +52,282 @@
/**
* Org-wide navigation editor REST surface.
*/
-class AdminOrgNavigationController extends Controller
-{
- /**
- * Setting key for the global navigation rail position
- * (REQ-ONAV-004). Stored in `launchpad_admin_settings` rather than
- * `IAppData` because it is a scalar enum, not a tree.
- *
- * @var string
- */
- public const SETTING_KEY_POSITION = 'org_navigation_position';
-
- /**
- * Allowed values for the position setting (REQ-ONAV-004).
- *
- * @var array
- */
- public const ALLOWED_POSITIONS = ['left', 'right', 'top', 'hidden'];
-
- /**
- * Default position when the setting is unset (REQ-ONAV-004).
- *
- * @var string
- */
- public const DEFAULT_POSITION = 'hidden';
-
- /**
- * Constructor.
- *
- * @param IRequest $request Inbound request.
- * @param OrgNavigationService $service Tree storage + filter
- * service.
- * @param AdminSettingMapper $settings Persistence layer for
- * the position scalar.
- * @param IUserSession $userSession Current user session.
- * @param IGroupManager $groupManager Admin check for write endpoints.
- * @param ActionAuthService $actionAuth ADR-023 action authorization.
- */
- public function __construct(
- IRequest $request,
- private readonly OrgNavigationService $service,
- private readonly AdminSettingMapper $settings,
- private readonly IUserSession $userSession,
- private readonly IGroupManager $groupManager,
- private readonly ActionAuthService $actionAuth,
- ) {
- parent::__construct(
- appName: Application::APP_ID,
- request: $request
- );
- }//end __construct()
-
- /**
- * Inline admin guard (returns null when the caller is an NC admin).
- *
- * @return JSONResponse|null Non-null = caller must be rejected.
- */
- private function assertAdmin(): ?JSONResponse
- {
- $user = $this->userSession->getUser();
- if ($user === null) {
- return new JSONResponse(
- data: ['error' => 'Not authenticated'],
- statusCode: Http::STATUS_UNAUTHORIZED
- );
- }
-
- if ($this->groupManager->isAdmin(userId: $user->getUID()) === false) {
- return new JSONResponse(
- data: ['error' => 'Admin required'],
- statusCode: Http::STATUS_FORBIDDEN
- );
- }
-
- return null;
- }//end assertAdmin()
-
- /**
- * Read the org-navigation tree filtered for the current user.
- *
- * Accessible to any logged-in user (REQ-ONAV-002).
- *
- * @param string $lang Language code (defaults to `nl`).
- *
- * @return JSONResponse The filtered tree under `tree` plus the
- * effective `language`.
- *
- * @spec openspec/specs/navigation-editor-org/spec.md
- */
- #[NoAdminRequired]
- public function getOrgNavigation(string $lang=OrgNavigationService::DEFAULT_LANGUAGE): JSONResponse
- {
- $user = $this->userSession->getUser();
- if ($user === null) {
- return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED);
- }
-
- try {
- $this->actionAuth->requireAction($user, 'admin-org-navigation.get-org-navigation');
- } catch (OCSForbiddenException) {
- return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN);
- }
-
- $language = $this->validateLanguage(language: $lang);
- if ($language === null) {
- return new JSONResponse(
- data: ['error' => 'Unsupported language'],
- statusCode: Http::STATUS_BAD_REQUEST
- );
- }
-
- $tree = $this->service->getTree(language: $language);
- $filtered = $this->service->filterTreeByUserGroups(
- tree: $tree,
- userId: $user->getUID()
- );
-
- return ResponseHelper::success(
- data: [
- 'tree' => $filtered,
- 'language' => $language,
- ]
- );
- }//end getOrgNavigation()
-
- /**
- * Replace the org-navigation tree for the given language.
- *
- * Admin-only (REQ-ONAV-003); validates and persists in one go.
- *
- * @param array|null $tree The full replacement tree.
- * @param string $lang Language code (defaults to `nl`).
- *
- * @return JSONResponse The persisted tree (unchanged) on success.
- *
- * @spec openspec/specs/navigation-editor-org/spec.md
- */
- #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
- public function updateOrgNavigation(
- ?array $tree=null,
- string $lang=OrgNavigationService::DEFAULT_LANGUAGE
- ): JSONResponse {
- $guard = $this->assertAdmin();
- if ($guard !== null) {
- return $guard;
- }
-
- $language = $this->validateLanguage(language: $lang);
- if ($language === null) {
- return new JSONResponse(
- data: ['error' => 'Unsupported language'],
- statusCode: Http::STATUS_BAD_REQUEST
- );
- }
-
- if (is_array($tree) === false) {
- return new JSONResponse(
- data: ['error' => 'tree must be an array of node objects'],
- statusCode: Http::STATUS_BAD_REQUEST
- );
- }
-
- try {
- $this->service->setTree(tree: $tree, language: $language);
- } catch (InvalidArgumentException $e) {
- return new JSONResponse(
- data: ['error' => $e->getMessage()],
- statusCode: Http::STATUS_BAD_REQUEST
- );
- }
-
- return ResponseHelper::success(
- data: [
- 'tree' => $tree,
- 'language' => $language,
- ]
- );
- }//end updateOrgNavigation()
-
- /**
- * Read the global rail-position setting (REQ-ONAV-004).
- *
- * @return JSONResponse The current effective position.
- *
- * @spec openspec/specs/navigation-editor-org/spec.md
- */
- #[NoAdminRequired]
- public function getPosition(): JSONResponse
- {
- $user = $this->userSession->getUser();
- if ($user === null) {
- return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED);
- }
-
- try {
- $this->actionAuth->requireAction($user, 'admin-org-navigation.get-position');
- } catch (OCSForbiddenException) {
- return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN);
- }
-
- return ResponseHelper::success(
- data: ['position' => $this->readPosition()]
- );
- }//end getPosition()
-
- /**
- * Replace the global rail-position setting (REQ-ONAV-004).
- *
- * Admin-only. Accepts `{position: 'left'|'right'|'top'|'hidden'}`.
- *
- * @param string|null $position The desired position.
- *
- * @return JSONResponse The persisted position on success.
- *
- * @spec openspec/specs/navigation-editor-org/spec.md
- */
- #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
- public function updatePosition(?string $position=null): JSONResponse
- {
- $guard = $this->assertAdmin();
- if ($guard !== null) {
- return $guard;
- }
-
- if ($position === null
- || in_array(needle: $position, haystack: self::ALLOWED_POSITIONS, strict: true) === false
- ) {
- return new JSONResponse(
- data: ['error' => 'position must be one of: '.implode(separator: ', ', array: self::ALLOWED_POSITIONS)],
- statusCode: Http::STATUS_BAD_REQUEST
- );
- }
-
- $this->settings->setSetting(
- key: self::SETTING_KEY_POSITION,
- value: $position
- );
-
- return ResponseHelper::success(
- data: ['position' => $position]
- );
- }//end updatePosition()
-
- /**
- * Validate that a language code is one LaunchPad supports in v1.
- *
- * @param string $language The candidate language code.
- *
- * @return string|null The normalised language code, or `null`
- * when the value is not supported.
- */
- private function validateLanguage(string $language): ?string
- {
- $lower = strtolower(string: trim(string: $language));
- if (in_array(
- needle: $lower,
- haystack: OrgNavigationService::SUPPORTED_LANGUAGES,
- strict: true
- ) === false
- ) {
- return null;
- }
-
- return $lower;
- }//end validateLanguage()
-
- /**
- * Read the persisted position with a default fallback.
- *
- * @return string Always one of {@see self::ALLOWED_POSITIONS}.
- */
- private function readPosition(): string
- {
- $raw = $this->settings->getValue(
- key: self::SETTING_KEY_POSITION,
- default: self::DEFAULT_POSITION
- );
-
- if (is_string($raw) === false
- || in_array(needle: $raw, haystack: self::ALLOWED_POSITIONS, strict: true) === false
- ) {
- return self::DEFAULT_POSITION;
- }
-
- return $raw;
- }//end readPosition()
+class AdminOrgNavigationController extends Controller {
+ /**
+ * Setting key for the global navigation rail position
+ * (REQ-ONAV-004). Stored in `launchpad_admin_settings` rather than
+ * `IAppData` because it is a scalar enum, not a tree.
+ *
+ * @var string
+ */
+ public const SETTING_KEY_POSITION = 'org_navigation_position';
+
+ /**
+ * Allowed values for the position setting (REQ-ONAV-004).
+ *
+ * @var array
+ */
+ public const ALLOWED_POSITIONS = ['left', 'right', 'top', 'hidden'];
+
+ /**
+ * Default position when the setting is unset (REQ-ONAV-004).
+ *
+ * @var string
+ */
+ public const DEFAULT_POSITION = 'hidden';
+
+ /**
+ * Constructor.
+ *
+ * @param IRequest $request Inbound request.
+ * @param OrgNavigationService $service Tree storage + filter
+ * service.
+ * @param AdminSettingMapper $settings Persistence layer for
+ * the position scalar.
+ * @param IUserSession $userSession Current user session.
+ * @param IGroupManager $groupManager Admin check for write endpoints.
+ * @param ActionAuthService $actionAuth ADR-023 action authorization.
+ */
+ public function __construct(
+ IRequest $request,
+ private readonly OrgNavigationService $service,
+ private readonly AdminSettingMapper $settings,
+ private readonly IUserSession $userSession,
+ private readonly IGroupManager $groupManager,
+ private readonly ActionAuthService $actionAuth,
+ ) {
+ parent::__construct(
+ appName: Application::APP_ID,
+ request: $request
+ );
+ }//end __construct()
+
+ /**
+ * Inline admin guard (returns null when the caller is an NC admin).
+ *
+ * @return JSONResponse|null Non-null = caller must be rejected.
+ */
+ private function assertAdmin(): ?JSONResponse {
+ $user = $this->userSession->getUser();
+ if ($user === null) {
+ return new JSONResponse(
+ data: ['error' => 'Not authenticated'],
+ statusCode: Http::STATUS_UNAUTHORIZED
+ );
+ }
+
+ if ($this->groupManager->isAdmin(userId: $user->getUID()) === false) {
+ return new JSONResponse(
+ data: ['error' => 'Admin required'],
+ statusCode: Http::STATUS_FORBIDDEN
+ );
+ }
+
+ return null;
+ }//end assertAdmin()
+
+ /**
+ * Read the org-navigation tree filtered for the current user.
+ *
+ * Accessible to any logged-in user (REQ-ONAV-002).
+ *
+ * @param string $lang Language code (defaults to `nl`).
+ *
+ * @return JSONResponse The filtered tree under `tree` plus the
+ * effective `language`.
+ *
+ * @spec openspec/specs/navigation-editor-org/spec.md
+ */
+ #[NoAdminRequired]
+ public function getOrgNavigation(string $lang = OrgNavigationService::DEFAULT_LANGUAGE): JSONResponse {
+ $user = $this->userSession->getUser();
+ if ($user === null) {
+ return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED);
+ }
+
+ try {
+ $this->actionAuth->requireAction($user, 'admin-org-navigation.get-org-navigation');
+ } catch (OCSForbiddenException) {
+ return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN);
+ }
+
+ $language = $this->validateLanguage(language: $lang);
+ if ($language === null) {
+ return new JSONResponse(
+ data: ['error' => 'Unsupported language'],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ }
+
+ $tree = $this->service->getTree(language: $language);
+ $filtered = $this->service->filterTreeByUserGroups(
+ tree: $tree,
+ userId: $user->getUID()
+ );
+
+ return ResponseHelper::success(
+ data: [
+ 'tree' => $filtered,
+ 'language' => $language,
+ ]
+ );
+ }//end getOrgNavigation()
+
+ /**
+ * Replace the org-navigation tree for the given language.
+ *
+ * Admin-only (REQ-ONAV-003); validates and persists in one go.
+ *
+ * @param array|null $tree The full replacement tree.
+ * @param string $lang Language code (defaults to `nl`).
+ *
+ * @return JSONResponse The persisted tree (unchanged) on success.
+ *
+ * @spec openspec/specs/navigation-editor-org/spec.md
+ */
+ #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
+ public function updateOrgNavigation(
+ ?array $tree = null,
+ string $lang = OrgNavigationService::DEFAULT_LANGUAGE,
+ ): JSONResponse {
+ $guard = $this->assertAdmin();
+ if ($guard !== null) {
+ return $guard;
+ }
+
+ $language = $this->validateLanguage(language: $lang);
+ if ($language === null) {
+ return new JSONResponse(
+ data: ['error' => 'Unsupported language'],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ }
+
+ if (is_array($tree) === false) {
+ return new JSONResponse(
+ data: ['error' => 'tree must be an array of node objects'],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ }
+
+ try {
+ $this->service->setTree(tree: $tree, language: $language);
+ } catch (InvalidArgumentException $e) {
+ return new JSONResponse(
+ data: ['error' => $e->getMessage()],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ }
+
+ return ResponseHelper::success(
+ data: [
+ 'tree' => $tree,
+ 'language' => $language,
+ ]
+ );
+ }//end updateOrgNavigation()
+
+ /**
+ * Read the global rail-position setting (REQ-ONAV-004).
+ *
+ * @return JSONResponse The current effective position.
+ *
+ * @spec openspec/specs/navigation-editor-org/spec.md
+ */
+ #[NoAdminRequired]
+ public function getPosition(): JSONResponse {
+ $user = $this->userSession->getUser();
+ if ($user === null) {
+ return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED);
+ }
+
+ try {
+ $this->actionAuth->requireAction($user, 'admin-org-navigation.get-position');
+ } catch (OCSForbiddenException) {
+ return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN);
+ }
+
+ return ResponseHelper::success(
+ data: ['position' => $this->readPosition()]
+ );
+ }//end getPosition()
+
+ /**
+ * Replace the global rail-position setting (REQ-ONAV-004).
+ *
+ * Admin-only. Accepts `{position: 'left'|'right'|'top'|'hidden'}`.
+ *
+ * @param string|null $position The desired position.
+ *
+ * @return JSONResponse The persisted position on success.
+ *
+ * @spec openspec/specs/navigation-editor-org/spec.md
+ */
+ #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
+ public function updatePosition(?string $position = null): JSONResponse {
+ $guard = $this->assertAdmin();
+ if ($guard !== null) {
+ return $guard;
+ }
+
+ if ($position === null
+ || in_array(needle: $position, haystack: self::ALLOWED_POSITIONS, strict: true) === false
+ ) {
+ return new JSONResponse(
+ data: ['error' => 'position must be one of: ' . implode(separator: ', ', array: self::ALLOWED_POSITIONS)],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ }
+
+ $this->settings->setSetting(
+ key: self::SETTING_KEY_POSITION,
+ value: $position
+ );
+
+ return ResponseHelper::success(
+ data: ['position' => $position]
+ );
+ }//end updatePosition()
+
+ /**
+ * Validate that a language code is one LaunchPad supports in v1.
+ *
+ * @param string $language The candidate language code.
+ *
+ * @return string|null The normalised language code, or `null`
+ * when the value is not supported.
+ */
+ private function validateLanguage(string $language): ?string {
+ $lower = strtolower(string: trim(string: $language));
+ if (in_array(
+ needle: $lower,
+ haystack: OrgNavigationService::SUPPORTED_LANGUAGES,
+ strict: true
+ ) === false
+ ) {
+ return null;
+ }
+
+ return $lower;
+ }//end validateLanguage()
+
+ /**
+ * Read the persisted position with a default fallback.
+ *
+ * @return string Always one of {@see self::ALLOWED_POSITIONS}.
+ */
+ private function readPosition(): string {
+ $raw = $this->settings->getValue(
+ key: self::SETTING_KEY_POSITION,
+ default: self::DEFAULT_POSITION
+ );
+
+ if (is_string($raw) === false
+ || in_array(needle: $raw, haystack: self::ALLOWED_POSITIONS, strict: true) === false
+ ) {
+ return self::DEFAULT_POSITION;
+ }
+
+ return $raw;
+ }//end readPosition()
}//end class
diff --git a/lib/Controller/AdminSettingsController.php b/lib/Controller/AdminSettingsController.php
index c0d2c66a..6c807754 100644
--- a/lib/Controller/AdminSettingsController.php
+++ b/lib/Controller/AdminSettingsController.php
@@ -50,178 +50,174 @@
/**
* Admin-only controller for the group-priority order setting.
*/
-class AdminSettingsController extends Controller
-{
- /**
- * Constructor.
- *
- * @param IRequest $request The HTTP request.
- * @param AdminSettingsService $settingsService Persisted-settings service.
- * @param IGroupManager $groupManager Group manager (admin check + listing).
- * @param IUserSession $userSession Active session accessor.
- */
- public function __construct(
- IRequest $request,
- private readonly AdminSettingsService $settingsService,
- private readonly IGroupManager $groupManager,
- private readonly IUserSession $userSession,
- ) {
- parent::__construct(
- appName: Application::APP_ID,
- request: $request
- );
- }//end __construct()
-
- /**
- * Handle `GET /api/admin/groups` — REQ-ASET-013.
- *
- * Returns the disjoint exhaustive split `{active, inactive, allKnown}`:
- * - `active` — the persisted `group_order` list, in admin-chosen
- * order. Stale IDs (no longer in Nextcloud) remain so admin can
- * see and remove them.
- * - `inactive` — every Nextcloud group ID NOT in `active`, sorted
- * by displayName (case-insensitive).
- * - `allKnown` — full `{id, displayName}` list for the UI to render
- * display names without a second round-trip. Stale IDs MUST NOT
- * appear here (no display name available).
- *
- * @return JSONResponse Either the success payload or HTTP 403 when
- * the caller is not an administrator.
- *
- * @spec openspec/specs/admin-settings/spec.md
- */
- #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
- public function listGroups(): JSONResponse
- {
- $forbidden = $this->assertAdmin();
- if ($forbidden !== null) {
- return $forbidden;
- }
-
- $allKnown = [];
- $allKnownIds = [];
- foreach ($this->groupManager->search(search: '') as $group) {
- $id = $group->getGID();
- $allKnownIds[] = $id;
- $allKnown[] = [
- 'id' => $id,
- 'displayName' => $group->getDisplayName(),
- ];
- }
-
- $active = $this->settingsService->getGroupOrder();
-
- // `inactive` = allKnown - active (stale active IDs MUST NOT
- // appear in inactive — REQ-ASET-013 disjoint scenario).
- $activeSet = array_flip(array: $active);
- $inactive = [];
- foreach ($allKnownIds as $id) {
- if (array_key_exists(key: $id, array: $activeSet) === false) {
- $inactive[] = $id;
- }
- }
-
- // Sort `inactive` by displayName (case-insensitive). Build a
- // lookup so stable sort by name is cheap.
- $displayNameById = [];
- foreach ($allKnown as $row) {
- $displayNameById[$row['id']] = $row['displayName'];
- }
-
- usort(
- array: $inactive,
- callback: static function (string $aId, string $bId) use ($displayNameById): int {
- $aName = strtolower(string: $displayNameById[$aId] ?? $aId);
- $bName = strtolower(string: $displayNameById[$bId] ?? $bId);
- return strcmp(string1: $aName, string2: $bName);
- }
- );
-
- return ResponseHelper::success(
- data: [
- 'active' => $active,
- 'inactive' => $inactive,
- 'allKnown' => $allKnown,
- ]
- );
- }//end listGroups()
-
- /**
- * Handle `POST /api/admin/groups` — REQ-ASET-012, REQ-ASET-014.
- *
- * Body: `{"groups": ["id", ...]}`. Replaces the persisted
- * `group_order` setting wholesale. Validation:
- * - `groups` MUST be present and an array.
- * - Every element MUST be a non-empty string.
- * - Duplicate IDs are deduplicated (first occurrence kept).
- * - Unknown (not currently in Nextcloud) IDs are tolerated — they
- * remain in the persisted setting per REQ-ASET-014.
- *
- * @param mixed $groups The raw `groups` payload from the request body.
- *
- * @return JSONResponse HTTP 200 with `{status: 'ok'}` on success,
- * 400 on validation failure, 403 for non-admins.
- *
- * @spec openspec/specs/admin-settings/spec.md
- */
- #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
- public function updateGroupOrder(mixed $groups=null): JSONResponse
- {
- $forbidden = $this->assertAdmin();
- if ($forbidden !== null) {
- return $forbidden;
- }
-
- if (is_array($groups) === false) {
- return new JSONResponse(
- data: ['error' => 'groups must be an array'],
- statusCode: Http::STATUS_BAD_REQUEST
- );
- }
-
- try {
- $this->settingsService->setGroupOrder(groupIds: $groups);
- } catch (InvalidArgumentException $e) {
- return new JSONResponse(
- data: ['error' => $e->getMessage()],
- statusCode: Http::STATUS_BAD_REQUEST
- );
- }
-
- return ResponseHelper::success(
- data: [
- 'status' => 'ok',
- 'groupOrder' => $this->settingsService->getGroupOrder(),
- ]
- );
- }//end updateGroupOrder()
-
- /**
- * Assert that the active session belongs to an administrator.
- *
- * Both endpoints are admin-only because the inactive list reveals
- * every group on the system (REQ-ASET-014). The base controller
- * routing already requires authentication; this guard only adds the
- * admin check on top.
- *
- * @return JSONResponse|null `null` when the caller is an admin, or
- * a 403 response that the calling action
- * should return verbatim.
- */
- private function assertAdmin(): ?JSONResponse
- {
- $user = $this->userSession->getUser();
- if ($user === null) {
- return new JSONResponse(
- data: ['error' => 'Not authenticated'],
- statusCode: Http::STATUS_UNAUTHORIZED
- );
- }
-
- if ($this->groupManager->isAdmin(userId: $user->getUID()) === false) {
- return ResponseHelper::forbidden();
- }
-
- return null;
- }//end assertAdmin()
+class AdminSettingsController extends Controller {
+ /**
+ * Constructor.
+ *
+ * @param IRequest $request The HTTP request.
+ * @param AdminSettingsService $settingsService Persisted-settings service.
+ * @param IGroupManager $groupManager Group manager (admin check + listing).
+ * @param IUserSession $userSession Active session accessor.
+ */
+ public function __construct(
+ IRequest $request,
+ private readonly AdminSettingsService $settingsService,
+ private readonly IGroupManager $groupManager,
+ private readonly IUserSession $userSession,
+ ) {
+ parent::__construct(
+ appName: Application::APP_ID,
+ request: $request
+ );
+ }//end __construct()
+
+ /**
+ * Handle `GET /api/admin/groups` — REQ-ASET-013.
+ *
+ * Returns the disjoint exhaustive split `{active, inactive, allKnown}`:
+ * - `active` — the persisted `group_order` list, in admin-chosen
+ * order. Stale IDs (no longer in Nextcloud) remain so admin can
+ * see and remove them.
+ * - `inactive` — every Nextcloud group ID NOT in `active`, sorted
+ * by displayName (case-insensitive).
+ * - `allKnown` — full `{id, displayName}` list for the UI to render
+ * display names without a second round-trip. Stale IDs MUST NOT
+ * appear here (no display name available).
+ *
+ * @return JSONResponse Either the success payload or HTTP 403 when
+ * the caller is not an administrator.
+ *
+ * @spec openspec/specs/admin-settings/spec.md
+ */
+ #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
+ public function listGroups(): JSONResponse {
+ $forbidden = $this->assertAdmin();
+ if ($forbidden !== null) {
+ return $forbidden;
+ }
+
+ $allKnown = [];
+ $allKnownIds = [];
+ foreach ($this->groupManager->search(search: '') as $group) {
+ $id = $group->getGID();
+ $allKnownIds[] = $id;
+ $allKnown[] = [
+ 'id' => $id,
+ 'displayName' => $group->getDisplayName(),
+ ];
+ }
+
+ $active = $this->settingsService->getGroupOrder();
+
+ // `inactive` = allKnown - active (stale active IDs MUST NOT
+ // appear in inactive — REQ-ASET-013 disjoint scenario).
+ $activeSet = array_flip(array: $active);
+ $inactive = [];
+ foreach ($allKnownIds as $id) {
+ if (array_key_exists(key: $id, array: $activeSet) === false) {
+ $inactive[] = $id;
+ }
+ }
+
+ // Sort `inactive` by displayName (case-insensitive). Build a
+ // lookup so stable sort by name is cheap.
+ $displayNameById = [];
+ foreach ($allKnown as $row) {
+ $displayNameById[$row['id']] = $row['displayName'];
+ }
+
+ usort(
+ array: $inactive,
+ callback: static function (string $aId, string $bId) use ($displayNameById): int {
+ $aName = strtolower(string: $displayNameById[$aId] ?? $aId);
+ $bName = strtolower(string: $displayNameById[$bId] ?? $bId);
+ return strcmp(string1: $aName, string2: $bName);
+ }
+ );
+
+ return ResponseHelper::success(
+ data: [
+ 'active' => $active,
+ 'inactive' => $inactive,
+ 'allKnown' => $allKnown,
+ ]
+ );
+ }//end listGroups()
+
+ /**
+ * Handle `POST /api/admin/groups` — REQ-ASET-012, REQ-ASET-014.
+ *
+ * Body: `{"groups": ["id", ...]}`. Replaces the persisted
+ * `group_order` setting wholesale. Validation:
+ * - `groups` MUST be present and an array.
+ * - Every element MUST be a non-empty string.
+ * - Duplicate IDs are deduplicated (first occurrence kept).
+ * - Unknown (not currently in Nextcloud) IDs are tolerated — they
+ * remain in the persisted setting per REQ-ASET-014.
+ *
+ * @param mixed $groups The raw `groups` payload from the request body.
+ *
+ * @return JSONResponse HTTP 200 with `{status: 'ok'}` on success,
+ * 400 on validation failure, 403 for non-admins.
+ *
+ * @spec openspec/specs/admin-settings/spec.md
+ */
+ #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
+ public function updateGroupOrder(mixed $groups = null): JSONResponse {
+ $forbidden = $this->assertAdmin();
+ if ($forbidden !== null) {
+ return $forbidden;
+ }
+
+ if (is_array($groups) === false) {
+ return new JSONResponse(
+ data: ['error' => 'groups must be an array'],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ }
+
+ try {
+ $this->settingsService->setGroupOrder(groupIds: $groups);
+ } catch (InvalidArgumentException $e) {
+ return new JSONResponse(
+ data: ['error' => $e->getMessage()],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ }
+
+ return ResponseHelper::success(
+ data: [
+ 'status' => 'ok',
+ 'groupOrder' => $this->settingsService->getGroupOrder(),
+ ]
+ );
+ }//end updateGroupOrder()
+
+ /**
+ * Assert that the active session belongs to an administrator.
+ *
+ * Both endpoints are admin-only because the inactive list reveals
+ * every group on the system (REQ-ASET-014). The base controller
+ * routing already requires authentication; this guard only adds the
+ * admin check on top.
+ *
+ * @return JSONResponse|null `null` when the caller is an admin, or
+ * a 403 response that the calling action
+ * should return verbatim.
+ */
+ private function assertAdmin(): ?JSONResponse {
+ $user = $this->userSession->getUser();
+ if ($user === null) {
+ return new JSONResponse(
+ data: ['error' => 'Not authenticated'],
+ statusCode: Http::STATUS_UNAUTHORIZED
+ );
+ }
+
+ if ($this->groupManager->isAdmin(userId: $user->getUID()) === false) {
+ return ResponseHelper::forbidden();
+ }
+
+ return null;
+ }//end assertAdmin()
}//end class
diff --git a/lib/Controller/AdminWidgetRulesController.php b/lib/Controller/AdminWidgetRulesController.php
index 43c78c6a..590644c6 100644
--- a/lib/Controller/AdminWidgetRulesController.php
+++ b/lib/Controller/AdminWidgetRulesController.php
@@ -35,44 +35,42 @@
*
* @spec openspec/specs/conditional-visibility/spec.md
*/
-class AdminWidgetRulesController extends Controller
-{
- /**
- * Constructor
- *
- * @param IRequest $request The request.
- * @param ConditionalService $conditionalService The conditional service.
- */
- public function __construct(
- IRequest $request,
- private readonly ConditionalService $conditionalService,
- ) {
- parent::__construct(
- appName: Application::APP_ID,
- request: $request
- );
- }//end __construct()
+class AdminWidgetRulesController extends Controller {
+ /**
+ * Constructor
+ *
+ * @param IRequest $request The request.
+ * @param ConditionalService $conditionalService The conditional service.
+ */
+ public function __construct(
+ IRequest $request,
+ private readonly ConditionalService $conditionalService,
+ ) {
+ parent::__construct(
+ appName: Application::APP_ID,
+ request: $request
+ );
+ }//end __construct()
- /**
- * List every widget placement that carries at least one conditional rule.
- *
- * Admin-only — the overview discloses every user's dashboard names and
- * widget types, so it is gated with `#[AuthorizedAdminSetting]` like the
- * rest of the Beheer surface (ADR-005).
- *
- * @return JSONResponse The overview rows (placement + dashboard + counts).
- *
- * @spec openspec/specs/conditional-visibility/spec.md
- */
- #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
- public function index(): JSONResponse
- {
- try {
- return ResponseHelper::success(
- data: $this->conditionalService->listAllRules()
- );
- } catch (\Exception $e) {
- return ResponseHelper::error(exception: $e);
- }
- }//end index()
+ /**
+ * List every widget placement that carries at least one conditional rule.
+ *
+ * Admin-only — the overview discloses every user's dashboard names and
+ * widget types, so it is gated with `#[AuthorizedAdminSetting]` like the
+ * rest of the Beheer surface (ADR-005).
+ *
+ * @return JSONResponse The overview rows (placement + dashboard + counts).
+ *
+ * @spec openspec/specs/conditional-visibility/spec.md
+ */
+ #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
+ public function index(): JSONResponse {
+ try {
+ return ResponseHelper::success(
+ data: $this->conditionalService->listAllRules()
+ );
+ } catch (\Exception $e) {
+ return ResponseHelper::error(exception: $e);
+ }
+ }//end index()
}//end class
diff --git a/lib/Controller/AnalyticsController.php b/lib/Controller/AnalyticsController.php
index d9e65702..edeb7eb0 100644
--- a/lib/Controller/AnalyticsController.php
+++ b/lib/Controller/AnalyticsController.php
@@ -55,214 +55,211 @@
*
* @spec openspec/changes/archive/2026-05-02-dashboard-view-analytics/tasks.md
*/
-class AnalyticsController extends Controller
-{
- /**
- * Constructor.
- *
- * @param IRequest $request The HTTP request.
- * @param AnalyticsService $analyticsService The analytics reporting service.
- * @param ActionAuthService $actionAuth ADR-023 action authorization.
- * @param IUserSession $userSession Current user session.
- */
- public function __construct(
- IRequest $request,
- private readonly AnalyticsService $analyticsService,
- private readonly ActionAuthService $actionAuth,
- private readonly IUserSession $userSession,
- ) {
- parent::__construct(
- appName: Application::APP_ID,
- request: $request
- );
- }//end __construct()
+class AnalyticsController extends Controller {
+ /**
+ * Constructor.
+ *
+ * @param IRequest $request The HTTP request.
+ * @param AnalyticsService $analyticsService The analytics reporting service.
+ * @param ActionAuthService $actionAuth ADR-023 action authorization.
+ * @param IUserSession $userSession Current user session.
+ */
+ public function __construct(
+ IRequest $request,
+ private readonly AnalyticsService $analyticsService,
+ private readonly ActionAuthService $actionAuth,
+ private readonly IUserSession $userSession,
+ ) {
+ parent::__construct(
+ appName: Application::APP_ID,
+ request: $request
+ );
+ }//end __construct()
- /**
- * Handle `GET /api/admin/analytics/dashboards/top` (REQ-ANLT-006).
- *
- * @param string $period The period string (`7d`, `30d`, `90d`).
- * @param int $limit Maximum rows.
- *
- * @return JSONResponse The response.
- *
- * @spec openspec/specs/dashboard-view-analytics/spec.md
- */
- #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
- public function topDashboards(
- string $period='30d',
- int $limit=10
- ): JSONResponse {
- $user = $this->userSession->getUser();
- if ($user === null) {
- return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED);
- }
+ /**
+ * Handle `GET /api/admin/analytics/dashboards/top` (REQ-ANLT-006).
+ *
+ * @param string $period The period string (`7d`, `30d`, `90d`).
+ * @param int $limit Maximum rows.
+ *
+ * @return JSONResponse The response.
+ *
+ * @spec openspec/specs/dashboard-view-analytics/spec.md
+ */
+ #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
+ public function topDashboards(
+ string $period = '30d',
+ int $limit = 10,
+ ): JSONResponse {
+ $user = $this->userSession->getUser();
+ if ($user === null) {
+ return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED);
+ }
- try {
- $this->actionAuth->requireAction($user, 'analytics.top-dashboards');
- } catch (OCSForbiddenException) {
- return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN);
- }
+ try {
+ $this->actionAuth->requireAction($user, 'analytics.top-dashboards');
+ } catch (OCSForbiddenException) {
+ return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN);
+ }
- try {
- $rows = $this->analyticsService->getTopDashboards(
- period: $period,
- limit: $limit
- );
- } catch (InvalidArgumentException $e) {
- return new JSONResponse(
- data: [
- 'status' => 'error',
- 'error' => 'invalid_period',
- 'message' => $e->getMessage(),
- ],
- statusCode: Http::STATUS_BAD_REQUEST
- );
- }
+ try {
+ $rows = $this->analyticsService->getTopDashboards(
+ period: $period,
+ limit: $limit
+ );
+ } catch (InvalidArgumentException $e) {
+ return new JSONResponse(
+ data: [
+ 'status' => 'error',
+ 'error' => 'invalid_period',
+ 'message' => $e->getMessage(),
+ ],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ }
- return ResponseHelper::success(data: $rows);
- }//end topDashboards()
+ return ResponseHelper::success(data: $rows);
+ }//end topDashboards()
- /**
- * Handle `GET /api/admin/analytics/dashboards/{uuid}`
- * (REQ-ANLT-007).
- *
- * @param string $uuid The dashboard UUID from the URL.
- * @param string $period The period string.
- *
- * @return JSONResponse The response.
- *
- * @spec openspec/specs/dashboard-view-analytics/spec.md
- */
- #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
- public function dashboardDetail(
- string $uuid,
- string $period='30d'
- ): JSONResponse {
- $user = $this->userSession->getUser();
- if ($user === null) {
- return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED);
- }
+ /**
+ * Handle `GET /api/admin/analytics/dashboards/{uuid}`
+ * (REQ-ANLT-007).
+ *
+ * @param string $uuid The dashboard UUID from the URL.
+ * @param string $period The period string.
+ *
+ * @return JSONResponse The response.
+ *
+ * @spec openspec/specs/dashboard-view-analytics/spec.md
+ */
+ #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
+ public function dashboardDetail(
+ string $uuid,
+ string $period = '30d',
+ ): JSONResponse {
+ $user = $this->userSession->getUser();
+ if ($user === null) {
+ return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED);
+ }
- try {
- $this->actionAuth->requireAction($user, 'analytics.dashboard-detail');
- } catch (OCSForbiddenException) {
- return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN);
- }
+ try {
+ $this->actionAuth->requireAction($user, 'analytics.dashboard-detail');
+ } catch (OCSForbiddenException) {
+ return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN);
+ }
- try {
- $rows = $this->analyticsService->getDashboardDetail(
- dashboardUuid: $uuid,
- period: $period
- );
- } catch (DoesNotExistException) {
- return new JSONResponse(
- data: [
- 'status' => 'error',
- 'error' => 'not_found',
- ],
- statusCode: Http::STATUS_NOT_FOUND
- );
- } catch (InvalidArgumentException $e) {
- return new JSONResponse(
- data: [
- 'status' => 'error',
- 'error' => 'invalid_period',
- 'message' => $e->getMessage(),
- ],
- statusCode: Http::STATUS_BAD_REQUEST
- );
- }//end try
+ try {
+ $rows = $this->analyticsService->getDashboardDetail(
+ dashboardUuid: $uuid,
+ period: $period
+ );
+ } catch (DoesNotExistException) {
+ return new JSONResponse(
+ data: [
+ 'status' => 'error',
+ 'error' => 'not_found',
+ ],
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ } catch (InvalidArgumentException $e) {
+ return new JSONResponse(
+ data: [
+ 'status' => 'error',
+ 'error' => 'invalid_period',
+ 'message' => $e->getMessage(),
+ ],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ }//end try
- return ResponseHelper::success(data: $rows);
- }//end dashboardDetail()
+ return ResponseHelper::success(data: $rows);
+ }//end dashboardDetail()
- /**
- * Handle `GET /api/admin/analytics/summary` (REQ-ANLT-008).
- *
- * @param string $period The period string.
- *
- * @return JSONResponse The response.
- *
- * @spec openspec/specs/dashboard-view-analytics/spec.md
- */
- #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
- public function instanceSummary(string $period='30d'): JSONResponse
- {
- $user = $this->userSession->getUser();
- if ($user === null) {
- return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED);
- }
+ /**
+ * Handle `GET /api/admin/analytics/summary` (REQ-ANLT-008).
+ *
+ * @param string $period The period string.
+ *
+ * @return JSONResponse The response.
+ *
+ * @spec openspec/specs/dashboard-view-analytics/spec.md
+ */
+ #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
+ public function instanceSummary(string $period = '30d'): JSONResponse {
+ $user = $this->userSession->getUser();
+ if ($user === null) {
+ return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED);
+ }
- try {
- $this->actionAuth->requireAction($user, 'analytics.instance-summary');
- } catch (OCSForbiddenException) {
- return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN);
- }
+ try {
+ $this->actionAuth->requireAction($user, 'analytics.instance-summary');
+ } catch (OCSForbiddenException) {
+ return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN);
+ }
- try {
- $summary = $this->analyticsService->getInstanceSummary(
- period: $period
- );
- } catch (InvalidArgumentException $e) {
- return new JSONResponse(
- data: [
- 'status' => 'error',
- 'error' => 'invalid_period',
- 'message' => $e->getMessage(),
- ],
- statusCode: Http::STATUS_BAD_REQUEST
- );
- }
+ try {
+ $summary = $this->analyticsService->getInstanceSummary(
+ period: $period
+ );
+ } catch (InvalidArgumentException $e) {
+ return new JSONResponse(
+ data: [
+ 'status' => 'error',
+ 'error' => 'invalid_period',
+ 'message' => $e->getMessage(),
+ ],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ }
- return ResponseHelper::success(data: $summary);
- }//end instanceSummary()
+ return ResponseHelper::success(data: $summary);
+ }//end instanceSummary()
- /**
- * Handle `GET /api/admin/analytics/export` (REQ-ANLT-010).
- *
- * Returns a `text/csv` attachment with the filename
- * `dashboard-analytics-YYYY-MM-DD.csv` (today's UTC date).
- *
- * @param string $period The period string.
- *
- * @return Response The CSV download response or a JSON error
- * envelope.
- *
- * @spec openspec/specs/dashboard-view-analytics/spec.md
- */
- #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
- public function exportCsv(string $period='30d'): Response
- {
- $user = $this->userSession->getUser();
- if ($user === null) {
- return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED);
- }
+ /**
+ * Handle `GET /api/admin/analytics/export` (REQ-ANLT-010).
+ *
+ * Returns a `text/csv` attachment with the filename
+ * `dashboard-analytics-YYYY-MM-DD.csv` (today's UTC date).
+ *
+ * @param string $period The period string.
+ *
+ * @return Response The CSV download response or a JSON error
+ * envelope.
+ *
+ * @spec openspec/specs/dashboard-view-analytics/spec.md
+ */
+ #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
+ public function exportCsv(string $period = '30d'): Response {
+ $user = $this->userSession->getUser();
+ if ($user === null) {
+ return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED);
+ }
- try {
- $this->actionAuth->requireAction($user, 'analytics.export-csv');
- } catch (OCSForbiddenException) {
- return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN);
- }
+ try {
+ $this->actionAuth->requireAction($user, 'analytics.export-csv');
+ } catch (OCSForbiddenException) {
+ return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN);
+ }
- try {
- $csv = $this->analyticsService->generateCsvExport(
- period: $period
- );
- } catch (InvalidArgumentException $e) {
- return new JSONResponse(
- data: [
- 'status' => 'error',
- 'error' => 'invalid_period',
- 'message' => $e->getMessage(),
- ],
- statusCode: Http::STATUS_BAD_REQUEST
- );
- }
+ try {
+ $csv = $this->analyticsService->generateCsvExport(
+ period: $period
+ );
+ } catch (InvalidArgumentException $e) {
+ return new JSONResponse(
+ data: [
+ 'status' => 'error',
+ 'error' => 'invalid_period',
+ 'message' => $e->getMessage(),
+ ],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ }
- return new DataDownloadResponse(
- data: $csv,
- filename: $this->analyticsService->csvExportFilename(),
- contentType: 'text/csv'
- );
- }//end exportCsv()
+ return new DataDownloadResponse(
+ data: $csv,
+ filename: $this->analyticsService->csvExportFilename(),
+ contentType: 'text/csv'
+ );
+ }//end exportCsv()
}//end class
diff --git a/lib/Controller/ConfluenceImportController.php b/lib/Controller/ConfluenceImportController.php
index eaafd722..03da94eb 100644
--- a/lib/Controller/ConfluenceImportController.php
+++ b/lib/Controller/ConfluenceImportController.php
@@ -42,122 +42,118 @@
* `$_FILES` is the only multipart entry point under Nextcloud.
* @spec openspec/specs/confluence-html-import/spec.md
*/
-class ConfluenceImportController extends Controller
-{
- /**
- * Constructor.
- *
- * @param IRequest $request Request handle.
- * @param ConfluenceImportService $importService The import orchestrator.
- * @param IUserSession $userSession Current session.
- */
- public function __construct(
- IRequest $request,
- private readonly ConfluenceImportService $importService,
- private readonly IUserSession $userSession,
- ) {
- parent::__construct(
- appName: Application::APP_ID,
- request: $request
- );
- }//end __construct()
+class ConfluenceImportController extends Controller {
+ /**
+ * Constructor.
+ *
+ * @param IRequest $request Request handle.
+ * @param ConfluenceImportService $importService The import orchestrator.
+ * @param IUserSession $userSession Current session.
+ */
+ public function __construct(
+ IRequest $request,
+ private readonly ConfluenceImportService $importService,
+ private readonly IUserSession $userSession,
+ ) {
+ parent::__construct(
+ appName: Application::APP_ID,
+ request: $request
+ );
+ }//end __construct()
- /**
- * `POST /api/admin/import/confluence/dry-run` — REQ-CFLI-007.
- *
- * @return JSONResponse The dry-run preview, or an error response.
- *
- * @spec openspec/specs/confluence-html-import/spec.md
- */
- #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
- public function dryRun(): JSONResponse
- {
- $tmpName = $this->resolveUpload();
- if ($tmpName instanceof JSONResponse) {
- return $tmpName;
- }
+ /**
+ * `POST /api/admin/import/confluence/dry-run` — REQ-CFLI-007.
+ *
+ * @return JSONResponse The dry-run preview, or an error response.
+ *
+ * @spec openspec/specs/confluence-html-import/spec.md
+ */
+ #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
+ public function dryRun(): JSONResponse {
+ $tmpName = $this->resolveUpload();
+ if ($tmpName instanceof JSONResponse) {
+ return $tmpName;
+ }
- try {
- $result = $this->importService->dryRun(zipPath: $tmpName);
- } catch (InvalidArgumentException $e) {
- return new JSONResponse(
- data: ['error' => $e->getMessage()],
- statusCode: Http::STATUS_BAD_REQUEST
- );
- } catch (Throwable $e) {
- return new JSONResponse(
- data: ['error' => 'Confluence dry-run failed: '.$e->getMessage()],
- statusCode: Http::STATUS_INTERNAL_SERVER_ERROR
- );
- }
+ try {
+ $result = $this->importService->dryRun(zipPath: $tmpName);
+ } catch (InvalidArgumentException $e) {
+ return new JSONResponse(
+ data: ['error' => $e->getMessage()],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ } catch (Throwable $e) {
+ return new JSONResponse(
+ data: ['error' => 'Confluence dry-run failed: ' . $e->getMessage()],
+ statusCode: Http::STATUS_INTERNAL_SERVER_ERROR
+ );
+ }
- return new JSONResponse(data: $result);
- }//end dryRun()
+ return new JSONResponse(data: $result);
+ }//end dryRun()
- /**
- * `POST /api/admin/import/confluence` — REQ-CFLI-001..006, 009, 012.
- *
- * @param string|null $parentUuid Optional parent dashboard UUID
- * under which root pages will be slotted.
- *
- * @return JSONResponse The import summary, or an error response.
- *
- * @spec openspec/specs/confluence-html-import/spec.md
- */
- #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
- public function import(?string $parentUuid=null): JSONResponse
- {
- $tmpName = $this->resolveUpload();
- if ($tmpName instanceof JSONResponse) {
- return $tmpName;
- }
+ /**
+ * `POST /api/admin/import/confluence` — REQ-CFLI-001..006, 009, 012.
+ *
+ * @param string|null $parentUuid Optional parent dashboard UUID
+ * under which root pages will be slotted.
+ *
+ * @return JSONResponse The import summary, or an error response.
+ *
+ * @spec openspec/specs/confluence-html-import/spec.md
+ */
+ #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
+ public function import(?string $parentUuid = null): JSONResponse {
+ $tmpName = $this->resolveUpload();
+ if ($tmpName instanceof JSONResponse) {
+ return $tmpName;
+ }
- $userId = (string) $this->userSession->getUser()?->getUID();
+ $userId = (string)$this->userSession->getUser()?->getUID();
- $resolvedParent = null;
- if ($parentUuid !== null && $parentUuid !== '') {
- $resolvedParent = $parentUuid;
- }
+ $resolvedParent = null;
+ if ($parentUuid !== null && $parentUuid !== '') {
+ $resolvedParent = $parentUuid;
+ }
- try {
- $result = $this->importService->import(
- zipPath: $tmpName,
- currentUserId: $userId,
- parentUuid: $resolvedParent
- );
- } catch (InvalidArgumentException $e) {
- return new JSONResponse(
- data: ['error' => $e->getMessage()],
- statusCode: Http::STATUS_BAD_REQUEST
- );
- } catch (Throwable $e) {
- return new JSONResponse(
- data: ['error' => 'Confluence import failed: '.$e->getMessage()],
- statusCode: Http::STATUS_INTERNAL_SERVER_ERROR
- );
- }
+ try {
+ $result = $this->importService->import(
+ zipPath: $tmpName,
+ currentUserId: $userId,
+ parentUuid: $resolvedParent
+ );
+ } catch (InvalidArgumentException $e) {
+ return new JSONResponse(
+ data: ['error' => $e->getMessage()],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ } catch (Throwable $e) {
+ return new JSONResponse(
+ data: ['error' => 'Confluence import failed: ' . $e->getMessage()],
+ statusCode: Http::STATUS_INTERNAL_SERVER_ERROR
+ );
+ }
- return new JSONResponse(data: $result);
- }//end import()
+ return new JSONResponse(data: $result);
+ }//end import()
- /**
- * Locate and validate the uploaded ZIP, returning its tmp path.
- *
- * @return string|JSONResponse Either the tmp path or a 400 response.
- */
- private function resolveUpload(): string|JSONResponse
- {
- $upload = $_FILES['file'] ?? null;
- if (is_array($upload) === false
- || isset($upload['tmp_name']) === false
- || (string) $upload['tmp_name'] === ''
- ) {
- return new JSONResponse(
- data: ['error' => 'No file uploaded under field "file".'],
- statusCode: Http::STATUS_BAD_REQUEST
- );
- }
+ /**
+ * Locate and validate the uploaded ZIP, returning its tmp path.
+ *
+ * @return string|JSONResponse Either the tmp path or a 400 response.
+ */
+ private function resolveUpload(): string|JSONResponse {
+ $upload = $_FILES['file'] ?? null;
+ if (is_array($upload) === false
+ || isset($upload['tmp_name']) === false
+ || (string)$upload['tmp_name'] === ''
+ ) {
+ return new JSONResponse(
+ data: ['error' => 'No file uploaded under field "file".'],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ }
- return (string) $upload['tmp_name'];
- }//end resolveUpload()
+ return (string)$upload['tmp_name'];
+ }//end resolveUpload()
}//end class
diff --git a/lib/Controller/DashboardApiController.php b/lib/Controller/DashboardApiController.php
index a8cefc1d..fc71006e 100644
--- a/lib/Controller/DashboardApiController.php
+++ b/lib/Controller/DashboardApiController.php
@@ -80,1877 +80,1856 @@
* surface.
* @spec openspec/specs/dashboards/spec.md
*/
-class DashboardApiController extends Controller
-{
- /**
- * Constructor
- *
- * @param IRequest $request The request.
- * @param DashboardService $dashboardService The dashboard service.
- * @param PermissionService $permissionService The permission service.
- * @param DashboardTreeService $treeService The tree service that
- * owns hierarchy
- * queries, cycle
- * detection, slug
- * uniqueness, path
- * resolution, and the
- * cascade-delete walker
- * (REQ-DASH-023..030).
- * @param DashboardVersionService $versionService Snapshot service
- * (REQ-VERS-001) —
- * automatic
- * snapshots fire
- * after every
- * successful PUT
- * via the
- * debounced
- * `captureSnapshot`
- * helper.
- * @param AnalyticsService $analyticsService The view-analytics
- * service used by the
- * `viewEvent` endpoint
- * (REQ-ANLT-002).
- * @param LoggerInterface $logger PSR logger (used by
- * fork to report
- * unexpected errors
- * — REQ-DASH-021).
- * @param IUserSession $userSession The user session, used
- * to resolve the
- * authenticated IUser for
- * ADR-023 action checks.
- * @param ActionAuthService $actionAuth The ADR-023 action
- * authorization service.
- * @param string|null $userId The user ID.
- * @param QuotaService|null $quotaService The quota-enforcement
- * service used to gate
- * dashboard creation
- * (dashboard-quota-limits).
- */
- public function __construct(
- IRequest $request,
- private readonly DashboardService $dashboardService,
- private readonly PermissionService $permissionService,
- private readonly DashboardTreeService $treeService,
- private readonly DashboardVersionService $versionService,
- private readonly AnalyticsService $analyticsService,
- private readonly LoggerInterface $logger,
- private readonly IUserSession $userSession,
- private readonly ActionAuthService $actionAuth,
- private readonly ?string $userId,
- private readonly ?QuotaService $quotaService=null,
- ) {
- parent::__construct(
- appName: Application::APP_ID,
- request: $request
- );
- }//end __construct()
-
- /**
- * List all personal dashboards for the current user.
- *
- * Backward compatible — this endpoint never returns group-shared
- * dashboards (REQ-DASH-014). Use {@see self::visible()} for the
- * unioned listing.
- *
- * @return JSONResponse The list of dashboards.
- *
- * @spec openspec/changes/retrofit-2026-05-24-annotate-launchpad/tasks.md#task-17
- */
- #[NoAdminRequired]
- public function list(): JSONResponse
- {
- $user = $this->userSession->getUser();
- if ($user === null) {
- return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED);
- }
-
- $this->actionAuth->requireAction($user, 'dashboard.list');
-
- if ($this->userId === null) {
- return ResponseHelper::unauthorized();
- }
-
- $dashboards = $this->dashboardService->getUserDashboards(
- userId: $this->userId
- );
-
- $serialized = ResponseHelper::serializeList(entities: $dashboards);
-
- // Dashboard-quota-limits REQ-QUOTA-006: additive quota envelope on
- // the personal dashboards list. Response shape is
- // `{items: [...], quota: {...}}`. When the quota service is absent
- // (legacy test doubles) fall back to the bare-array contract.
- if ($this->quotaService === null) {
- return ResponseHelper::success(data: $serialized);
- }
-
- return ResponseHelper::success(
- data: [
- 'items' => $serialized,
- 'quota' => $this->quotaService->getQuotaStatus(
- userId: $this->userId
- ),
- ]
- );
- }//end list()
-
- /**
- * List the deduplicated union of dashboards visible to the user.
- *
- * Returns personal + group-matching + default-group dashboards, each
- * tagged with `source` (`'user'`, `'group'`, `'default'`).
- * REQ-DASH-013.
- *
- * @return JSONResponse The visible dashboards.
- *
- * @spec openspec/specs/dashboards/spec.md
- */
- #[NoAdminRequired]
- public function visible(): JSONResponse
- {
- $user = $this->userSession->getUser();
- if ($user === null) {
- return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED);
- }
-
- $this->actionAuth->requireAction($user, 'dashboard.visible');
-
- if ($this->userId === null) {
- return ResponseHelper::unauthorized();
- }
-
- $items = $this->dashboardService->getVisibleToUser(
- userId: $this->userId
- );
-
- $serialized = [];
- foreach ($items as $entry) {
- $row = $entry['dashboard']->jsonSerialize();
- $row['source'] = $entry['source'];
- // Tag ownership so the frontend can route activation correctly:
- // only personal `user`-type rows owned by the caller take the
- // legacy id-based `is_active` path; group/default rows (user_id
- // NULL) are activated via the UUID preference instead.
- $row['isOwner'] = ($entry['dashboard']->getUserId() === $this->userId);
- $serialized[] = $row;
- }
-
- // Dashboard-quota-limits REQ-QUOTA-006: carry the additive quota
- // envelope on the unioned listing the store consumes, so the
- // frontend can disable create affordances at the limit without an
- // extra round-trip. The response shape is now
- // `{items: [...], quota: {...}}`; clients that read the bare array
- // are handled by the store's shape-tolerant unwrap. When the quota
- // service is absent (legacy test doubles) fall back to the
- // bare-array contract.
- if ($this->quotaService === null) {
- return ResponseHelper::success(data: $serialized);
- }
-
- return ResponseHelper::success(
- data: [
- 'items' => $serialized,
- 'quota' => $this->quotaService->getQuotaStatus(
- userId: $this->userId
- ),
- ]
- );
- }//end visible()
-
- /**
- * Get the user's active dashboard with placements.
- *
- * @return JSONResponse The active dashboard data.
- *
- * @spec openspec/changes/retrofit-2026-05-24-annotate-launchpad/tasks.md#task-18
- */
- #[NoAdminRequired]
- public function getActive(): JSONResponse
- {
- $user = $this->userSession->getUser();
- if ($user === null) {
- return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED);
- }
-
- $this->actionAuth->requireAction($user, 'dashboard.get-active');
-
- if ($this->userId === null) {
- return ResponseHelper::unauthorized();
- }
-
- $result = $this->dashboardService->getEffectiveDashboard(
- userId: $this->userId
- );
-
- if ($result === null) {
- return ResponseHelper::success(
- data: ['error' => 'No dashboard available'],
- statusCode: Http::STATUS_NOT_FOUND
- );
- }
-
- // The effective dashboard can now be a group/default (showcase)
- // dashboard the user does not own (resolved via the last-used
- // preference), so tag ownership the same way show() does rather
- // than letting the client assume the caller owns it.
- $activeDashboard = $result['dashboard'];
- $isOwner = ($activeDashboard->getUserId() === $this->userId);
-
- $sharedBy = null;
- if ($isOwner === false) {
- $sharedBy = $activeDashboard->getUserId();
- }
-
- return ResponseHelper::success(
- data: [
- 'dashboard' => $activeDashboard->jsonSerialize(),
- 'placements' => ResponseHelper::serializeList(
- entities: $result['placements']
- ),
- 'permissionLevel' => $result['permissionLevel'],
- 'isOwner' => $isOwner,
- 'sharedBy' => $sharedBy,
- ]
- );
- }//end getActive()
-
- /**
- * Get a single dashboard by id with its placements + permission level.
- *
- * Powers the front-end's `switchDashboard` flow: clicking a row in the
- * sidebar issues `GET /api/dashboard/{id}` and the response is the
- * same envelope shape as {@see self::getActive()}, so the store can
- * write `activeDashboard`, `widgetPlacements`, and `permissionLevel`
- * with no per-source branching.
- *
- * Returns 404 (not 403) when the dashboard exists but is not visible
- * to the caller — this matches the `getVisibleToUser` policy and
- * intentionally does not leak existence (REQ-DASH-020 scenario
- * "Cannot see what you cannot read").
- *
- * @param int $id The dashboard ID.
- *
- * @return JSONResponse The dashboard envelope (200) or
- * `{'error': 'Not found'}` (404).
- *
- * @spec openspec/changes/retrofit-2026-05-24-annotate-launchpad/tasks.md#task-21
- */
- #[NoAdminRequired]
- public function show(int $id): JSONResponse
- {
- $user = $this->userSession->getUser();
- if ($user === null) {
- return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED);
- }
-
- $this->actionAuth->requireAction($user, 'dashboard.show');
-
- if ($this->userId === null) {
- return ResponseHelper::unauthorized();
- }
-
- $result = $this->dashboardService->getDashboardForUser(
- dashboardId: $id,
- userId: $this->userId
- );
-
- if ($result === null) {
- return ResponseHelper::success(
- data: ['error' => 'Not found'],
- statusCode: Http::STATUS_NOT_FOUND
- );
- }
-
- $dashboard = $result['dashboard'];
- $isOwner = ($dashboard->getUserId() === $this->userId);
- $sharedBy = null;
- if ($isOwner === false) {
- $sharedBy = $dashboard->getUserId();
- }
-
- return ResponseHelper::success(
- data: [
- 'dashboard' => $dashboard->jsonSerialize(),
- 'placements' => ResponseHelper::serializeList(
- entities: $result['placements']
- ),
- 'permissionLevel' => $result['permissionLevel'],
- 'isOwner' => $isOwner,
- 'sharedBy' => $sharedBy,
- ]
- );
- }//end show()
-
- /**
- * Create a new dashboard.
- *
- * @param mixed $name The dashboard name.
- * @param string|null $description The description.
- * @param string|null $icon The icon registry key (or NULL/empty to use the default).
- * @param string|null $parentUuid Optional parent dashboard UUID
- * (REQ-DASH-023). NULL ⇒ root.
- * @param string|null $slug Optional caller-supplied slug
- * (REQ-DASH-024). NULL ⇒ derive from
- * the name.
- * @param int|null $sortOrder Optional sibling sort order
- * (REQ-DASH-029). NULL ⇒ 0.
- *
- * @return JSONResponse The created dashboard.
- *
- * @spec openspec/changes/retrofit-2026-05-24-annotate-launchpad/tasks.md#task-16
- */
- #[NoAdminRequired]
- public function create(
- $name=null,
- ?string $description=null,
- ?string $icon=null,
- ?string $parentUuid=null,
- ?string $slug=null,
- ?int $sortOrder=null
- ): JSONResponse {
- $denial = $this->denyCreate();
- if ($denial !== null) {
- return $denial;
- }
-
- $resolved = $this->resolveCreateParams(
- name: $name,
- description: $description,
- icon: $icon,
- parentUuid: $parentUuid,
- slug: $slug,
- sortOrder: $sortOrder
- );
-
- $permError = $this->checkCreatePermissions(
- userId: $this->userId
- );
- if ($permError !== null) {
- return $permError;
- }
-
- try {
- $dashboard = $this->dashboardService->createDashboard(
- userId: $this->userId,
- name: $resolved['name'],
- description: $resolved['description'],
- icon: $resolved['icon'],
- parentUuid: $resolved['parentUuid'],
- slug: $resolved['slug'],
- sortOrder: $resolved['sortOrder'],
- seedDefaults: true
- );
-
- // The newly-created dashboard ships with a default widget
- // bundle (Conduction + Sendent + Nextcloud tiles + a Files
- // widget) seeded by the service. Returning the placements
- // here matches the `getActive()` envelope so the store can
- // populate `widgetPlacements` without an extra round-trip.
- $placements = $this->dashboardService->findPlacements(
- dashboardId: $dashboard->getId()
- );
-
- return ResponseHelper::success(
- data: [
- 'dashboard' => $dashboard->jsonSerialize(),
- 'placements' => ResponseHelper::serializeList(
- entities: $placements
- ),
- ],
- statusCode: Http::STATUS_CREATED
- );
- } catch (QuotaExceededException $e) {
- // Dashboard-quota-limits REQ-QUOTA-002: the user is at their
- // dashboard limit — HTTP 409 with the structured body.
- return ResponseHelper::quotaExceeded(exception: $e);
- } catch (InvalidArgumentException $e) {
- // REQ-DASH-023..029: parent / slug / depth / cycle violations
- // surface as HTTP 400 with the validation message verbatim.
- return new JSONResponse(
- data: [
- 'status' => 'error',
- 'error' => 'invalid_argument',
- 'message' => $e->getMessage(),
- ],
- statusCode: Http::STATUS_BAD_REQUEST
- );
- } catch (\Exception $e) {
- return ResponseHelper::error(exception: $e);
- }//end try
- }//end create()
-
- /**
- * Resolve the authentication / authorisation guard chain for
- * {@see self::create()}.
- *
- * Order is load-bearing. REQ-ASET-003 (extended): the admin gating
- * runs BEFORE any request-body handling so the response envelope is
- * the stable `personal_dashboards_disabled` shape no matter what the
- * body looked like.
- *
- * @return JSONResponse|NULL The refusal, or NULL to proceed.
- *
- * @spec openspec/changes/retrofit-2026-05-24-annotate-launchpad/tasks.md#task-16
- */
- private function denyCreate(): ?JSONResponse
- {
- $user = $this->userSession->getUser();
- if ($user === null) {
- return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED);
- }
-
- // L3: wire the create action so the matrix entry is enforced —
- // consistent with all other mutation endpoints (ADR-023).
- $this->actionAuth->requireAction($user, 'dashboard.create');
-
- if ($this->userId === null) {
- return ResponseHelper::unauthorized();
- }
-
- try {
- $this->dashboardService->assertPersonalDashboardsAllowed();
- } catch (PersonalDashboardsDisabledException $e) {
- return new JSONResponse(
- data: [
- 'status' => 'error',
- 'error' => $e->getErrorCode(),
- 'message' => $e->getMessage(),
- ],
- statusCode: Http::STATUS_FORBIDDEN
- );
- }
-
- return null;
- }//end denyCreate()
-
- /**
- * Update a dashboard.
- *
- * @param int $id The dashboard ID.
- * @param string|null $name The name.
- * @param string|null $description The description.
- * @param array|null $placements The placements.
- * @param string|null $icon The icon registry key, URL, or NULL to leave unchanged.
- * @param string|null $parentUuid Optional new parent UUID (REQ-DASH-023);
- * explicit empty string clears the
- * parent (re-roots the dashboard).
- * @param string|null $slug Optional new slug (REQ-DASH-024).
- * @param int|null $sortOrder Optional new sort order (REQ-DASH-029).
- *
- * @return JSONResponse The updated dashboard.
- *
- * @spec openspec/changes/retrofit-2026-05-24-annotate-launchpad/tasks.md#task-19
- */
- #[NoAdminRequired]
- public function update(
- int $id,
- ?string $name=null,
- ?string $description=null,
- ?array $placements=null,
- ?string $icon=null,
- ?string $parentUuid=null,
- ?string $slug=null,
- ?int $sortOrder=null
- ): JSONResponse {
- $user = $this->userSession->getUser();
- if ($user === null) {
- return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED);
- }
-
- $this->actionAuth->requireAction($user, 'dashboard.update');
-
- if ($this->userId === null) {
- return ResponseHelper::unauthorized();
- }
-
- // REQ-PERM-007: Metadata-only updates (name, description, icon) are
- // allowed for all permission levels. Widget/tile/layout changes
- // require add_only or full permission.
- $isMetadataOnly = $placements === null;
- if ($isMetadataOnly === true
- && $this->permissionService->canEditDashboardMetadata(
- userId: $this->userId,
- dashboardId: $id
- ) === false
- ) {
- return ResponseHelper::forbidden();
- }
-
- if ($isMetadataOnly === false
- && $this->permissionService->canEditDashboard(
- userId: $this->userId,
- dashboardId: $id
- ) === false
- ) {
- return ResponseHelper::forbidden();
- }
-
- try {
- $data = $this->buildUpdateData(
- name: $name,
- description: $description,
- placements: $placements,
- icon: $icon,
- parentUuid: $parentUuid,
- slug: $slug,
- sortOrder: $sortOrder
- );
-
- $dashboard = $this->dashboardService->updateDashboard(
- dashboardId: $id,
- userId: $this->userId,
- data: $data
- );
-
- // REQ-VERS-001: capture an automatic snapshot after the
- // PUT succeeds. The version service enforces its own
- // debounce window (60 s) so rapid drag-and-drop edits do
- // not flood the table. Failures are swallowed so they do
- // not surface to the dashboard PUT response.
- $this->captureAutomaticSnapshot(dashboard: $dashboard);
-
- return ResponseHelper::success(
- data: ['dashboard' => $dashboard->jsonSerialize()]
- );
- } catch (InvalidArgumentException $e) {
- // REQ-DASH-023..029: parent / slug / depth / cycle violations
- // surface as HTTP 400 with the validation message verbatim.
- return new JSONResponse(
- data: [
- 'status' => 'error',
- 'error' => 'invalid_argument',
- 'message' => $e->getMessage(),
- ],
- statusCode: Http::STATUS_BAD_REQUEST
- );
- } catch (\Exception $e) {
- return ResponseHelper::error(exception: $e);
- }//end try
- }//end update()
-
- /**
- * Delete a dashboard.
- *
- * Honours the cascade-delete guard from REQ-DASH-030: when the
- * dashboard has children the request MUST include `?cascade=true`
- * (case-insensitive) — otherwise the response is HTTP 409 with the
- * child count so the UI can surface a confirmation.
- *
- * @param int $id The dashboard ID.
- *
- * @return JSONResponse The deletion confirmation.
- *
- * @spec openspec/changes/retrofit-2026-05-24-annotate-launchpad/tasks.md#task-20
- */
- #[NoAdminRequired]
- public function delete(int $id): JSONResponse
- {
- $user = $this->userSession->getUser();
- if ($user === null) {
- return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED);
- }
-
- $this->actionAuth->requireAction($user, 'dashboard.delete');
-
- if ($this->userId === null) {
- return ResponseHelper::unauthorized();
- }
-
- $cascade = $this->resolveCascadeFlag();
-
- try {
- $this->dashboardService->deleteDashboard(
- dashboardId: $id,
- userId: $this->userId,
- cascade: $cascade
- );
-
- return ResponseHelper::success(data: ['status' => 'ok']);
- } catch (DashboardHasChildrenException $e) {
- // REQ-DASH-030: stable 409 envelope with the child count so
- // the frontend can render "Delete N children?" before
- // retrying with cascade=true.
- return new JSONResponse(
- data: [
- 'status' => 'error',
- 'error' => DashboardHasChildrenException::ERROR_CODE,
- 'message' => $e->getMessage(),
- 'childCount' => $e->getChildCount(),
- ],
- statusCode: Http::STATUS_CONFLICT
- );
- } catch (\Exception $e) {
- return ResponseHelper::error(exception: $e);
- }//end try
- }//end delete()
-
- /**
- * GET /api/dashboards/tree — return the nested dashboard tree scoped
- * to the calling user's visible dashboards (REQ-DASH-026).
- *
- * Each node carries `{uuid, name, slug, sortOrder, children: [...]}`.
- * Only nodes for dashboards that `DashboardService::getVisibleToUser`
- * resolves for the caller are included — personal drafts owned by
- * other users are not enumerable (C1 fix: REQ-DASH-026 + REQ-PERM-001).
- *
- * @return JSONResponse The nested tree.
- *
- * @spec openspec/specs/dashboards/spec.md
- */
- #[NoAdminRequired]
- public function tree(): JSONResponse
- {
- $user = $this->userSession->getUser();
- if ($user === null) {
- return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED);
- }
-
- $this->actionAuth->requireAction($user, 'dashboard.tree');
-
- if ($this->userId === null) {
- return ResponseHelper::unauthorized();
- }
-
- // C1 fix: build the visibility set for the calling user, then ask
- // the tree service for the structural tree filtered to those UUIDs.
- // This prevents cross-user IDOR via UUID enumeration through the tree.
- $visible = $this->dashboardService->getVisibleToUser(
- userId: $this->userId
- );
- $visibleUuids = [];
- foreach ($visible as $entry) {
- $uuid = $entry['dashboard']->getUuid();
- if ($uuid !== null && $uuid !== '') {
- $visibleUuids[$uuid] = true;
- }
- }
-
- $tree = $this->treeService->getFilteredTree(
- visibleUuids: $visibleUuids
- );
-
- return ResponseHelper::success(data: $tree);
- }//end tree()
-
- /**
- * GET /api/dashboards/by-path/{path} — resolve a slug-chain path
- * (REQ-DASH-027).
- *
- * Returns the matching dashboard with its computed `path` and
- * `breadcrumbs` (REQ-DASH-025) attached. Responds with 404 (not 403)
- * on any miss — including visibility misses — to avoid confirming that
- * a given slug exists to an unauthorised caller.
- *
- * C2 fix (REQ-DASH-027 + REQ-PERM-001): after slug resolution the
- * resolved dashboard is checked via PermissionService; callers with no
- * view access receive the same 404 they would get for an unknown slug.
- *
- * @param string $path The slug-joined path captured from the URL
- * (the `{path}` placeholder is regex-allowed
- * to include slashes — see `appinfo/routes.php`).
- *
- * @return JSONResponse The dashboard payload, or a 404 envelope.
- *
- * @spec openspec/specs/dashboards/spec.md
- */
- #[NoAdminRequired]
- public function byPath(string $path=''): JSONResponse
- {
- $user = $this->userSession->getUser();
- if ($user === null) {
- return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED);
- }
-
- $this->actionAuth->requireAction($user, 'dashboard.by-path');
-
- if ($this->userId === null) {
- return ResponseHelper::unauthorized();
- }
-
- if ($path === '') {
- $path = (string) $this->request->getParam(key: 'path', default: '');
- }
-
- $dashboard = $this->treeService->resolvePath(path: $path);
- if ($dashboard === null) {
- return new JSONResponse(
- data: [
- 'status' => 'error',
- 'error' => 'not_found',
- 'message' => 'Dashboard not found at path',
- ],
- statusCode: Http::STATUS_NOT_FOUND
- );
- }
-
- // C2 fix: verify the caller can see this dashboard. Return 404
- // (not 403) to avoid leaking that the slug exists at all.
- $dashboardId = (int) $dashboard->getId();
- if ($this->permissionService->canViewDashboard(
- userId: $this->userId,
- dashboardId: $dashboardId
- ) === false
- ) {
- return new JSONResponse(
- data: [
- 'status' => 'error',
- 'error' => 'not_found',
- 'message' => 'Dashboard not found at path',
- ],
- statusCode: Http::STATUS_NOT_FOUND
- );
- }
-
- $uuid = (string) $dashboard->getUuid();
- $serialised = $dashboard->jsonSerialize();
- $serialised['path'] = $this->treeService->computePath(uuid: $uuid);
- $serialised['breadcrumbs'] = $this->treeService->computeBreadcrumbs(
- uuid: $uuid
- );
-
- return ResponseHelper::success(
- data: ['dashboard' => $serialised]
- );
- }//end byPath()
-
- /**
- * GET /api/dashboards/{uuid}/path — return a dashboard's canonical
- * slug-chain path.
- *
- * Used by the frontend after every sidebar switch to keep the
- * browser URL in sync with the active dashboard. The path is the
- * leading-slash slug-chain returned by
- * {@see DashboardTreeService::computePath()}; an empty string means
- * the UUID does not resolve OR the dashboard has no slug (legal —
- * NULL slugs are simply unaddressable by path), and the frontend
- * treats either case as "leave the URL alone".
- *
- * @param string $uuid Dashboard UUID captured from the URL.
- *
- * @return JSONResponse `{path: string}` envelope (always 200 when
- * authorised — the empty-path case is a valid
- * response shape the caller distinguishes
- * client-side).
- *
- * @spec openspec/specs/dashboards/spec.md
- */
- #[NoAdminRequired]
- public function computePath(string $uuid=''): JSONResponse
- {
- $user = $this->userSession->getUser();
- if ($user === null) {
- return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED);
- }
-
- $this->actionAuth->requireAction($user, 'dashboard.compute-path');
-
- if ($this->userId === null) {
- return ResponseHelper::unauthorized();
- }
-
- if ($uuid === '') {
- return new JSONResponse(
- data: [
- 'status' => 'error',
- 'error' => 'missing_uuid',
- 'message' => 'UUID is required',
- ],
- statusCode: Http::STATUS_BAD_REQUEST
- );
- }
-
- return ResponseHelper::success(
- data: ['path' => $this->treeService->computePath(uuid: $uuid)]
- );
- }//end computePath()
-
- /**
- * Activate a dashboard.
- *
- * @param int $id The dashboard ID.
- *
- * @return JSONResponse The activated dashboard.
- *
- * @spec openspec/specs/dashboards/spec.md
- */
- #[NoAdminRequired]
- public function activate(int $id): JSONResponse
- {
- $user = $this->userSession->getUser();
- if ($user === null) {
- return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED);
- }
-
- $this->actionAuth->requireAction($user, 'dashboard.activate');
-
- if ($this->userId === null) {
- return ResponseHelper::unauthorized();
- }
-
- try {
- $dashboard = $this->dashboardService->activateDashboard(
- dashboardId: $id,
- userId: $this->userId
- );
-
- return ResponseHelper::success(
- data: ['dashboard' => $dashboard->jsonSerialize()]
- );
- } catch (\Exception $e) {
- return ResponseHelper::error(exception: $e);
- }
- }//end activate()
-
- /**
- * List the group-shared dashboards in a single group.
- *
- * Any logged-in user may list. REQ-DASH-014.
- *
- * @param string $groupId The group ID.
- *
- * @return JSONResponse The list of group-shared dashboards.
- *
- * @spec openspec/specs/dashboards/spec.md
- */
- #[NoAdminRequired]
- public function listGroup(string $groupId): JSONResponse
- {
- $user = $this->userSession->getUser();
- if ($user === null) {
- return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED);
- }
-
- $this->actionAuth->requireAction($user, 'dashboard.list-group');
-
- if ($this->userId === null) {
- return ResponseHelper::unauthorized();
- }
-
- // H1: verify the caller is a member of the requested group (or
- // admin) before returning its dashboards — mirrors the group-
- // membership check in PermissionService::resolveAccessLevel.
- if ($this->dashboardService->userCanAccessGroup(
- userId: $this->userId,
- groupId: $groupId
- ) === false
- ) {
- return ResponseHelper::forbidden();
- }
-
- $dashboards = $this->dashboardService->listGroupDashboards(
- groupId: $groupId
- );
-
- // M5: strip internal identity fields (userId, groupId, targetGroups)
- // from group-shared dashboard payloads returned to non-owner viewers.
- $viewerData = array_map(
- static fn ($dashboard) => $dashboard->toViewerArray(),
- $dashboards
- );
-
- return ResponseHelper::success(data: $viewerData);
- }//end listGroup()
-
- /**
- * Create a new group-shared dashboard.
- *
- * Admin-only — enforced by the `#[AuthorizedAdminSetting]` attribute
- * (gate-route-auth / gate-semantic-auth both pass since the
- * framework-level check is the actual authorization point).
- * REQ-DASH-014.
- *
- * @param string $groupId The group ID.
- * @param mixed $name The dashboard name (or {name,...}
- * dict as the body).
- * @param string|null $description The dashboard description.
- *
- * @return JSONResponse The created dashboard.
- *
- * @spec openspec/specs/dashboards/spec.md
- */
- #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
- public function createGroup(
- string $groupId,
- $name=null,
- ?string $description=null
- ): JSONResponse {
- if ($this->userId === null) {
- return ResponseHelper::unauthorized();
- }
-
- $resolved = $this->resolveCreateParams(
- name: $name,
- description: $description
- );
-
- try {
- $dashboard = $this->dashboardService->createGroupShared(
- actorUserId: $this->userId,
- groupId: $groupId,
- name: $resolved['name'],
- description: $resolved['description']
- );
-
- return ResponseHelper::success(
- data: ['dashboard' => $dashboard->jsonSerialize()],
- statusCode: Http::STATUS_CREATED
- );
- } catch (\Exception $e) {
- return ResponseHelper::error(exception: $e);
- }
- }//end createGroup()
-
- /**
- * Get a single group-shared dashboard with placements.
- *
- * @param string $groupId The group ID from the URL.
- * @param string $uuid The dashboard UUID from the URL.
- *
- * @return JSONResponse The dashboard payload.
- *
- * @spec openspec/specs/dashboards/spec.md
- */
- #[NoAdminRequired]
- public function getGroup(
- string $groupId,
- string $uuid
- ): JSONResponse {
- $user = $this->userSession->getUser();
- if ($user === null) {
- return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED);
- }
-
- $this->actionAuth->requireAction($user, 'dashboard.get-group');
-
- if ($this->userId === null) {
- return ResponseHelper::unauthorized();
- }
-
- // H1: verify the caller is a member of the requested group (or
- // admin) before fetching the dashboard payload.
- if ($this->dashboardService->userCanAccessGroup(
- userId: $this->userId,
- groupId: $groupId
- ) === false
- ) {
- return ResponseHelper::forbidden();
- }
-
- try {
- $dashboard = $this->dashboardService->findGroupDashboard(
- groupId: $groupId,
- uuid: $uuid
- );
- } catch (DoesNotExistException) {
- // ADR-005: do not leak raw exception messages to clients.
- return new JSONResponse(
- data: ['error' => 'Dashboard not found'],
- statusCode: Http::STATUS_NOT_FOUND
- );
- }
-
- // M5: strip internal identity fields from viewer-facing payload.
- return ResponseHelper::success(
- data: ['dashboard' => $dashboard->toViewerArray()]
- );
- }//end getGroup()
-
- /**
- * Update a group-shared dashboard. Admin-only.
- *
- * @param string $groupId The group ID from the URL.
- * @param string $uuid The dashboard UUID from the URL.
- * @param string|null $name The new name.
- * @param string|null $description The new description.
- * @param int|null $gridColumns The new grid column count.
- * @param array|null $placements Updated placements.
- *
- * @return JSONResponse The updated dashboard.
- *
- * @spec openspec/specs/dashboards/spec.md
- */
- #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
- public function updateGroup(
- string $groupId,
- string $uuid,
- ?string $name=null,
- ?string $description=null,
- ?int $gridColumns=null,
- ?array $placements=null
- ): JSONResponse {
- if ($this->userId === null) {
- return ResponseHelper::unauthorized();
- }
-
- $patch = $this->buildGroupUpdateData(
- name: $name,
- description: $description,
- gridColumns: $gridColumns,
- placements: $placements
- );
-
- try {
- $dashboard = $this->dashboardService->updateGroupShared(
- actorUserId: $this->userId,
- groupId: $groupId,
- uuid: $uuid,
- patch: $patch
- );
-
- return ResponseHelper::success(
- data: ['dashboard' => $dashboard->jsonSerialize()]
- );
- } catch (DoesNotExistException) {
- // ADR-005: do not leak raw exception messages to clients.
- return new JSONResponse(
- data: ['error' => 'Dashboard not found'],
- statusCode: Http::STATUS_NOT_FOUND
- );
- } catch (\Exception $e) {
- return ResponseHelper::error(exception: $e);
- }//end try
- }//end updateGroup()
-
- /**
- * Delete a group-shared dashboard. Admin-only.
- *
- * Returns HTTP 400 when the last-in-group guard rejects the delete
- * (REQ-DASH-014).
- *
- * @param string $groupId The group ID from the URL.
- * @param string $uuid The dashboard UUID from the URL.
- *
- * @return JSONResponse The status payload.
- *
- * @spec openspec/specs/dashboards/spec.md
- */
- #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
- public function deleteGroup(
- string $groupId,
- string $uuid
- ): JSONResponse {
- if ($this->userId === null) {
- return ResponseHelper::unauthorized();
- }
-
- try {
- $this->dashboardService->deleteGroupShared(
- actorUserId: $this->userId,
- groupId: $groupId,
- uuid: $uuid
- );
-
- return ResponseHelper::success(data: ['status' => 'ok']);
- } catch (DoesNotExistException) {
- // ADR-005: do not leak raw exception messages to clients.
- return new JSONResponse(
- data: ['error' => 'Dashboard not found'],
- statusCode: Http::STATUS_NOT_FOUND
- );
- } catch (\Exception $e) {
- return ResponseHelper::error(exception: $e);
- }//end try
- }//end deleteGroup()
-
- /**
- * Promote a single group-shared dashboard to the group's default.
- *
- * Admin-only — enforced by the `#[AuthorizedAdminSetting]` attribute.
- * The body payload is `{"uuid": "..."}`. Returns 404 when the uuid
- * does not belong to the given groupId. REQ-DASH-015.
- *
- * @param string $groupId The group ID from the URL.
- * @param string|null $uuid The dashboard UUID from the body.
- *
- * @return JSONResponse The status payload.
- *
- * @spec openspec/specs/dashboards/spec.md
- */
- #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
- public function setGroupDefault(
- string $groupId,
- ?string $uuid=null
- ): JSONResponse {
- if ($this->userId === null) {
- return ResponseHelper::unauthorized();
- }
-
- if ($uuid === null || $uuid === '') {
- return ResponseHelper::error(
- exception: new InvalidArgumentException(
- 'Missing required field: uuid'
- ),
- statusCode: Http::STATUS_BAD_REQUEST
- );
- }
-
- try {
- $this->dashboardService->setGroupDefault(
- actorUserId: $this->userId,
- groupId: $groupId,
- uuid: $uuid
- );
-
- return ResponseHelper::success(
- data: [
- 'status' => 'ok',
- 'groupId' => $groupId,
- 'uuid' => $uuid,
- ]
- );
- } catch (DoesNotExistException) {
- // ADR-005: do not leak raw exception messages to clients.
- return new JSONResponse(
- data: ['error' => 'Dashboard not found'],
- statusCode: Http::STATUS_NOT_FOUND
- );
- } catch (\Exception $e) {
- return ResponseHelper::error(exception: $e);
- }//end try
- }//end setGroupDefault()
-
- /**
- * Persist the user's active-dashboard preference.
- *
- * Accepts any UUID string (including non-existent UUIDs — the resolver's
- * stale-pref path handles invalid values on next render). Empty string
- * clears the preference. REQ-DASH-019.
- *
- * @param string|null $uuid The dashboard UUID from the request body, or
- * empty string to clear.
- *
- * @return JSONResponse HTTP 200 `{status: 'success'}` on success; 401
- * when the session has no user.
- *
- * @spec openspec/specs/dashboards/spec.md
- */
- #[NoAdminRequired]
- public function setActiveDashboard(?string $uuid=null): JSONResponse
- {
- $user = $this->userSession->getUser();
- if ($user === null) {
- return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED);
- }
-
- $this->actionAuth->requireAction($user, 'dashboard.set-active-dashboard');
-
- if ($this->userId === null) {
- return ResponseHelper::unauthorized();
- }
-
- $this->dashboardService->setActivePreference(
- userId: $this->userId,
- uuid: ($uuid ?? '')
- );
-
- return ResponseHelper::success(data: ['status' => 'success']);
- }//end setActiveDashboard()
-
- /**
- * Pin (or clear) the user's EXPLICIT default-dashboard choice
- * (wave3.7).
- *
- * Distinct from {@see self::setActiveDashboard()} — this pref is
- * only ever written when the user explicitly clicks "Set as
- * default" on a row's cog menu, and is NOT auto-overwritten on
- * every switch. The resolver checks it before the active pref so
- * the pin survives across switches.
- *
- * Body shape: `{uuid: string}` — empty string clears the pin.
- *
- * @param string|null $uuid The dashboard UUID, or empty string to clear.
- *
- * @return JSONResponse 200 `{status: 'success'}` on success; 401
- * when the session has no user.
- *
- * @spec openspec/specs/dashboards/spec.md
- */
- #[NoAdminRequired]
- public function setDefaultDashboard(?string $uuid=null): JSONResponse
- {
- $user = $this->userSession->getUser();
- if ($user === null) {
- return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED);
- }
-
- $this->actionAuth->requireAction($user, 'dashboard.set-default-dashboard');
-
- if ($this->userId === null) {
- return ResponseHelper::unauthorized();
- }
-
- $this->dashboardService->setDefaultPreference(
- userId: $this->userId,
- uuid: ($uuid ?? '')
- );
-
- return ResponseHelper::success(data: ['status' => 'success']);
- }//end setDefaultDashboard()
-
- /**
- * Read the user's EXPLICIT default-dashboard pin (wave3.7).
- *
- * @return JSONResponse 200 `{uuid: string}` — empty string when no
- * pin set; 401 when the session has no user.
- *
- * @spec openspec/specs/dashboards/spec.md
- */
- #[NoAdminRequired]
- public function getDefaultDashboard(): JSONResponse
- {
- $user = $this->userSession->getUser();
- if ($user === null) {
- return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED);
- }
-
- $this->actionAuth->requireAction($user, 'dashboard.get-default-dashboard');
-
- if ($this->userId === null) {
- return ResponseHelper::unauthorized();
- }
-
- return ResponseHelper::success(
- data: [
- 'uuid' => $this->dashboardService->getDefaultPreference(
- userId: $this->userId
- ),
- ]
- );
- }//end getDefaultDashboard()
-
- /**
- * Fork any visible dashboard into a brand-new personal copy.
- *
- * REQ-DASH-020 / REQ-DASH-021 / REQ-DASH-022. Body shape:
- * `{name?: string}` — when `name` is absent the system applies the
- * default `t('My copy of {name}', source.name)` translated via the
- * caller's active language.
- *
- * Status mapping:
- * - HTTP 201 with the full new dashboard payload on success.
- * - HTTP 401 when the session has no user.
- * - HTTP 403 with stable error code `personal_dashboards_disabled`
- * when the admin flag `allow_user_dashboards` is off — REQ-ASET-003
- * runtime gating runs FIRST so the envelope shape is stable
- * regardless of body contents.
- * - HTTP 404 when the source UUID is not visible to the caller —
- * do not leak existence (REQ-DASH-020 scenario "Cannot fork a
- * dashboard you cannot read").
- * - HTTP 500 when a partial-failure rollback fires — REQ-DASH-021.
- * ADR-005: the response carries a stable error code and a generic
- * user-facing message; the underlying exception is logged for ops.
- *
- * @param string $uuid The source dashboard UUID from the URL.
- * @param string|null $name Optional explicit fork name from the body.
- *
- * @return JSONResponse The new dashboard payload (201) or an
- * appropriate error envelope.
- *
- * @spec openspec/specs/dashboards/spec.md
- */
- #[NoAdminRequired]
- public function fork(
- string $uuid,
- ?string $name=null
- ): JSONResponse {
- if ($this->userId === null) {
- return ResponseHelper::unauthorized();
- }
-
- try {
- $fork = $this->dashboardService->forkAsPersonal(
- userId: $this->userId,
- sourceUuid: $uuid,
- name: $name
- );
-
- return new JSONResponse(
- data: [
- 'status' => 'success',
- 'dashboard' => $fork->jsonSerialize(),
- ],
- statusCode: Http::STATUS_CREATED
- );
- } catch (PersonalDashboardsDisabledException $e) {
- return new JSONResponse(
- data: [
- 'status' => 'error',
- 'error' => $e->getErrorCode(),
- 'message' => $e->getMessage(),
- ],
- statusCode: Http::STATUS_FORBIDDEN
- );
- } catch (QuotaExceededException $e) {
- // Dashboard-quota-limits REQ-QUOTA-002: a fork is bound by the
- // per-user dashboard quota — HTTP 409 with the structured body.
- return ResponseHelper::quotaExceeded(exception: $e);
- } catch (DoesNotExistException) {
- // REQ-DASH-020: source not visible — 404 without leaking
- // existence (use the canonical message rather than echoing
- // the exception detail).
- return new JSONResponse(
- data: [
- 'status' => 'error',
- 'error' => 'not_found',
- ],
- statusCode: Http::STATUS_NOT_FOUND
- );
- } catch (\Throwable $t) {
- // REQ-DASH-021 + ADR-005: log the real cause, return a
- // stable, generic envelope to the client.
- $this->logger->error(
- message: 'launchpad: fork failed for user {user}: {message}',
- context: [
- 'user' => $this->userId,
- 'message' => $t->getMessage(),
- ]
- );
- return new JSONResponse(
- data: [
- 'status' => 'error',
- 'error' => 'internal_error',
- 'message' => 'An unexpected error occurred',
- ],
- statusCode: Http::STATUS_INTERNAL_SERVER_ERROR
- );
- }//end try
- }//end fork()
-
- /**
- * Publish a dashboard. REQ-DASH-032.
- *
- * Owner-or-admin gated at the service boundary; the route attribute
- * is `#[NoAdminRequired]` because the in-body owner check is the
- * actual authorization point (gate-semantic-auth).
- *
- * @param string $uuid The dashboard UUID from the URL.
- *
- * @return JSONResponse The updated dashboard payload.
- *
- * @spec openspec/specs/dashboards/spec.md
- */
- #[NoAdminRequired]
- public function publish(string $uuid): JSONResponse
- {
- $user = $this->userSession->getUser();
- if ($user === null) {
- return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED);
- }
-
- $this->actionAuth->requireAction($user, 'dashboard.publish');
-
- if ($this->userId === null) {
- return ResponseHelper::unauthorized();
- }
-
- try {
- $dashboard = $this->dashboardService->publishDashboard(
- uuid: $uuid,
- userId: $this->userId
- );
-
- return ResponseHelper::success(
- data: ['dashboard' => $dashboard->jsonSerialize()]
- );
- } catch (DoesNotExistException) {
- return new JSONResponse(
- data: [
- 'status' => 'error',
- 'error' => 'not_found',
- ],
- statusCode: Http::STATUS_NOT_FOUND
- );
- } catch (\Exception $e) {
- return $this->mapPublicationError(exception: $e);
- }//end try
- }//end publish()
-
- /**
- * Unpublish a dashboard. REQ-DASH-033.
- *
- * @param string $uuid The dashboard UUID from the URL.
- *
- * @return JSONResponse The updated dashboard payload.
- *
- * @spec openspec/specs/dashboards/spec.md
- */
- #[NoAdminRequired]
- public function unpublish(string $uuid): JSONResponse
- {
- $user = $this->userSession->getUser();
- if ($user === null) {
- return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED);
- }
-
- $this->actionAuth->requireAction($user, 'dashboard.unpublish');
-
- if ($this->userId === null) {
- return ResponseHelper::unauthorized();
- }
-
- try {
- $dashboard = $this->dashboardService->unpublish(
- uuid: $uuid,
- userId: $this->userId
- );
-
- return ResponseHelper::success(
- data: ['dashboard' => $dashboard->jsonSerialize()]
- );
- } catch (DoesNotExistException) {
- return new JSONResponse(
- data: [
- 'status' => 'error',
- 'error' => 'not_found',
- ],
- statusCode: Http::STATUS_NOT_FOUND
- );
- } catch (\Exception $e) {
- return $this->mapPublicationError(exception: $e);
- }//end try
- }//end unpublish()
-
- /**
- * Schedule a dashboard for automatic publication. REQ-DASH-034.
- *
- * Body: `{"publishAt": "2026-04-01T10:00:00Z"}`. Returns 400 with an
- * i18n-friendly error message when `publishAt` is missing,
- * unparseable, or in the past; 403 when the actor is neither owner
- * nor admin.
- *
- * @param string $uuid The dashboard UUID from the URL.
- * @param string|null $publishAt The future ISO-8601 timestamp from
- * the request body.
- *
- * @return JSONResponse The updated dashboard payload.
- *
- * @spec openspec/specs/dashboards/spec.md
- */
- #[NoAdminRequired]
- public function schedule(
- string $uuid,
- ?string $publishAt=null
- ): JSONResponse {
- $user = $this->userSession->getUser();
- if ($user === null) {
- return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED);
- }
-
- $this->actionAuth->requireAction($user, 'dashboard.schedule');
-
- if ($this->userId === null) {
- return ResponseHelper::unauthorized();
- }
-
- if ($publishAt === null || $publishAt === '') {
- return new JSONResponse(
- data: [
- 'status' => 'error',
- 'error' => 'invalid_argument',
- 'message' => DashboardService::ERR_SCHEDULE_PAST_DATE,
- ],
- statusCode: Http::STATUS_BAD_REQUEST
- );
- }
-
- try {
- $dashboard = $this->dashboardService->schedule(
- uuid: $uuid,
- publishAt: $publishAt,
- userId: $this->userId
- );
-
- return ResponseHelper::success(
- data: ['dashboard' => $dashboard->jsonSerialize()]
- );
- } catch (DoesNotExistException) {
- return new JSONResponse(
- data: [
- 'status' => 'error',
- 'error' => 'not_found',
- ],
- statusCode: Http::STATUS_NOT_FOUND
- );
- } catch (InvalidArgumentException $e) {
- return new JSONResponse(
- data: [
- 'status' => 'error',
- 'error' => 'invalid_argument',
- 'message' => $e->getMessage(),
- ],
- statusCode: Http::STATUS_BAD_REQUEST
- );
- } catch (\Exception $e) {
- return $this->mapPublicationError(exception: $e);
- }//end try
- }//end schedule()
-
- /**
- * Record a dashboard view event (REQ-ANLT-002).
- *
- * Authenticated users only — POST `{}` body. Returns HTTP 204
- * after the daily counter has been incremented. Short-circuits
- * silently to 204 when the user has opted out (REQ-ANLT-004) or
- * when global analytics is disabled (REQ-ANLT-005). Returns 404
- * when the dashboard does not exist.
- *
- * @param string $uuid The dashboard UUID from the URL.
- *
- * @return JSONResponse An empty 204 response on success, 401
- * when unauthenticated, 404 when the
- * dashboard does not exist.
- *
- * @spec openspec/specs/dashboards/spec.md
- */
- #[NoAdminRequired]
- public function viewEvent(string $uuid): JSONResponse
- {
- $user = $this->userSession->getUser();
- if ($user === null) {
- return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED);
- }
-
- $this->actionAuth->requireAction($user, 'dashboard.view-event');
-
- if ($this->userId === null) {
- return ResponseHelper::unauthorized();
- }
-
- // H4: resolve the dashboard and assert the caller can view it
- // before recording any counter increment (REQ-ANLT-002).
- try {
- $dashboard = $this->dashboardService->findByUuid(uuid: $uuid);
- } catch (DoesNotExistException) {
- return new JSONResponse(
- data: [
- 'status' => 'error',
- 'error' => 'not_found',
- ],
- statusCode: Http::STATUS_NOT_FOUND
- );
- }
-
- if ($this->permissionService->canViewDashboard(
- userId: $this->userId,
- dashboardId: $dashboard->getId()
- ) === false
- ) {
- return ResponseHelper::forbidden();
- }
-
- try {
- $this->analyticsService->recordViewEvent(
- dashboardUuid: $uuid,
- userId: $this->userId
- );
- } catch (DoesNotExistException) {
- return new JSONResponse(
- data: [
- 'status' => 'error',
- 'error' => 'not_found',
- ],
- statusCode: Http::STATUS_NOT_FOUND
- );
- }
-
- return new JSONResponse(
- data: [],
- statusCode: Http::STATUS_NO_CONTENT
- );
- }//end viewEvent()
-
- /**
- * Map publication-related service exceptions onto the right HTTP
- * status. REQ-DASH-032..034.
- *
- * The service raises `Exception` with the sentinel message
- * {@see DashboardService::ERR_FORBIDDEN_NOT_OWNER_OR_ADMIN} when
- * the actor is not allowed; everything else falls through to the
- * generic ResponseHelper::error path.
- *
- * @param \Exception $exception The thrown exception.
- *
- * @return JSONResponse The mapped error envelope.
- */
- private function mapPublicationError(\Exception $exception): JSONResponse
- {
- if ($exception->getMessage() === DashboardService::ERR_FORBIDDEN_NOT_OWNER_OR_ADMIN
- ) {
- return ResponseHelper::forbidden(
- message: DashboardService::ERR_FORBIDDEN_NOT_OWNER_OR_ADMIN
- );
- }
-
- return ResponseHelper::error(exception: $exception);
- }//end mapPublicationError()
-
- /**
- * Resolve create parameters from JSON body or individual params.
- *
- * Forwards the new hierarchy fields (`parentUuid`, `slug`,
- * `sortOrder`) introduced by REQ-DASH-023..029. When the caller
- * sends a JSON body the helper inspects the array form first; when
- * positional / typed params come via the framework binding it
- * falls back to those.
- *
- * @param mixed $name The name parameter.
- * @param string|null $description The description parameter.
- * @param string|null $icon The icon parameter.
- * @param string|null $parentUuid Optional parent UUID.
- * @param string|null $slug Optional caller-supplied slug.
- * @param int|null $sortOrder Optional sort order.
- *
- * @return array{name: string, description: ?string, icon: ?string, parentUuid: ?string, slug: ?string, sortOrder: int}
- * The resolved values.
- */
- private function resolveCreateParams(
- $name,
- ?string $description,
- ?string $icon=null,
- ?string $parentUuid=null,
- ?string $slug=null,
- ?int $sortOrder=null
- ): array {
- if (is_array($name) === true) {
- $bodyIcon = ($name['icon'] ?? null);
- $resolvedIcon = null;
- if (is_string($bodyIcon) === true) {
- $resolvedIcon = $bodyIcon;
- }
-
- $bodyParent = ($name['parentUuid'] ?? null);
- $bodySlug = ($name['slug'] ?? null);
- $bodySort = ($name['sortOrder'] ?? null);
- $resolvedParent = null;
- if (is_string($bodyParent) === true) {
- $resolvedParent = $bodyParent;
- }
-
- $resolvedSlug = null;
- if (is_string($bodySlug) === true) {
- $resolvedSlug = $bodySlug;
- }
-
- $resolvedSort = 0;
- if (is_numeric($bodySort) === true) {
- $resolvedSort = (int) $bodySort;
- }
-
- return [
- 'name' => $name['name'] ?? 'My Dashboard',
- 'description' => $name['description'] ?? null,
- 'icon' => $resolvedIcon,
- 'parentUuid' => $resolvedParent,
- 'slug' => $resolvedSlug,
- 'sortOrder' => $resolvedSort,
- ];
- }//end if
-
- return [
- 'name' => $name ?? 'My Dashboard',
- 'description' => $description,
- 'icon' => $icon,
- 'parentUuid' => $parentUuid,
- 'slug' => $slug,
- 'sortOrder' => ($sortOrder ?? 0),
- ];
- }//end resolveCreateParams()
-
- /**
- * Read the case-insensitive `cascade` query param (REQ-DASH-030).
- *
- * `?cascade=true|TRUE|True|1|yes|on|cascade` → true; anything else
- * (including the param being absent) → false.
- *
- * @return bool Whether cascade-delete was explicitly requested.
- */
- private function resolveCascadeFlag(): bool
- {
- $raw = $this->request->getParam(key: 'cascade');
- if ($raw === null) {
- return false;
- }
-
- $lower = strtolower((string) $raw);
- return in_array(
- $lower,
- ['true', '1', 'yes', 'on', 'cascade'],
- true
- );
- }//end resolveCascadeFlag()
-
- /**
- * Check creation permissions and return error if denied.
- *
- * @param string $userId The user ID.
- *
- * @return JSONResponse|null Error response or null if allowed.
- */
- private function checkCreatePermissions(string $userId): ?JSONResponse
- {
- if ($this->permissionService->canCreateDashboard(
- userId: $userId
- ) === false
- ) {
- return ResponseHelper::forbidden(
- message: 'Dashboard creation not allowed'
- );
- }
-
- $existing = $this->dashboardService->getUserDashboards(
- userId: $userId
- );
- if (empty($existing) === false
- && $this->permissionService->canHaveMultipleDashboards() === false
- ) {
- return ResponseHelper::forbidden(
- message: 'Multiple dashboards not allowed'
- );
- }
-
- return null;
- }//end checkCreatePermissions()
-
- /**
- * Build update data from nullable parameters.
- *
- * @param string|null $name The name.
- * @param string|null $description The description.
- * @param array|null $placements The placements.
- * @param string|null $icon The icon registry key, URL, or NULL/empty.
- * @param string|null $parentUuid Optional new parent UUID
- * (REQ-DASH-023). The literal sentinel
- * `__null__` clears the parent
- * (re-roots the dashboard) — needed
- * because the framework cannot
- * distinguish "not in payload" from
- * "explicit NULL" with typed-string
- * binding.
- * @param string|null $slug Optional new slug (REQ-DASH-024).
- * @param int|null $sortOrder Optional new sort order
- * (REQ-DASH-029).
- *
- * @return array The non-null update data.
- */
- private function buildUpdateData(
- ?string $name,
- ?string $description,
- ?array $placements,
- ?string $icon=null,
- ?string $parentUuid=null,
- ?string $slug=null,
- ?int $sortOrder=null
- ): array {
- $fields = [
- 'name' => $name,
- 'description' => $description,
- 'placements' => $placements,
- ];
-
- $data = array_filter(
- array: $fields,
- callback: function ($value) {
- return $value !== null;
- }
- );
-
- // Icon explicitly supports NULL/empty (resets to the default
- // glyph), so it must be merged separately from the array_filter
- // above. Caller distinguishes "not in payload" via the default
- // null sentinel.
- if ($icon !== null) {
- $data['icon'] = $icon;
- }
-
- // REQ-DASH-023: `parentUuid = '__null__'` is the agreed sentinel
- // for "re-root this dashboard" because the framework's typed
- // string binding cannot represent an explicit NULL. Anything
- // else (non-null string) is forwarded verbatim — including the
- // empty string, which the service treats as a NULL parent.
- if ($parentUuid !== null) {
- $data['parentUuid'] = $parentUuid;
- if ($parentUuid === '__null__' || $parentUuid === '') {
- $data['parentUuid'] = null;
- }
- }
-
- if ($slug !== null) {
- $data['slug'] = $slug;
- }
-
- if ($sortOrder !== null) {
- $data['sortOrder'] = $sortOrder;
- }
-
- return $data;
- }//end buildUpdateData()
-
- /**
- * Build the patch payload for the group-shared update endpoint.
- *
- * @param string|null $name The new name.
- * @param string|null $description The new description.
- * @param int|null $gridColumns The new grid columns.
- * @param array|null $placements Updated placements.
- *
- * @return array The non-null patch fields.
- */
- private function buildGroupUpdateData(
- ?string $name,
- ?string $description,
- ?int $gridColumns,
- ?array $placements
- ): array {
- $fields = [
- 'name' => $name,
- 'description' => $description,
- 'gridColumns' => $gridColumns,
- 'placements' => $placements,
- ];
-
- return array_filter(
- array: $fields,
- callback: function ($value) {
- return $value !== null;
- }
- );
- }//end buildGroupUpdateData()
-
- /**
- * Capture an automatic version snapshot after a successful update
- * (REQ-VERS-001). The version service enforces a 60-second debounce
- * window so a flurry of drag-and-drop saves does not flood the
- * table.
- *
- * Failures here MUST NOT surface to the dashboard PUT response —
- * the user's edit succeeded; missing one snapshot is a quality of
- * life regression, not a data-integrity bug. We log + swallow.
- *
- * @param \OCA\LaunchPad\Db\Dashboard $dashboard The dashboard that was
- * just updated.
- *
- * @return void
- */
- private function captureAutomaticSnapshot(
- \OCA\LaunchPad\Db\Dashboard $dashboard
- ): void {
- if ($this->userId === null) {
- return;
- }
-
- try {
- $this->versionService->captureSnapshot(
- dashboard: $dashboard,
- snapshotJson: null,
- createdBy: $this->userId,
- note: null,
- explicit: false
- );
- } catch (\Throwable $t) {
- $this->logger->warning(
- message: 'launchpad: automatic version snapshot failed',
- context: ['exception' => $t]
- );
- }
- }//end captureAutomaticSnapshot()
+class DashboardApiController extends Controller {
+ /**
+ * Constructor
+ *
+ * @param IRequest $request The request.
+ * @param DashboardService $dashboardService The dashboard service.
+ * @param PermissionService $permissionService The permission service.
+ * @param DashboardTreeService $treeService The tree service that
+ * owns hierarchy
+ * queries, cycle
+ * detection, slug
+ * uniqueness, path
+ * resolution, and the
+ * cascade-delete walker
+ * (REQ-DASH-023..030).
+ * @param DashboardVersionService $versionService Snapshot service
+ * (REQ-VERS-001) —
+ * automatic
+ * snapshots fire
+ * after every
+ * successful PUT
+ * via the
+ * debounced
+ * `captureSnapshot`
+ * helper.
+ * @param AnalyticsService $analyticsService The view-analytics
+ * service used by the
+ * `viewEvent` endpoint
+ * (REQ-ANLT-002).
+ * @param LoggerInterface $logger PSR logger (used by
+ * fork to report
+ * unexpected errors
+ * — REQ-DASH-021).
+ * @param IUserSession $userSession The user session, used
+ * to resolve the
+ * authenticated IUser for
+ * ADR-023 action checks.
+ * @param ActionAuthService $actionAuth The ADR-023 action
+ * authorization service.
+ * @param string|null $userId The user ID.
+ * @param QuotaService|null $quotaService The quota-enforcement
+ * service used to gate
+ * dashboard creation
+ * (dashboard-quota-limits).
+ */
+ public function __construct(
+ IRequest $request,
+ private readonly DashboardService $dashboardService,
+ private readonly PermissionService $permissionService,
+ private readonly DashboardTreeService $treeService,
+ private readonly DashboardVersionService $versionService,
+ private readonly AnalyticsService $analyticsService,
+ private readonly LoggerInterface $logger,
+ private readonly IUserSession $userSession,
+ private readonly ActionAuthService $actionAuth,
+ private readonly ?string $userId,
+ private readonly ?QuotaService $quotaService = null,
+ ) {
+ parent::__construct(
+ appName: Application::APP_ID,
+ request: $request
+ );
+ }//end __construct()
+
+ /**
+ * List all personal dashboards for the current user.
+ *
+ * Backward compatible — this endpoint never returns group-shared
+ * dashboards (REQ-DASH-014). Use {@see self::visible()} for the
+ * unioned listing.
+ *
+ * @return JSONResponse The list of dashboards.
+ *
+ * @spec openspec/changes/retrofit-2026-05-24-annotate-launchpad/tasks.md#task-17
+ */
+ #[NoAdminRequired]
+ public function list(): JSONResponse {
+ $user = $this->userSession->getUser();
+ if ($user === null) {
+ return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED);
+ }
+
+ $this->actionAuth->requireAction($user, 'dashboard.list');
+
+ if ($this->userId === null) {
+ return ResponseHelper::unauthorized();
+ }
+
+ $dashboards = $this->dashboardService->getUserDashboards(
+ userId: $this->userId
+ );
+
+ $serialized = ResponseHelper::serializeList(entities: $dashboards);
+
+ // Dashboard-quota-limits REQ-QUOTA-006: additive quota envelope on
+ // the personal dashboards list. Response shape is
+ // `{items: [...], quota: {...}}`. When the quota service is absent
+ // (legacy test doubles) fall back to the bare-array contract.
+ if ($this->quotaService === null) {
+ return ResponseHelper::success(data: $serialized);
+ }
+
+ return ResponseHelper::success(
+ data: [
+ 'items' => $serialized,
+ 'quota' => $this->quotaService->getQuotaStatus(
+ userId: $this->userId
+ ),
+ ]
+ );
+ }//end list()
+
+ /**
+ * List the deduplicated union of dashboards visible to the user.
+ *
+ * Returns personal + group-matching + default-group dashboards, each
+ * tagged with `source` (`'user'`, `'group'`, `'default'`).
+ * REQ-DASH-013.
+ *
+ * @return JSONResponse The visible dashboards.
+ *
+ * @spec openspec/specs/dashboards/spec.md
+ */
+ #[NoAdminRequired]
+ public function visible(): JSONResponse {
+ $user = $this->userSession->getUser();
+ if ($user === null) {
+ return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED);
+ }
+
+ $this->actionAuth->requireAction($user, 'dashboard.visible');
+
+ if ($this->userId === null) {
+ return ResponseHelper::unauthorized();
+ }
+
+ $items = $this->dashboardService->getVisibleToUser(
+ userId: $this->userId
+ );
+
+ $serialized = [];
+ foreach ($items as $entry) {
+ $row = $entry['dashboard']->jsonSerialize();
+ $row['source'] = $entry['source'];
+ // Tag ownership so the frontend can route activation correctly:
+ // only personal `user`-type rows owned by the caller take the
+ // legacy id-based `is_active` path; group/default rows (user_id
+ // NULL) are activated via the UUID preference instead.
+ $row['isOwner'] = ($entry['dashboard']->getUserId() === $this->userId);
+ $serialized[] = $row;
+ }
+
+ // Dashboard-quota-limits REQ-QUOTA-006: carry the additive quota
+ // envelope on the unioned listing the store consumes, so the
+ // frontend can disable create affordances at the limit without an
+ // extra round-trip. The response shape is now
+ // `{items: [...], quota: {...}}`; clients that read the bare array
+ // are handled by the store's shape-tolerant unwrap. When the quota
+ // service is absent (legacy test doubles) fall back to the
+ // bare-array contract.
+ if ($this->quotaService === null) {
+ return ResponseHelper::success(data: $serialized);
+ }
+
+ return ResponseHelper::success(
+ data: [
+ 'items' => $serialized,
+ 'quota' => $this->quotaService->getQuotaStatus(
+ userId: $this->userId
+ ),
+ ]
+ );
+ }//end visible()
+
+ /**
+ * Get the user's active dashboard with placements.
+ *
+ * @return JSONResponse The active dashboard data.
+ *
+ * @spec openspec/changes/retrofit-2026-05-24-annotate-launchpad/tasks.md#task-18
+ */
+ #[NoAdminRequired]
+ public function getActive(): JSONResponse {
+ $user = $this->userSession->getUser();
+ if ($user === null) {
+ return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED);
+ }
+
+ $this->actionAuth->requireAction($user, 'dashboard.get-active');
+
+ if ($this->userId === null) {
+ return ResponseHelper::unauthorized();
+ }
+
+ $result = $this->dashboardService->getEffectiveDashboard(
+ userId: $this->userId
+ );
+
+ if ($result === null) {
+ return ResponseHelper::success(
+ data: ['error' => 'No dashboard available'],
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ }
+
+ // The effective dashboard can now be a group/default (showcase)
+ // dashboard the user does not own (resolved via the last-used
+ // preference), so tag ownership the same way show() does rather
+ // than letting the client assume the caller owns it.
+ $activeDashboard = $result['dashboard'];
+ $isOwner = ($activeDashboard->getUserId() === $this->userId);
+
+ $sharedBy = null;
+ if ($isOwner === false) {
+ $sharedBy = $activeDashboard->getUserId();
+ }
+
+ return ResponseHelper::success(
+ data: [
+ 'dashboard' => $activeDashboard->jsonSerialize(),
+ 'placements' => ResponseHelper::serializeList(
+ entities: $result['placements']
+ ),
+ 'permissionLevel' => $result['permissionLevel'],
+ 'isOwner' => $isOwner,
+ 'sharedBy' => $sharedBy,
+ ]
+ );
+ }//end getActive()
+
+ /**
+ * Get a single dashboard by id with its placements + permission level.
+ *
+ * Powers the front-end's `switchDashboard` flow: clicking a row in the
+ * sidebar issues `GET /api/dashboard/{id}` and the response is the
+ * same envelope shape as {@see self::getActive()}, so the store can
+ * write `activeDashboard`, `widgetPlacements`, and `permissionLevel`
+ * with no per-source branching.
+ *
+ * Returns 404 (not 403) when the dashboard exists but is not visible
+ * to the caller — this matches the `getVisibleToUser` policy and
+ * intentionally does not leak existence (REQ-DASH-020 scenario
+ * "Cannot see what you cannot read").
+ *
+ * @param int $id The dashboard ID.
+ *
+ * @return JSONResponse The dashboard envelope (200) or
+ * `{'error': 'Not found'}` (404).
+ *
+ * @spec openspec/changes/retrofit-2026-05-24-annotate-launchpad/tasks.md#task-21
+ */
+ #[NoAdminRequired]
+ public function show(int $id): JSONResponse {
+ $user = $this->userSession->getUser();
+ if ($user === null) {
+ return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED);
+ }
+
+ $this->actionAuth->requireAction($user, 'dashboard.show');
+
+ if ($this->userId === null) {
+ return ResponseHelper::unauthorized();
+ }
+
+ $result = $this->dashboardService->getDashboardForUser(
+ dashboardId: $id,
+ userId: $this->userId
+ );
+
+ if ($result === null) {
+ return ResponseHelper::success(
+ data: ['error' => 'Not found'],
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ }
+
+ $dashboard = $result['dashboard'];
+ $isOwner = ($dashboard->getUserId() === $this->userId);
+ $sharedBy = null;
+ if ($isOwner === false) {
+ $sharedBy = $dashboard->getUserId();
+ }
+
+ return ResponseHelper::success(
+ data: [
+ 'dashboard' => $dashboard->jsonSerialize(),
+ 'placements' => ResponseHelper::serializeList(
+ entities: $result['placements']
+ ),
+ 'permissionLevel' => $result['permissionLevel'],
+ 'isOwner' => $isOwner,
+ 'sharedBy' => $sharedBy,
+ ]
+ );
+ }//end show()
+
+ /**
+ * Create a new dashboard.
+ *
+ * @param mixed $name The dashboard name.
+ * @param string|null $description The description.
+ * @param string|null $icon The icon registry key (or NULL/empty to use the default).
+ * @param string|null $parentUuid Optional parent dashboard UUID
+ * (REQ-DASH-023). NULL ⇒ root.
+ * @param string|null $slug Optional caller-supplied slug
+ * (REQ-DASH-024). NULL ⇒ derive from
+ * the name.
+ * @param int|null $sortOrder Optional sibling sort order
+ * (REQ-DASH-029). NULL ⇒ 0.
+ *
+ * @return JSONResponse The created dashboard.
+ *
+ * @spec openspec/changes/retrofit-2026-05-24-annotate-launchpad/tasks.md#task-16
+ */
+ #[NoAdminRequired]
+ public function create(
+ $name = null,
+ ?string $description = null,
+ ?string $icon = null,
+ ?string $parentUuid = null,
+ ?string $slug = null,
+ ?int $sortOrder = null,
+ ): JSONResponse {
+ $denial = $this->denyCreate();
+ if ($denial !== null) {
+ return $denial;
+ }
+
+ $resolved = $this->resolveCreateParams(
+ name: $name,
+ description: $description,
+ icon: $icon,
+ parentUuid: $parentUuid,
+ slug: $slug,
+ sortOrder: $sortOrder
+ );
+
+ $permError = $this->checkCreatePermissions(
+ userId: $this->userId
+ );
+ if ($permError !== null) {
+ return $permError;
+ }
+
+ try {
+ $dashboard = $this->dashboardService->createDashboard(
+ userId: $this->userId,
+ name: $resolved['name'],
+ description: $resolved['description'],
+ icon: $resolved['icon'],
+ parentUuid: $resolved['parentUuid'],
+ slug: $resolved['slug'],
+ sortOrder: $resolved['sortOrder'],
+ seedDefaults: true
+ );
+
+ // The newly-created dashboard ships with a default widget
+ // bundle (Conduction + Sendent + Nextcloud tiles + a Files
+ // widget) seeded by the service. Returning the placements
+ // here matches the `getActive()` envelope so the store can
+ // populate `widgetPlacements` without an extra round-trip.
+ $placements = $this->dashboardService->findPlacements(
+ dashboardId: $dashboard->getId()
+ );
+
+ return ResponseHelper::success(
+ data: [
+ 'dashboard' => $dashboard->jsonSerialize(),
+ 'placements' => ResponseHelper::serializeList(
+ entities: $placements
+ ),
+ ],
+ statusCode: Http::STATUS_CREATED
+ );
+ } catch (QuotaExceededException $e) {
+ // Dashboard-quota-limits REQ-QUOTA-002: the user is at their
+ // dashboard limit — HTTP 409 with the structured body.
+ return ResponseHelper::quotaExceeded(exception: $e);
+ } catch (InvalidArgumentException $e) {
+ // REQ-DASH-023..029: parent / slug / depth / cycle violations
+ // surface as HTTP 400 with the validation message verbatim.
+ return new JSONResponse(
+ data: [
+ 'status' => 'error',
+ 'error' => 'invalid_argument',
+ 'message' => $e->getMessage(),
+ ],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ } catch (\Exception $e) {
+ return ResponseHelper::error(exception: $e);
+ }//end try
+ }//end create()
+
+ /**
+ * Resolve the authentication / authorisation guard chain for
+ * {@see self::create()}.
+ *
+ * Order is load-bearing. REQ-ASET-003 (extended): the admin gating
+ * runs BEFORE any request-body handling so the response envelope is
+ * the stable `personal_dashboards_disabled` shape no matter what the
+ * body looked like.
+ *
+ * @return JSONResponse|NULL The refusal, or NULL to proceed.
+ *
+ * @spec openspec/changes/retrofit-2026-05-24-annotate-launchpad/tasks.md#task-16
+ */
+ private function denyCreate(): ?JSONResponse {
+ $user = $this->userSession->getUser();
+ if ($user === null) {
+ return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED);
+ }
+
+ // L3: wire the create action so the matrix entry is enforced —
+ // consistent with all other mutation endpoints (ADR-023).
+ $this->actionAuth->requireAction($user, 'dashboard.create');
+
+ if ($this->userId === null) {
+ return ResponseHelper::unauthorized();
+ }
+
+ try {
+ $this->dashboardService->assertPersonalDashboardsAllowed();
+ } catch (PersonalDashboardsDisabledException $e) {
+ return new JSONResponse(
+ data: [
+ 'status' => 'error',
+ 'error' => $e->getErrorCode(),
+ 'message' => $e->getMessage(),
+ ],
+ statusCode: Http::STATUS_FORBIDDEN
+ );
+ }
+
+ return null;
+ }//end denyCreate()
+
+ /**
+ * Update a dashboard.
+ *
+ * @param int $id The dashboard ID.
+ * @param string|null $name The name.
+ * @param string|null $description The description.
+ * @param array|null $placements The placements.
+ * @param string|null $icon The icon registry key, URL, or NULL to leave unchanged.
+ * @param string|null $parentUuid Optional new parent UUID (REQ-DASH-023);
+ * explicit empty string clears the
+ * parent (re-roots the dashboard).
+ * @param string|null $slug Optional new slug (REQ-DASH-024).
+ * @param int|null $sortOrder Optional new sort order (REQ-DASH-029).
+ *
+ * @return JSONResponse The updated dashboard.
+ *
+ * @spec openspec/changes/retrofit-2026-05-24-annotate-launchpad/tasks.md#task-19
+ */
+ #[NoAdminRequired]
+ public function update(
+ int $id,
+ ?string $name = null,
+ ?string $description = null,
+ ?array $placements = null,
+ ?string $icon = null,
+ ?string $parentUuid = null,
+ ?string $slug = null,
+ ?int $sortOrder = null,
+ ): JSONResponse {
+ $user = $this->userSession->getUser();
+ if ($user === null) {
+ return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED);
+ }
+
+ $this->actionAuth->requireAction($user, 'dashboard.update');
+
+ if ($this->userId === null) {
+ return ResponseHelper::unauthorized();
+ }
+
+ // REQ-PERM-007: Metadata-only updates (name, description, icon) are
+ // allowed for all permission levels. Widget/tile/layout changes
+ // require add_only or full permission.
+ $isMetadataOnly = $placements === null;
+ if ($isMetadataOnly === true
+ && $this->permissionService->canEditDashboardMetadata(
+ userId: $this->userId,
+ dashboardId: $id
+ ) === false
+ ) {
+ return ResponseHelper::forbidden();
+ }
+
+ if ($isMetadataOnly === false
+ && $this->permissionService->canEditDashboard(
+ userId: $this->userId,
+ dashboardId: $id
+ ) === false
+ ) {
+ return ResponseHelper::forbidden();
+ }
+
+ try {
+ $data = $this->buildUpdateData(
+ name: $name,
+ description: $description,
+ placements: $placements,
+ icon: $icon,
+ parentUuid: $parentUuid,
+ slug: $slug,
+ sortOrder: $sortOrder
+ );
+
+ $dashboard = $this->dashboardService->updateDashboard(
+ dashboardId: $id,
+ userId: $this->userId,
+ data: $data
+ );
+
+ // REQ-VERS-001: capture an automatic snapshot after the
+ // PUT succeeds. The version service enforces its own
+ // debounce window (60 s) so rapid drag-and-drop edits do
+ // not flood the table. Failures are swallowed so they do
+ // not surface to the dashboard PUT response.
+ $this->captureAutomaticSnapshot(dashboard: $dashboard);
+
+ return ResponseHelper::success(
+ data: ['dashboard' => $dashboard->jsonSerialize()]
+ );
+ } catch (InvalidArgumentException $e) {
+ // REQ-DASH-023..029: parent / slug / depth / cycle violations
+ // surface as HTTP 400 with the validation message verbatim.
+ return new JSONResponse(
+ data: [
+ 'status' => 'error',
+ 'error' => 'invalid_argument',
+ 'message' => $e->getMessage(),
+ ],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ } catch (\Exception $e) {
+ return ResponseHelper::error(exception: $e);
+ }//end try
+ }//end update()
+
+ /**
+ * Delete a dashboard.
+ *
+ * Honours the cascade-delete guard from REQ-DASH-030: when the
+ * dashboard has children the request MUST include `?cascade=true`
+ * (case-insensitive) — otherwise the response is HTTP 409 with the
+ * child count so the UI can surface a confirmation.
+ *
+ * @param int $id The dashboard ID.
+ *
+ * @return JSONResponse The deletion confirmation.
+ *
+ * @spec openspec/changes/retrofit-2026-05-24-annotate-launchpad/tasks.md#task-20
+ */
+ #[NoAdminRequired]
+ public function delete(int $id): JSONResponse {
+ $user = $this->userSession->getUser();
+ if ($user === null) {
+ return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED);
+ }
+
+ $this->actionAuth->requireAction($user, 'dashboard.delete');
+
+ if ($this->userId === null) {
+ return ResponseHelper::unauthorized();
+ }
+
+ $cascade = $this->resolveCascadeFlag();
+
+ try {
+ $this->dashboardService->deleteDashboard(
+ dashboardId: $id,
+ userId: $this->userId,
+ cascade: $cascade
+ );
+
+ return ResponseHelper::success(data: ['status' => 'ok']);
+ } catch (DashboardHasChildrenException $e) {
+ // REQ-DASH-030: stable 409 envelope with the child count so
+ // the frontend can render "Delete N children?" before
+ // retrying with cascade=true.
+ return new JSONResponse(
+ data: [
+ 'status' => 'error',
+ 'error' => DashboardHasChildrenException::ERROR_CODE,
+ 'message' => $e->getMessage(),
+ 'childCount' => $e->getChildCount(),
+ ],
+ statusCode: Http::STATUS_CONFLICT
+ );
+ } catch (\Exception $e) {
+ return ResponseHelper::error(exception: $e);
+ }//end try
+ }//end delete()
+
+ /**
+ * GET /api/dashboards/tree — return the nested dashboard tree scoped
+ * to the calling user's visible dashboards (REQ-DASH-026).
+ *
+ * Each node carries `{uuid, name, slug, sortOrder, children: [...]}`.
+ * Only nodes for dashboards that `DashboardService::getVisibleToUser`
+ * resolves for the caller are included — personal drafts owned by
+ * other users are not enumerable (C1 fix: REQ-DASH-026 + REQ-PERM-001).
+ *
+ * @return JSONResponse The nested tree.
+ *
+ * @spec openspec/specs/dashboards/spec.md
+ */
+ #[NoAdminRequired]
+ public function tree(): JSONResponse {
+ $user = $this->userSession->getUser();
+ if ($user === null) {
+ return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED);
+ }
+
+ $this->actionAuth->requireAction($user, 'dashboard.tree');
+
+ if ($this->userId === null) {
+ return ResponseHelper::unauthorized();
+ }
+
+ // C1 fix: build the visibility set for the calling user, then ask
+ // the tree service for the structural tree filtered to those UUIDs.
+ // This prevents cross-user IDOR via UUID enumeration through the tree.
+ $visible = $this->dashboardService->getVisibleToUser(
+ userId: $this->userId
+ );
+ $visibleUuids = [];
+ foreach ($visible as $entry) {
+ $uuid = $entry['dashboard']->getUuid();
+ if ($uuid !== null && $uuid !== '') {
+ $visibleUuids[$uuid] = true;
+ }
+ }
+
+ $tree = $this->treeService->getFilteredTree(
+ visibleUuids: $visibleUuids
+ );
+
+ return ResponseHelper::success(data: $tree);
+ }//end tree()
+
+ /**
+ * GET /api/dashboards/by-path/{path} — resolve a slug-chain path
+ * (REQ-DASH-027).
+ *
+ * Returns the matching dashboard with its computed `path` and
+ * `breadcrumbs` (REQ-DASH-025) attached. Responds with 404 (not 403)
+ * on any miss — including visibility misses — to avoid confirming that
+ * a given slug exists to an unauthorised caller.
+ *
+ * C2 fix (REQ-DASH-027 + REQ-PERM-001): after slug resolution the
+ * resolved dashboard is checked via PermissionService; callers with no
+ * view access receive the same 404 they would get for an unknown slug.
+ *
+ * @param string $path The slug-joined path captured from the URL
+ * (the `{path}` placeholder is regex-allowed
+ * to include slashes — see `appinfo/routes.php`).
+ *
+ * @return JSONResponse The dashboard payload, or a 404 envelope.
+ *
+ * @spec openspec/specs/dashboards/spec.md
+ */
+ #[NoAdminRequired]
+ public function byPath(string $path = ''): JSONResponse {
+ $user = $this->userSession->getUser();
+ if ($user === null) {
+ return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED);
+ }
+
+ $this->actionAuth->requireAction($user, 'dashboard.by-path');
+
+ if ($this->userId === null) {
+ return ResponseHelper::unauthorized();
+ }
+
+ if ($path === '') {
+ $path = (string)$this->request->getParam(key: 'path', default: '');
+ }
+
+ $dashboard = $this->treeService->resolvePath(path: $path);
+ if ($dashboard === null) {
+ return new JSONResponse(
+ data: [
+ 'status' => 'error',
+ 'error' => 'not_found',
+ 'message' => 'Dashboard not found at path',
+ ],
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ }
+
+ // C2 fix: verify the caller can see this dashboard. Return 404
+ // (not 403) to avoid leaking that the slug exists at all.
+ $dashboardId = (int)$dashboard->getId();
+ if ($this->permissionService->canViewDashboard(
+ userId: $this->userId,
+ dashboardId: $dashboardId
+ ) === false
+ ) {
+ return new JSONResponse(
+ data: [
+ 'status' => 'error',
+ 'error' => 'not_found',
+ 'message' => 'Dashboard not found at path',
+ ],
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ }
+
+ $uuid = (string)$dashboard->getUuid();
+ $serialised = $dashboard->jsonSerialize();
+ $serialised['path'] = $this->treeService->computePath(uuid: $uuid);
+ $serialised['breadcrumbs'] = $this->treeService->computeBreadcrumbs(
+ uuid: $uuid
+ );
+
+ return ResponseHelper::success(
+ data: ['dashboard' => $serialised]
+ );
+ }//end byPath()
+
+ /**
+ * GET /api/dashboards/{uuid}/path — return a dashboard's canonical
+ * slug-chain path.
+ *
+ * Used by the frontend after every sidebar switch to keep the
+ * browser URL in sync with the active dashboard. The path is the
+ * leading-slash slug-chain returned by
+ * {@see DashboardTreeService::computePath()}; an empty string means
+ * the UUID does not resolve OR the dashboard has no slug (legal —
+ * NULL slugs are simply unaddressable by path), and the frontend
+ * treats either case as "leave the URL alone".
+ *
+ * @param string $uuid Dashboard UUID captured from the URL.
+ *
+ * @return JSONResponse `{path: string}` envelope (always 200 when
+ * authorised — the empty-path case is a valid
+ * response shape the caller distinguishes
+ * client-side).
+ *
+ * @spec openspec/specs/dashboards/spec.md
+ */
+ #[NoAdminRequired]
+ public function computePath(string $uuid = ''): JSONResponse {
+ $user = $this->userSession->getUser();
+ if ($user === null) {
+ return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED);
+ }
+
+ $this->actionAuth->requireAction($user, 'dashboard.compute-path');
+
+ if ($this->userId === null) {
+ return ResponseHelper::unauthorized();
+ }
+
+ if ($uuid === '') {
+ return new JSONResponse(
+ data: [
+ 'status' => 'error',
+ 'error' => 'missing_uuid',
+ 'message' => 'UUID is required',
+ ],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ }
+
+ return ResponseHelper::success(
+ data: ['path' => $this->treeService->computePath(uuid: $uuid)]
+ );
+ }//end computePath()
+
+ /**
+ * Activate a dashboard.
+ *
+ * @param int $id The dashboard ID.
+ *
+ * @return JSONResponse The activated dashboard.
+ *
+ * @spec openspec/specs/dashboards/spec.md
+ */
+ #[NoAdminRequired]
+ public function activate(int $id): JSONResponse {
+ $user = $this->userSession->getUser();
+ if ($user === null) {
+ return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED);
+ }
+
+ $this->actionAuth->requireAction($user, 'dashboard.activate');
+
+ if ($this->userId === null) {
+ return ResponseHelper::unauthorized();
+ }
+
+ try {
+ $dashboard = $this->dashboardService->activateDashboard(
+ dashboardId: $id,
+ userId: $this->userId
+ );
+
+ return ResponseHelper::success(
+ data: ['dashboard' => $dashboard->jsonSerialize()]
+ );
+ } catch (\Exception $e) {
+ return ResponseHelper::error(exception: $e);
+ }
+ }//end activate()
+
+ /**
+ * List the group-shared dashboards in a single group.
+ *
+ * Any logged-in user may list. REQ-DASH-014.
+ *
+ * @param string $groupId The group ID.
+ *
+ * @return JSONResponse The list of group-shared dashboards.
+ *
+ * @spec openspec/specs/dashboards/spec.md
+ */
+ #[NoAdminRequired]
+ public function listGroup(string $groupId): JSONResponse {
+ $user = $this->userSession->getUser();
+ if ($user === null) {
+ return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED);
+ }
+
+ $this->actionAuth->requireAction($user, 'dashboard.list-group');
+
+ if ($this->userId === null) {
+ return ResponseHelper::unauthorized();
+ }
+
+ // H1: verify the caller is a member of the requested group (or
+ // admin) before returning its dashboards — mirrors the group-
+ // membership check in PermissionService::resolveAccessLevel.
+ if ($this->dashboardService->userCanAccessGroup(
+ userId: $this->userId,
+ groupId: $groupId
+ ) === false
+ ) {
+ return ResponseHelper::forbidden();
+ }
+
+ $dashboards = $this->dashboardService->listGroupDashboards(
+ groupId: $groupId
+ );
+
+ // M5: strip internal identity fields (userId, groupId, targetGroups)
+ // from group-shared dashboard payloads returned to non-owner viewers.
+ $viewerData = array_map(
+ static fn ($dashboard) => $dashboard->toViewerArray(),
+ $dashboards
+ );
+
+ return ResponseHelper::success(data: $viewerData);
+ }//end listGroup()
+
+ /**
+ * Create a new group-shared dashboard.
+ *
+ * Admin-only — enforced by the `#[AuthorizedAdminSetting]` attribute
+ * (gate-route-auth / gate-semantic-auth both pass since the
+ * framework-level check is the actual authorization point).
+ * REQ-DASH-014.
+ *
+ * @param string $groupId The group ID.
+ * @param mixed $name The dashboard name (or {name,...}
+ * dict as the body).
+ * @param string|null $description The dashboard description.
+ *
+ * @return JSONResponse The created dashboard.
+ *
+ * @spec openspec/specs/dashboards/spec.md
+ */
+ #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
+ public function createGroup(
+ string $groupId,
+ $name = null,
+ ?string $description = null,
+ ): JSONResponse {
+ if ($this->userId === null) {
+ return ResponseHelper::unauthorized();
+ }
+
+ $resolved = $this->resolveCreateParams(
+ name: $name,
+ description: $description
+ );
+
+ try {
+ $dashboard = $this->dashboardService->createGroupShared(
+ actorUserId: $this->userId,
+ groupId: $groupId,
+ name: $resolved['name'],
+ description: $resolved['description']
+ );
+
+ return ResponseHelper::success(
+ data: ['dashboard' => $dashboard->jsonSerialize()],
+ statusCode: Http::STATUS_CREATED
+ );
+ } catch (\Exception $e) {
+ return ResponseHelper::error(exception: $e);
+ }
+ }//end createGroup()
+
+ /**
+ * Get a single group-shared dashboard with placements.
+ *
+ * @param string $groupId The group ID from the URL.
+ * @param string $uuid The dashboard UUID from the URL.
+ *
+ * @return JSONResponse The dashboard payload.
+ *
+ * @spec openspec/specs/dashboards/spec.md
+ */
+ #[NoAdminRequired]
+ public function getGroup(
+ string $groupId,
+ string $uuid,
+ ): JSONResponse {
+ $user = $this->userSession->getUser();
+ if ($user === null) {
+ return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED);
+ }
+
+ $this->actionAuth->requireAction($user, 'dashboard.get-group');
+
+ if ($this->userId === null) {
+ return ResponseHelper::unauthorized();
+ }
+
+ // H1: verify the caller is a member of the requested group (or
+ // admin) before fetching the dashboard payload.
+ if ($this->dashboardService->userCanAccessGroup(
+ userId: $this->userId,
+ groupId: $groupId
+ ) === false
+ ) {
+ return ResponseHelper::forbidden();
+ }
+
+ try {
+ $dashboard = $this->dashboardService->findGroupDashboard(
+ groupId: $groupId,
+ uuid: $uuid
+ );
+ } catch (DoesNotExistException) {
+ // ADR-005: do not leak raw exception messages to clients.
+ return new JSONResponse(
+ data: ['error' => 'Dashboard not found'],
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ }
+
+ // M5: strip internal identity fields from viewer-facing payload.
+ return ResponseHelper::success(
+ data: ['dashboard' => $dashboard->toViewerArray()]
+ );
+ }//end getGroup()
+
+ /**
+ * Update a group-shared dashboard. Admin-only.
+ *
+ * @param string $groupId The group ID from the URL.
+ * @param string $uuid The dashboard UUID from the URL.
+ * @param string|null $name The new name.
+ * @param string|null $description The new description.
+ * @param int|null $gridColumns The new grid column count.
+ * @param array|null $placements Updated placements.
+ *
+ * @return JSONResponse The updated dashboard.
+ *
+ * @spec openspec/specs/dashboards/spec.md
+ */
+ #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
+ public function updateGroup(
+ string $groupId,
+ string $uuid,
+ ?string $name = null,
+ ?string $description = null,
+ ?int $gridColumns = null,
+ ?array $placements = null,
+ ): JSONResponse {
+ if ($this->userId === null) {
+ return ResponseHelper::unauthorized();
+ }
+
+ $patch = $this->buildGroupUpdateData(
+ name: $name,
+ description: $description,
+ gridColumns: $gridColumns,
+ placements: $placements
+ );
+
+ try {
+ $dashboard = $this->dashboardService->updateGroupShared(
+ actorUserId: $this->userId,
+ groupId: $groupId,
+ uuid: $uuid,
+ patch: $patch
+ );
+
+ return ResponseHelper::success(
+ data: ['dashboard' => $dashboard->jsonSerialize()]
+ );
+ } catch (DoesNotExistException) {
+ // ADR-005: do not leak raw exception messages to clients.
+ return new JSONResponse(
+ data: ['error' => 'Dashboard not found'],
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ } catch (\Exception $e) {
+ return ResponseHelper::error(exception: $e);
+ }//end try
+ }//end updateGroup()
+
+ /**
+ * Delete a group-shared dashboard. Admin-only.
+ *
+ * Returns HTTP 400 when the last-in-group guard rejects the delete
+ * (REQ-DASH-014).
+ *
+ * @param string $groupId The group ID from the URL.
+ * @param string $uuid The dashboard UUID from the URL.
+ *
+ * @return JSONResponse The status payload.
+ *
+ * @spec openspec/specs/dashboards/spec.md
+ */
+ #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
+ public function deleteGroup(
+ string $groupId,
+ string $uuid,
+ ): JSONResponse {
+ if ($this->userId === null) {
+ return ResponseHelper::unauthorized();
+ }
+
+ try {
+ $this->dashboardService->deleteGroupShared(
+ actorUserId: $this->userId,
+ groupId: $groupId,
+ uuid: $uuid
+ );
+
+ return ResponseHelper::success(data: ['status' => 'ok']);
+ } catch (DoesNotExistException) {
+ // ADR-005: do not leak raw exception messages to clients.
+ return new JSONResponse(
+ data: ['error' => 'Dashboard not found'],
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ } catch (\Exception $e) {
+ return ResponseHelper::error(exception: $e);
+ }//end try
+ }//end deleteGroup()
+
+ /**
+ * Promote a single group-shared dashboard to the group's default.
+ *
+ * Admin-only — enforced by the `#[AuthorizedAdminSetting]` attribute.
+ * The body payload is `{"uuid": "..."}`. Returns 404 when the uuid
+ * does not belong to the given groupId. REQ-DASH-015.
+ *
+ * @param string $groupId The group ID from the URL.
+ * @param string|null $uuid The dashboard UUID from the body.
+ *
+ * @return JSONResponse The status payload.
+ *
+ * @spec openspec/specs/dashboards/spec.md
+ */
+ #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
+ public function setGroupDefault(
+ string $groupId,
+ ?string $uuid = null,
+ ): JSONResponse {
+ if ($this->userId === null) {
+ return ResponseHelper::unauthorized();
+ }
+
+ if ($uuid === null || $uuid === '') {
+ return ResponseHelper::error(
+ exception: new InvalidArgumentException(
+ 'Missing required field: uuid'
+ ),
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ }
+
+ try {
+ $this->dashboardService->setGroupDefault(
+ actorUserId: $this->userId,
+ groupId: $groupId,
+ uuid: $uuid
+ );
+
+ return ResponseHelper::success(
+ data: [
+ 'status' => 'ok',
+ 'groupId' => $groupId,
+ 'uuid' => $uuid,
+ ]
+ );
+ } catch (DoesNotExistException) {
+ // ADR-005: do not leak raw exception messages to clients.
+ return new JSONResponse(
+ data: ['error' => 'Dashboard not found'],
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ } catch (\Exception $e) {
+ return ResponseHelper::error(exception: $e);
+ }//end try
+ }//end setGroupDefault()
+
+ /**
+ * Persist the user's active-dashboard preference.
+ *
+ * Accepts any UUID string (including non-existent UUIDs — the resolver's
+ * stale-pref path handles invalid values on next render). Empty string
+ * clears the preference. REQ-DASH-019.
+ *
+ * @param string|null $uuid The dashboard UUID from the request body, or
+ * empty string to clear.
+ *
+ * @return JSONResponse HTTP 200 `{status: 'success'}` on success; 401
+ * when the session has no user.
+ *
+ * @spec openspec/specs/dashboards/spec.md
+ */
+ #[NoAdminRequired]
+ public function setActiveDashboard(?string $uuid = null): JSONResponse {
+ $user = $this->userSession->getUser();
+ if ($user === null) {
+ return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED);
+ }
+
+ $this->actionAuth->requireAction($user, 'dashboard.set-active-dashboard');
+
+ if ($this->userId === null) {
+ return ResponseHelper::unauthorized();
+ }
+
+ $this->dashboardService->setActivePreference(
+ userId: $this->userId,
+ uuid: ($uuid ?? '')
+ );
+
+ return ResponseHelper::success(data: ['status' => 'success']);
+ }//end setActiveDashboard()
+
+ /**
+ * Pin (or clear) the user's EXPLICIT default-dashboard choice
+ * (wave3.7).
+ *
+ * Distinct from {@see self::setActiveDashboard()} — this pref is
+ * only ever written when the user explicitly clicks "Set as
+ * default" on a row's cog menu, and is NOT auto-overwritten on
+ * every switch. The resolver checks it before the active pref so
+ * the pin survives across switches.
+ *
+ * Body shape: `{uuid: string}` — empty string clears the pin.
+ *
+ * @param string|null $uuid The dashboard UUID, or empty string to clear.
+ *
+ * @return JSONResponse 200 `{status: 'success'}` on success; 401
+ * when the session has no user.
+ *
+ * @spec openspec/specs/dashboards/spec.md
+ */
+ #[NoAdminRequired]
+ public function setDefaultDashboard(?string $uuid = null): JSONResponse {
+ $user = $this->userSession->getUser();
+ if ($user === null) {
+ return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED);
+ }
+
+ $this->actionAuth->requireAction($user, 'dashboard.set-default-dashboard');
+
+ if ($this->userId === null) {
+ return ResponseHelper::unauthorized();
+ }
+
+ $this->dashboardService->setDefaultPreference(
+ userId: $this->userId,
+ uuid: ($uuid ?? '')
+ );
+
+ return ResponseHelper::success(data: ['status' => 'success']);
+ }//end setDefaultDashboard()
+
+ /**
+ * Read the user's EXPLICIT default-dashboard pin (wave3.7).
+ *
+ * @return JSONResponse 200 `{uuid: string}` — empty string when no
+ * pin set; 401 when the session has no user.
+ *
+ * @spec openspec/specs/dashboards/spec.md
+ */
+ #[NoAdminRequired]
+ public function getDefaultDashboard(): JSONResponse {
+ $user = $this->userSession->getUser();
+ if ($user === null) {
+ return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED);
+ }
+
+ $this->actionAuth->requireAction($user, 'dashboard.get-default-dashboard');
+
+ if ($this->userId === null) {
+ return ResponseHelper::unauthorized();
+ }
+
+ return ResponseHelper::success(
+ data: [
+ 'uuid' => $this->dashboardService->getDefaultPreference(
+ userId: $this->userId
+ ),
+ ]
+ );
+ }//end getDefaultDashboard()
+
+ /**
+ * Fork any visible dashboard into a brand-new personal copy.
+ *
+ * REQ-DASH-020 / REQ-DASH-021 / REQ-DASH-022. Body shape:
+ * `{name?: string}` — when `name` is absent the system applies the
+ * default `t('My copy of {name}', source.name)` translated via the
+ * caller's active language.
+ *
+ * Status mapping:
+ * - HTTP 201 with the full new dashboard payload on success.
+ * - HTTP 401 when the session has no user.
+ * - HTTP 403 with stable error code `personal_dashboards_disabled`
+ * when the admin flag `allow_user_dashboards` is off — REQ-ASET-003
+ * runtime gating runs FIRST so the envelope shape is stable
+ * regardless of body contents.
+ * - HTTP 404 when the source UUID is not visible to the caller —
+ * do not leak existence (REQ-DASH-020 scenario "Cannot fork a
+ * dashboard you cannot read").
+ * - HTTP 500 when a partial-failure rollback fires — REQ-DASH-021.
+ * ADR-005: the response carries a stable error code and a generic
+ * user-facing message; the underlying exception is logged for ops.
+ *
+ * @param string $uuid The source dashboard UUID from the URL.
+ * @param string|null $name Optional explicit fork name from the body.
+ *
+ * @return JSONResponse The new dashboard payload (201) or an
+ * appropriate error envelope.
+ *
+ * @spec openspec/specs/dashboards/spec.md
+ */
+ #[NoAdminRequired]
+ public function fork(
+ string $uuid,
+ ?string $name = null,
+ ): JSONResponse {
+ if ($this->userId === null) {
+ return ResponseHelper::unauthorized();
+ }
+
+ try {
+ $fork = $this->dashboardService->forkAsPersonal(
+ userId: $this->userId,
+ sourceUuid: $uuid,
+ name: $name
+ );
+
+ return new JSONResponse(
+ data: [
+ 'status' => 'success',
+ 'dashboard' => $fork->jsonSerialize(),
+ ],
+ statusCode: Http::STATUS_CREATED
+ );
+ } catch (PersonalDashboardsDisabledException $e) {
+ return new JSONResponse(
+ data: [
+ 'status' => 'error',
+ 'error' => $e->getErrorCode(),
+ 'message' => $e->getMessage(),
+ ],
+ statusCode: Http::STATUS_FORBIDDEN
+ );
+ } catch (QuotaExceededException $e) {
+ // Dashboard-quota-limits REQ-QUOTA-002: a fork is bound by the
+ // per-user dashboard quota — HTTP 409 with the structured body.
+ return ResponseHelper::quotaExceeded(exception: $e);
+ } catch (DoesNotExistException) {
+ // REQ-DASH-020: source not visible — 404 without leaking
+ // existence (use the canonical message rather than echoing
+ // the exception detail).
+ return new JSONResponse(
+ data: [
+ 'status' => 'error',
+ 'error' => 'not_found',
+ ],
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ } catch (\Throwable $t) {
+ // REQ-DASH-021 + ADR-005: log the real cause, return a
+ // stable, generic envelope to the client.
+ $this->logger->error(
+ message: 'launchpad: fork failed for user {user}: {message}',
+ context: [
+ 'user' => $this->userId,
+ 'message' => $t->getMessage(),
+ ]
+ );
+ return new JSONResponse(
+ data: [
+ 'status' => 'error',
+ 'error' => 'internal_error',
+ 'message' => 'An unexpected error occurred',
+ ],
+ statusCode: Http::STATUS_INTERNAL_SERVER_ERROR
+ );
+ }//end try
+ }//end fork()
+
+ /**
+ * Publish a dashboard. REQ-DASH-032.
+ *
+ * Owner-or-admin gated at the service boundary; the route attribute
+ * is `#[NoAdminRequired]` because the in-body owner check is the
+ * actual authorization point (gate-semantic-auth).
+ *
+ * @param string $uuid The dashboard UUID from the URL.
+ *
+ * @return JSONResponse The updated dashboard payload.
+ *
+ * @spec openspec/specs/dashboards/spec.md
+ */
+ #[NoAdminRequired]
+ public function publish(string $uuid): JSONResponse {
+ $user = $this->userSession->getUser();
+ if ($user === null) {
+ return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED);
+ }
+
+ $this->actionAuth->requireAction($user, 'dashboard.publish');
+
+ if ($this->userId === null) {
+ return ResponseHelper::unauthorized();
+ }
+
+ try {
+ $dashboard = $this->dashboardService->publishDashboard(
+ uuid: $uuid,
+ userId: $this->userId
+ );
+
+ return ResponseHelper::success(
+ data: ['dashboard' => $dashboard->jsonSerialize()]
+ );
+ } catch (DoesNotExistException) {
+ return new JSONResponse(
+ data: [
+ 'status' => 'error',
+ 'error' => 'not_found',
+ ],
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ } catch (\Exception $e) {
+ return $this->mapPublicationError(exception: $e);
+ }//end try
+ }//end publish()
+
+ /**
+ * Unpublish a dashboard. REQ-DASH-033.
+ *
+ * @param string $uuid The dashboard UUID from the URL.
+ *
+ * @return JSONResponse The updated dashboard payload.
+ *
+ * @spec openspec/specs/dashboards/spec.md
+ */
+ #[NoAdminRequired]
+ public function unpublish(string $uuid): JSONResponse {
+ $user = $this->userSession->getUser();
+ if ($user === null) {
+ return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED);
+ }
+
+ $this->actionAuth->requireAction($user, 'dashboard.unpublish');
+
+ if ($this->userId === null) {
+ return ResponseHelper::unauthorized();
+ }
+
+ try {
+ $dashboard = $this->dashboardService->unpublish(
+ uuid: $uuid,
+ userId: $this->userId
+ );
+
+ return ResponseHelper::success(
+ data: ['dashboard' => $dashboard->jsonSerialize()]
+ );
+ } catch (DoesNotExistException) {
+ return new JSONResponse(
+ data: [
+ 'status' => 'error',
+ 'error' => 'not_found',
+ ],
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ } catch (\Exception $e) {
+ return $this->mapPublicationError(exception: $e);
+ }//end try
+ }//end unpublish()
+
+ /**
+ * Schedule a dashboard for automatic publication. REQ-DASH-034.
+ *
+ * Body: `{"publishAt": "2026-04-01T10:00:00Z"}`. Returns 400 with an
+ * i18n-friendly error message when `publishAt` is missing,
+ * unparseable, or in the past; 403 when the actor is neither owner
+ * nor admin.
+ *
+ * @param string $uuid The dashboard UUID from the URL.
+ * @param string|null $publishAt The future ISO-8601 timestamp from
+ * the request body.
+ *
+ * @return JSONResponse The updated dashboard payload.
+ *
+ * @spec openspec/specs/dashboards/spec.md
+ */
+ #[NoAdminRequired]
+ public function schedule(
+ string $uuid,
+ ?string $publishAt = null,
+ ): JSONResponse {
+ $user = $this->userSession->getUser();
+ if ($user === null) {
+ return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED);
+ }
+
+ $this->actionAuth->requireAction($user, 'dashboard.schedule');
+
+ if ($this->userId === null) {
+ return ResponseHelper::unauthorized();
+ }
+
+ if ($publishAt === null || $publishAt === '') {
+ return new JSONResponse(
+ data: [
+ 'status' => 'error',
+ 'error' => 'invalid_argument',
+ 'message' => DashboardService::ERR_SCHEDULE_PAST_DATE,
+ ],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ }
+
+ try {
+ $dashboard = $this->dashboardService->schedule(
+ uuid: $uuid,
+ publishAt: $publishAt,
+ userId: $this->userId
+ );
+
+ return ResponseHelper::success(
+ data: ['dashboard' => $dashboard->jsonSerialize()]
+ );
+ } catch (DoesNotExistException) {
+ return new JSONResponse(
+ data: [
+ 'status' => 'error',
+ 'error' => 'not_found',
+ ],
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ } catch (InvalidArgumentException $e) {
+ return new JSONResponse(
+ data: [
+ 'status' => 'error',
+ 'error' => 'invalid_argument',
+ 'message' => $e->getMessage(),
+ ],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ } catch (\Exception $e) {
+ return $this->mapPublicationError(exception: $e);
+ }//end try
+ }//end schedule()
+
+ /**
+ * Record a dashboard view event (REQ-ANLT-002).
+ *
+ * Authenticated users only — POST `{}` body. Returns HTTP 204
+ * after the daily counter has been incremented. Short-circuits
+ * silently to 204 when the user has opted out (REQ-ANLT-004) or
+ * when global analytics is disabled (REQ-ANLT-005). Returns 404
+ * when the dashboard does not exist.
+ *
+ * @param string $uuid The dashboard UUID from the URL.
+ *
+ * @return JSONResponse An empty 204 response on success, 401
+ * when unauthenticated, 404 when the
+ * dashboard does not exist.
+ *
+ * @spec openspec/specs/dashboards/spec.md
+ */
+ #[NoAdminRequired]
+ public function viewEvent(string $uuid): JSONResponse {
+ $user = $this->userSession->getUser();
+ if ($user === null) {
+ return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED);
+ }
+
+ $this->actionAuth->requireAction($user, 'dashboard.view-event');
+
+ if ($this->userId === null) {
+ return ResponseHelper::unauthorized();
+ }
+
+ // H4: resolve the dashboard and assert the caller can view it
+ // before recording any counter increment (REQ-ANLT-002).
+ try {
+ $dashboard = $this->dashboardService->findByUuid(uuid: $uuid);
+ } catch (DoesNotExistException) {
+ return new JSONResponse(
+ data: [
+ 'status' => 'error',
+ 'error' => 'not_found',
+ ],
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ }
+
+ if ($this->permissionService->canViewDashboard(
+ userId: $this->userId,
+ dashboardId: $dashboard->getId()
+ ) === false
+ ) {
+ return ResponseHelper::forbidden();
+ }
+
+ try {
+ $this->analyticsService->recordViewEvent(
+ dashboardUuid: $uuid,
+ userId: $this->userId
+ );
+ } catch (DoesNotExistException) {
+ return new JSONResponse(
+ data: [
+ 'status' => 'error',
+ 'error' => 'not_found',
+ ],
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ }
+
+ return new JSONResponse(
+ data: [],
+ statusCode: Http::STATUS_NO_CONTENT
+ );
+ }//end viewEvent()
+
+ /**
+ * Map publication-related service exceptions onto the right HTTP
+ * status. REQ-DASH-032..034.
+ *
+ * The service raises `Exception` with the sentinel message
+ * {@see DashboardService::ERR_FORBIDDEN_NOT_OWNER_OR_ADMIN} when
+ * the actor is not allowed; everything else falls through to the
+ * generic ResponseHelper::error path.
+ *
+ * @param \Exception $exception The thrown exception.
+ *
+ * @return JSONResponse The mapped error envelope.
+ */
+ private function mapPublicationError(\Exception $exception): JSONResponse {
+ if ($exception->getMessage() === DashboardService::ERR_FORBIDDEN_NOT_OWNER_OR_ADMIN
+ ) {
+ return ResponseHelper::forbidden(
+ message: DashboardService::ERR_FORBIDDEN_NOT_OWNER_OR_ADMIN
+ );
+ }
+
+ return ResponseHelper::error(exception: $exception);
+ }//end mapPublicationError()
+
+ /**
+ * Resolve create parameters from JSON body or individual params.
+ *
+ * Forwards the new hierarchy fields (`parentUuid`, `slug`,
+ * `sortOrder`) introduced by REQ-DASH-023..029. When the caller
+ * sends a JSON body the helper inspects the array form first; when
+ * positional / typed params come via the framework binding it
+ * falls back to those.
+ *
+ * @param mixed $name The name parameter.
+ * @param string|null $description The description parameter.
+ * @param string|null $icon The icon parameter.
+ * @param string|null $parentUuid Optional parent UUID.
+ * @param string|null $slug Optional caller-supplied slug.
+ * @param int|null $sortOrder Optional sort order.
+ *
+ * @return array{name: string, description: ?string, icon: ?string, parentUuid: ?string, slug: ?string, sortOrder: int}
+ * The resolved values.
+ */
+ private function resolveCreateParams(
+ $name,
+ ?string $description,
+ ?string $icon = null,
+ ?string $parentUuid = null,
+ ?string $slug = null,
+ ?int $sortOrder = null,
+ ): array {
+ if (is_array($name) === true) {
+ $bodyIcon = ($name['icon'] ?? null);
+ $resolvedIcon = null;
+ if (is_string($bodyIcon) === true) {
+ $resolvedIcon = $bodyIcon;
+ }
+
+ $bodyParent = ($name['parentUuid'] ?? null);
+ $bodySlug = ($name['slug'] ?? null);
+ $bodySort = ($name['sortOrder'] ?? null);
+ $resolvedParent = null;
+ if (is_string($bodyParent) === true) {
+ $resolvedParent = $bodyParent;
+ }
+
+ $resolvedSlug = null;
+ if (is_string($bodySlug) === true) {
+ $resolvedSlug = $bodySlug;
+ }
+
+ $resolvedSort = 0;
+ if (is_numeric($bodySort) === true) {
+ $resolvedSort = (int)$bodySort;
+ }
+
+ return [
+ 'name' => $name['name'] ?? 'My Dashboard',
+ 'description' => $name['description'] ?? null,
+ 'icon' => $resolvedIcon,
+ 'parentUuid' => $resolvedParent,
+ 'slug' => $resolvedSlug,
+ 'sortOrder' => $resolvedSort,
+ ];
+ }//end if
+
+ return [
+ 'name' => $name ?? 'My Dashboard',
+ 'description' => $description,
+ 'icon' => $icon,
+ 'parentUuid' => $parentUuid,
+ 'slug' => $slug,
+ 'sortOrder' => ($sortOrder ?? 0),
+ ];
+ }//end resolveCreateParams()
+
+ /**
+ * Read the case-insensitive `cascade` query param (REQ-DASH-030).
+ *
+ * `?cascade=true|TRUE|True|1|yes|on|cascade` → true; anything else
+ * (including the param being absent) → false.
+ *
+ * @return bool Whether cascade-delete was explicitly requested.
+ */
+ private function resolveCascadeFlag(): bool {
+ $raw = $this->request->getParam(key: 'cascade');
+ if ($raw === null) {
+ return false;
+ }
+
+ $lower = strtolower((string)$raw);
+ return in_array(
+ $lower,
+ ['true', '1', 'yes', 'on', 'cascade'],
+ true
+ );
+ }//end resolveCascadeFlag()
+
+ /**
+ * Check creation permissions and return error if denied.
+ *
+ * @param string $userId The user ID.
+ *
+ * @return JSONResponse|null Error response or null if allowed.
+ */
+ private function checkCreatePermissions(string $userId): ?JSONResponse {
+ if ($this->permissionService->canCreateDashboard(
+ userId: $userId
+ ) === false
+ ) {
+ return ResponseHelper::forbidden(
+ message: 'Dashboard creation not allowed'
+ );
+ }
+
+ $existing = $this->dashboardService->getUserDashboards(
+ userId: $userId
+ );
+ if (empty($existing) === false
+ && $this->permissionService->canHaveMultipleDashboards() === false
+ ) {
+ return ResponseHelper::forbidden(
+ message: 'Multiple dashboards not allowed'
+ );
+ }
+
+ return null;
+ }//end checkCreatePermissions()
+
+ /**
+ * Build update data from nullable parameters.
+ *
+ * @param string|null $name The name.
+ * @param string|null $description The description.
+ * @param array|null $placements The placements.
+ * @param string|null $icon The icon registry key, URL, or NULL/empty.
+ * @param string|null $parentUuid Optional new parent UUID
+ * (REQ-DASH-023). The literal sentinel
+ * `__null__` clears the parent
+ * (re-roots the dashboard) — needed
+ * because the framework cannot
+ * distinguish "not in payload" from
+ * "explicit NULL" with typed-string
+ * binding.
+ * @param string|null $slug Optional new slug (REQ-DASH-024).
+ * @param int|null $sortOrder Optional new sort order
+ * (REQ-DASH-029).
+ *
+ * @return array The non-null update data.
+ */
+ private function buildUpdateData(
+ ?string $name,
+ ?string $description,
+ ?array $placements,
+ ?string $icon = null,
+ ?string $parentUuid = null,
+ ?string $slug = null,
+ ?int $sortOrder = null,
+ ): array {
+ $fields = [
+ 'name' => $name,
+ 'description' => $description,
+ 'placements' => $placements,
+ ];
+
+ $data = array_filter(
+ array: $fields,
+ callback: function ($value) {
+ return $value !== null;
+ }
+ );
+
+ // Icon explicitly supports NULL/empty (resets to the default
+ // glyph), so it must be merged separately from the array_filter
+ // above. Caller distinguishes "not in payload" via the default
+ // null sentinel.
+ if ($icon !== null) {
+ $data['icon'] = $icon;
+ }
+
+ // REQ-DASH-023: `parentUuid = '__null__'` is the agreed sentinel
+ // for "re-root this dashboard" because the framework's typed
+ // string binding cannot represent an explicit NULL. Anything
+ // else (non-null string) is forwarded verbatim — including the
+ // empty string, which the service treats as a NULL parent.
+ if ($parentUuid !== null) {
+ $data['parentUuid'] = $parentUuid;
+ if ($parentUuid === '__null__' || $parentUuid === '') {
+ $data['parentUuid'] = null;
+ }
+ }
+
+ if ($slug !== null) {
+ $data['slug'] = $slug;
+ }
+
+ if ($sortOrder !== null) {
+ $data['sortOrder'] = $sortOrder;
+ }
+
+ return $data;
+ }//end buildUpdateData()
+
+ /**
+ * Build the patch payload for the group-shared update endpoint.
+ *
+ * @param string|null $name The new name.
+ * @param string|null $description The new description.
+ * @param int|null $gridColumns The new grid columns.
+ * @param array|null $placements Updated placements.
+ *
+ * @return array The non-null patch fields.
+ */
+ private function buildGroupUpdateData(
+ ?string $name,
+ ?string $description,
+ ?int $gridColumns,
+ ?array $placements,
+ ): array {
+ $fields = [
+ 'name' => $name,
+ 'description' => $description,
+ 'gridColumns' => $gridColumns,
+ 'placements' => $placements,
+ ];
+
+ return array_filter(
+ array: $fields,
+ callback: function ($value) {
+ return $value !== null;
+ }
+ );
+ }//end buildGroupUpdateData()
+
+ /**
+ * Capture an automatic version snapshot after a successful update
+ * (REQ-VERS-001). The version service enforces a 60-second debounce
+ * window so a flurry of drag-and-drop saves does not flood the
+ * table.
+ *
+ * Failures here MUST NOT surface to the dashboard PUT response —
+ * the user's edit succeeded; missing one snapshot is a quality of
+ * life regression, not a data-integrity bug. We log + swallow.
+ *
+ * @param \OCA\LaunchPad\Db\Dashboard $dashboard The dashboard that was
+ * just updated.
+ *
+ * @return void
+ */
+ private function captureAutomaticSnapshot(
+ \OCA\LaunchPad\Db\Dashboard $dashboard,
+ ): void {
+ if ($this->userId === null) {
+ return;
+ }
+
+ try {
+ $this->versionService->captureSnapshot(
+ dashboard: $dashboard,
+ snapshotJson: null,
+ createdBy: $this->userId,
+ note: null,
+ explicit: false
+ );
+ } catch (\Throwable $t) {
+ $this->logger->warning(
+ message: 'launchpad: automatic version snapshot failed',
+ context: ['exception' => $t]
+ );
+ }
+ }//end captureAutomaticSnapshot()
}//end class
diff --git a/lib/Controller/DashboardLockApiController.php b/lib/Controller/DashboardLockApiController.php
index ec4629ab..0009d107 100644
--- a/lib/Controller/DashboardLockApiController.php
+++ b/lib/Controller/DashboardLockApiController.php
@@ -54,329 +54,323 @@
/**
* Controller for dashboard editing-lock endpoints (REQ-LOCK-001..008).
*/
-class DashboardLockApiController extends Controller
-{
- /**
- * Constructor
- *
- * @param IRequest $request The HTTP request.
- * @param DashboardLockService $lockService The lock service.
- * @param PermissionService $permissionService Dashboard permission resolver.
- * @param DashboardMapper $dashboardMapper UUID → id lookup.
- * @param ActionAuthService $actionAuth ADR-023 action authorization.
- * @param IUserSession $userSession User session (IUser resolution).
- * @param string|null $userId The calling user ID.
- */
- public function __construct(
- IRequest $request,
- private readonly DashboardLockService $lockService,
- private readonly PermissionService $permissionService,
- private readonly DashboardMapper $dashboardMapper,
- private readonly ActionAuthService $actionAuth,
- private readonly IUserSession $userSession,
- private readonly ?string $userId,
- ) {
- parent::__construct(
- appName: Application::APP_ID,
- request: $request
- );
- }//end __construct()
+class DashboardLockApiController extends Controller {
+ /**
+ * Constructor
+ *
+ * @param IRequest $request The HTTP request.
+ * @param DashboardLockService $lockService The lock service.
+ * @param PermissionService $permissionService Dashboard permission resolver.
+ * @param DashboardMapper $dashboardMapper UUID → id lookup.
+ * @param ActionAuthService $actionAuth ADR-023 action authorization.
+ * @param IUserSession $userSession User session (IUser resolution).
+ * @param string|null $userId The calling user ID.
+ */
+ public function __construct(
+ IRequest $request,
+ private readonly DashboardLockService $lockService,
+ private readonly PermissionService $permissionService,
+ private readonly DashboardMapper $dashboardMapper,
+ private readonly ActionAuthService $actionAuth,
+ private readonly IUserSession $userSession,
+ private readonly ?string $userId,
+ ) {
+ parent::__construct(
+ appName: Application::APP_ID,
+ request: $request
+ );
+ }//end __construct()
- /**
- * Acquire (or refresh) the lock for the given dashboard.
- *
- * Re-entrant for the same user — a second tab MUST receive HTTP
- * 200 with the refreshed lock instead of HTTP 409 (REQ-LOCK-001).
- *
- * @param string $uuid The dashboard UUID.
- *
- * @return JSONResponse 200 with the lock object on success,
- * 404 when the dashboard UUID is unknown,
- * 409 with the existing lock on conflict.
- *
- * @spec openspec/specs/dashboard-locking/spec.md
- */
- #[NoAdminRequired]
- public function acquire(string $uuid): JSONResponse
- {
- if ($this->userId === null) {
- return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED);
- }
+ /**
+ * Acquire (or refresh) the lock for the given dashboard.
+ *
+ * Re-entrant for the same user — a second tab MUST receive HTTP
+ * 200 with the refreshed lock instead of HTTP 409 (REQ-LOCK-001).
+ *
+ * @param string $uuid The dashboard UUID.
+ *
+ * @return JSONResponse 200 with the lock object on success,
+ * 404 when the dashboard UUID is unknown,
+ * 409 with the existing lock on conflict.
+ *
+ * @spec openspec/specs/dashboard-locking/spec.md
+ */
+ #[NoAdminRequired]
+ public function acquire(string $uuid): JSONResponse {
+ if ($this->userId === null) {
+ return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED);
+ }
- $user = $this->userSession->getUser();
- if ($user === null) {
- return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED);
- }
+ $user = $this->userSession->getUser();
+ if ($user === null) {
+ return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED);
+ }
- try {
- $this->actionAuth->requireAction($user, 'dashboard-lock.acquire');
- } catch (OCSForbiddenException) {
- return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN);
- }
+ try {
+ $this->actionAuth->requireAction($user, 'dashboard-lock.acquire');
+ } catch (OCSForbiddenException) {
+ return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN);
+ }
- try {
- $lock = $this->lockService->acquireLock(
- dashboardUuid: $uuid,
- userId: $this->userId
- );
- return new JSONResponse(
- data: $lock->jsonSerialize(),
- statusCode: Http::STATUS_OK
- );
- } catch (DoesNotExistException) {
- return new JSONResponse(
- data: ['error' => 'Dashboard not found'],
- statusCode: Http::STATUS_NOT_FOUND
- );
- } catch (LockForbiddenException $e) {
- // C3 fix: caller lacks view access to this dashboard.
- return new JSONResponse(
- data: [
- 'error' => $e->getMessage(),
- 'code' => LockForbiddenException::ERROR_CODE,
- ],
- statusCode: Http::STATUS_FORBIDDEN
- );
- } catch (LockConflictException $e) {
- // M2: strip userId from conflict response — callers need the
- // displayName to show "X is editing" but should not receive
- // the internal user identifier of a third party.
- return new JSONResponse(
- data: [
- 'error' => $e->getMessage(),
- 'code' => LockConflictException::ERROR_CODE,
- 'lock' => $e->getExistingLock()->jsonSerializeConflict(),
- ],
- statusCode: Http::STATUS_CONFLICT
- );
- }//end try
- }//end acquire()
+ try {
+ $lock = $this->lockService->acquireLock(
+ dashboardUuid: $uuid,
+ userId: $this->userId
+ );
+ return new JSONResponse(
+ data: $lock->jsonSerialize(),
+ statusCode: Http::STATUS_OK
+ );
+ } catch (DoesNotExistException) {
+ return new JSONResponse(
+ data: ['error' => 'Dashboard not found'],
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ } catch (LockForbiddenException $e) {
+ // C3 fix: caller lacks view access to this dashboard.
+ return new JSONResponse(
+ data: [
+ 'error' => $e->getMessage(),
+ 'code' => LockForbiddenException::ERROR_CODE,
+ ],
+ statusCode: Http::STATUS_FORBIDDEN
+ );
+ } catch (LockConflictException $e) {
+ // M2: strip userId from conflict response — callers need the
+ // displayName to show "X is editing" but should not receive
+ // the internal user identifier of a third party.
+ return new JSONResponse(
+ data: [
+ 'error' => $e->getMessage(),
+ 'code' => LockConflictException::ERROR_CODE,
+ 'lock' => $e->getExistingLock()->jsonSerializeConflict(),
+ ],
+ statusCode: Http::STATUS_CONFLICT
+ );
+ }//end try
+ }//end acquire()
- /**
- * Refresh the lock (heartbeat). Owner-only.
- *
- * @param string $uuid The dashboard UUID.
- *
- * @return JSONResponse 200 with the refreshed lock; 404 when no
- * active lock exists; 403 on owner mismatch.
- *
- * @spec openspec/specs/dashboard-locking/spec.md
- */
- #[NoAdminRequired]
- public function heartbeat(string $uuid): JSONResponse
- {
- if ($this->userId === null) {
- return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED);
- }
+ /**
+ * Refresh the lock (heartbeat). Owner-only.
+ *
+ * @param string $uuid The dashboard UUID.
+ *
+ * @return JSONResponse 200 with the refreshed lock; 404 when no
+ * active lock exists; 403 on owner mismatch.
+ *
+ * @spec openspec/specs/dashboard-locking/spec.md
+ */
+ #[NoAdminRequired]
+ public function heartbeat(string $uuid): JSONResponse {
+ if ($this->userId === null) {
+ return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED);
+ }
- $user = $this->userSession->getUser();
- if ($user === null) {
- return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED);
- }
+ $user = $this->userSession->getUser();
+ if ($user === null) {
+ return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED);
+ }
- try {
- $this->actionAuth->requireAction($user, 'dashboard-lock.heartbeat');
- } catch (OCSForbiddenException) {
- return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN);
- }
+ try {
+ $this->actionAuth->requireAction($user, 'dashboard-lock.heartbeat');
+ } catch (OCSForbiddenException) {
+ return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN);
+ }
- try {
- $lock = $this->lockService->heartbeat(
- dashboardUuid: $uuid,
- userId: $this->userId
- );
- return new JSONResponse(
- data: $lock->jsonSerialize(),
- statusCode: Http::STATUS_OK
- );
- } catch (LockNotFoundException $e) {
- return new JSONResponse(
- data: [
- 'error' => $e->getMessage(),
- 'code' => LockNotFoundException::ERROR_CODE,
- ],
- statusCode: Http::STATUS_NOT_FOUND
- );
- } catch (LockForbiddenException $e) {
- return new JSONResponse(
- data: [
- 'error' => $e->getMessage(),
- 'code' => LockForbiddenException::ERROR_CODE,
- ],
- statusCode: Http::STATUS_FORBIDDEN
- );
- }//end try
- }//end heartbeat()
+ try {
+ $lock = $this->lockService->heartbeat(
+ dashboardUuid: $uuid,
+ userId: $this->userId
+ );
+ return new JSONResponse(
+ data: $lock->jsonSerialize(),
+ statusCode: Http::STATUS_OK
+ );
+ } catch (LockNotFoundException $e) {
+ return new JSONResponse(
+ data: [
+ 'error' => $e->getMessage(),
+ 'code' => LockNotFoundException::ERROR_CODE,
+ ],
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ } catch (LockForbiddenException $e) {
+ return new JSONResponse(
+ data: [
+ 'error' => $e->getMessage(),
+ 'code' => LockForbiddenException::ERROR_CODE,
+ ],
+ statusCode: Http::STATUS_FORBIDDEN
+ );
+ }//end try
+ }//end heartbeat()
- /**
- * Release the lock. Owner-or-admin.
- *
- * Idempotent — releasing a non-existent lock returns 204 (the
- * caller's intent "no longer holding the lock" is satisfied).
- *
- * @param string $uuid The dashboard UUID.
- *
- * @return JSONResponse 204 on success; 403 on permission mismatch.
- *
- * @spec openspec/specs/dashboard-locking/spec.md
- */
- #[NoAdminRequired]
- public function release(string $uuid): JSONResponse
- {
- if ($this->userId === null) {
- return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED);
- }
+ /**
+ * Release the lock. Owner-or-admin.
+ *
+ * Idempotent — releasing a non-existent lock returns 204 (the
+ * caller's intent "no longer holding the lock" is satisfied).
+ *
+ * @param string $uuid The dashboard UUID.
+ *
+ * @return JSONResponse 204 on success; 403 on permission mismatch.
+ *
+ * @spec openspec/specs/dashboard-locking/spec.md
+ */
+ #[NoAdminRequired]
+ public function release(string $uuid): JSONResponse {
+ if ($this->userId === null) {
+ return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED);
+ }
- $user = $this->userSession->getUser();
- if ($user === null) {
- return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED);
- }
+ $user = $this->userSession->getUser();
+ if ($user === null) {
+ return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED);
+ }
- try {
- $this->actionAuth->requireAction($user, 'dashboard-lock.release');
- } catch (OCSForbiddenException) {
- return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN);
- }
+ try {
+ $this->actionAuth->requireAction($user, 'dashboard-lock.release');
+ } catch (OCSForbiddenException) {
+ return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN);
+ }
- try {
- $this->lockService->releaseLock(
- dashboardUuid: $uuid,
- userId: $this->userId,
- allowAdminOverride: true
- );
- return new JSONResponse(
- data: [],
- statusCode: Http::STATUS_NO_CONTENT
- );
- } catch (LockForbiddenException $e) {
- return new JSONResponse(
- data: [
- 'error' => $e->getMessage(),
- 'code' => LockForbiddenException::ERROR_CODE,
- ],
- statusCode: Http::STATUS_FORBIDDEN
- );
- }
- }//end release()
+ try {
+ $this->lockService->releaseLock(
+ dashboardUuid: $uuid,
+ userId: $this->userId,
+ allowAdminOverride: true
+ );
+ return new JSONResponse(
+ data: [],
+ statusCode: Http::STATUS_NO_CONTENT
+ );
+ } catch (LockForbiddenException $e) {
+ return new JSONResponse(
+ data: [
+ 'error' => $e->getMessage(),
+ 'code' => LockForbiddenException::ERROR_CODE,
+ ],
+ statusCode: Http::STATUS_FORBIDDEN
+ );
+ }
+ }//end release()
- /**
- * Query the current lock state.
- *
- * Returns the lock object when active, or HTTP 404 when none
- * exists. Stale rows are scrubbed inline by the service before
- * the response.
- *
- * @param string $uuid The dashboard UUID.
- *
- * @return JSONResponse 200 with the lock or 404 when none.
- *
- * @spec openspec/specs/dashboard-locking/spec.md
- */
- #[NoAdminRequired]
- public function get(string $uuid): JSONResponse
- {
- if ($this->userId === null) {
- return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED);
- }
+ /**
+ * Query the current lock state.
+ *
+ * Returns the lock object when active, or HTTP 404 when none
+ * exists. Stale rows are scrubbed inline by the service before
+ * the response.
+ *
+ * @param string $uuid The dashboard UUID.
+ *
+ * @return JSONResponse 200 with the lock or 404 when none.
+ *
+ * @spec openspec/specs/dashboard-locking/spec.md
+ */
+ #[NoAdminRequired]
+ public function get(string $uuid): JSONResponse {
+ if ($this->userId === null) {
+ return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED);
+ }
- $user = $this->userSession->getUser();
- if ($user === null) {
- return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED);
- }
+ $user = $this->userSession->getUser();
+ if ($user === null) {
+ return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED);
+ }
- try {
- $this->actionAuth->requireAction($user, 'dashboard-lock.get');
- } catch (OCSForbiddenException) {
- return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN);
- }
+ try {
+ $this->actionAuth->requireAction($user, 'dashboard-lock.get');
+ } catch (OCSForbiddenException) {
+ return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN);
+ }
- // H1: guard against identity leak — any authed user could enumerate
- // lock holders for arbitrary UUIDs; return 404 on no-view-access
- // (same shape as "no lock") to avoid leaking dashboard existence.
- try {
- $dashboard = $this->dashboardMapper->findByUuid(uuid: $uuid);
- } catch (DoesNotExistException) {
- return new JSONResponse(
- data: ['error' => 'Lock not found', 'code' => LockNotFoundException::ERROR_CODE],
- statusCode: Http::STATUS_NOT_FOUND
- );
- }
+ // H1: guard against identity leak — any authed user could enumerate
+ // lock holders for arbitrary UUIDs; return 404 on no-view-access
+ // (same shape as "no lock") to avoid leaking dashboard existence.
+ try {
+ $dashboard = $this->dashboardMapper->findByUuid(uuid: $uuid);
+ } catch (DoesNotExistException) {
+ return new JSONResponse(
+ data: ['error' => 'Lock not found', 'code' => LockNotFoundException::ERROR_CODE],
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ }
- if ($this->permissionService->canViewDashboard(
- userId: $this->userId,
- dashboardId: (int) $dashboard->getId()
- ) === false
- ) {
- // Return 404 not 403 to avoid leaking dashboard existence.
- return new JSONResponse(
- data: ['error' => 'Lock not found', 'code' => LockNotFoundException::ERROR_CODE],
- statusCode: Http::STATUS_NOT_FOUND
- );
- }
+ if ($this->permissionService->canViewDashboard(
+ userId: $this->userId,
+ dashboardId: (int)$dashboard->getId()
+ ) === false
+ ) {
+ // Return 404 not 403 to avoid leaking dashboard existence.
+ return new JSONResponse(
+ data: ['error' => 'Lock not found', 'code' => LockNotFoundException::ERROR_CODE],
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ }
- $lock = $this->lockService->getLockState(dashboardUuid: $uuid);
- if ($lock === null) {
- return new JSONResponse(
- data: [
- 'error' => 'Lock not found',
- 'code' => LockNotFoundException::ERROR_CODE,
- ],
- statusCode: Http::STATUS_NOT_FOUND
- );
- }
+ $lock = $this->lockService->getLockState(dashboardUuid: $uuid);
+ if ($lock === null) {
+ return new JSONResponse(
+ data: [
+ 'error' => 'Lock not found',
+ 'code' => LockNotFoundException::ERROR_CODE,
+ ],
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ }
- return new JSONResponse(
- data: $lock->jsonSerialize(),
- statusCode: Http::STATUS_OK
- );
- }//end get()
+ return new JSONResponse(
+ data: $lock->jsonSerialize(),
+ statusCode: Http::STATUS_OK
+ );
+ }//end get()
- /**
- * Admin-only: force-release any user's lock (REQ-LOCK-006, design
- * D4). The admin may then `acquire` normally if they want to take
- * the lock themselves.
- *
- * @param string $uuid The dashboard UUID.
- *
- * @return JSONResponse 200 on success; 403 when caller is not admin.
- *
- * @spec openspec/specs/dashboard-locking/spec.md
- */
- #[NoAdminRequired]
- public function forceRelease(string $uuid): JSONResponse
- {
- if ($this->userId === null) {
- return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED);
- }
+ /**
+ * Admin-only: force-release any user's lock (REQ-LOCK-006, design
+ * D4). The admin may then `acquire` normally if they want to take
+ * the lock themselves.
+ *
+ * @param string $uuid The dashboard UUID.
+ *
+ * @return JSONResponse 200 on success; 403 when caller is not admin.
+ *
+ * @spec openspec/specs/dashboard-locking/spec.md
+ */
+ #[NoAdminRequired]
+ public function forceRelease(string $uuid): JSONResponse {
+ if ($this->userId === null) {
+ return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED);
+ }
- $user = $this->userSession->getUser();
- if ($user === null) {
- return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED);
- }
+ $user = $this->userSession->getUser();
+ if ($user === null) {
+ return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED);
+ }
- try {
- $this->actionAuth->requireAction($user, 'dashboard-lock.force-release');
- } catch (OCSForbiddenException) {
- return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN);
- }
+ try {
+ $this->actionAuth->requireAction($user, 'dashboard-lock.force-release');
+ } catch (OCSForbiddenException) {
+ return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN);
+ }
- try {
- $this->lockService->forceRelease(
- dashboardUuid: $uuid,
- adminUserId: $this->userId
- );
- return new JSONResponse(
- data: ['status' => 'ok'],
- statusCode: Http::STATUS_OK
- );
- } catch (LockForbiddenException $e) {
- return new JSONResponse(
- data: [
- 'error' => $e->getMessage(),
- 'code' => LockForbiddenException::ERROR_CODE,
- ],
- statusCode: Http::STATUS_FORBIDDEN
- );
- }
- }//end forceRelease()
+ try {
+ $this->lockService->forceRelease(
+ dashboardUuid: $uuid,
+ adminUserId: $this->userId
+ );
+ return new JSONResponse(
+ data: ['status' => 'ok'],
+ statusCode: Http::STATUS_OK
+ );
+ } catch (LockForbiddenException $e) {
+ return new JSONResponse(
+ data: [
+ 'error' => $e->getMessage(),
+ 'code' => LockForbiddenException::ERROR_CODE,
+ ],
+ statusCode: Http::STATUS_FORBIDDEN
+ );
+ }
+ }//end forceRelease()
}//end class
diff --git a/lib/Controller/DashboardMetadataController.php b/lib/Controller/DashboardMetadataController.php
index 1f298cee..b290490b 100644
--- a/lib/Controller/DashboardMetadataController.php
+++ b/lib/Controller/DashboardMetadataController.php
@@ -48,171 +48,167 @@
* All access decisions are delegated to PermissionService — the single
* source of truth for dashboard ACL (H5, REQ-MDFL-008).
*/
-class DashboardMetadataController extends Controller
-{
- /**
- * Constructor.
- *
- * @param IRequest $request The HTTP request.
- * @param MetadataService $metadataService The metadata service facade.
- * @param DashboardMapper $dashboardMapper For dashboard lookup.
- * @param PermissionService $permissionService Authoritative ACL service
- * (replaces inline canRead /
- * canWrite helpers — H5).
- * @param ActionAuthService $actionAuth ADR-023 action authorization.
- * @param IUserSession $userSession User session (IUser resolution).
- * @param string|null $userId The active user id.
- */
- public function __construct(
- IRequest $request,
- private readonly MetadataService $metadataService,
- private readonly DashboardMapper $dashboardMapper,
- private readonly PermissionService $permissionService,
- private readonly ActionAuthService $actionAuth,
- private readonly IUserSession $userSession,
- private readonly ?string $userId,
- ) {
- parent::__construct(
- appName: Application::APP_ID,
- request: $request
- );
- }//end __construct()
-
- /**
- * `GET /api/dashboards/{uuid}/metadata` — REQ-MDFL-004 / REQ-MDFL-008.
- *
- * @param string $uuid The dashboard UUID.
- *
- * @return JSONResponse 200 + flat metadata, 404 when missing,
- * 403 when the caller cannot see the dashboard.
- *
- * @spec openspec/specs/dashboard-metadata-fields/spec.md
- */
- #[NoAdminRequired]
- public function getMetadata(string $uuid): JSONResponse
- {
- if ($this->userId === null) {
- return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED);
- }
-
- $user = $this->userSession->getUser();
- if ($user === null) {
- return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED);
- }
-
- try {
- $this->actionAuth->requireAction($user, 'dashboard-metadata.get-metadata');
- } catch (OCSForbiddenException) {
- return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN);
- }
-
- $dashboard = $this->loadDashboard(uuid: $uuid);
- if ($dashboard === null) {
- return new JSONResponse(
- data: ['error' => 'Dashboard not found'],
- statusCode: Http::STATUS_NOT_FOUND
- );
- }
-
- // H5: delegate to PermissionService — the single ACL source of truth.
- if ($this->permissionService->canViewDashboard(
- userId: $this->userId,
- dashboardId: $dashboard->getId()
- ) === false
- ) {
- return ResponseHelper::forbidden();
- }
-
- $metadata = $this->metadataService->getMetadataForDashboard(
- dashboardUuid: $uuid
- );
-
- return ResponseHelper::success(data: $metadata);
- }//end getMetadata()
-
- /**
- * `PUT /api/dashboards/{uuid}/metadata` — REQ-MDFL-005 / REQ-MDFL-008.
- *
- * Body: flat key-value object. Omitted keys are NOT removed; only
- * keys present in the payload are upserted.
- *
- * @param string $uuid The dashboard UUID.
- * @param array $metadata The patch payload.
- *
- * @return JSONResponse 200 + updated metadata, 400 on validation
- * failure, 404 when missing, 403 otherwise.
- *
- * @spec openspec/specs/dashboard-metadata-fields/spec.md
- */
- #[NoAdminRequired]
- public function setMetadata(string $uuid, array $metadata=[]): JSONResponse
- {
- if ($this->userId === null) {
- return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED);
- }
-
- $user = $this->userSession->getUser();
- if ($user === null) {
- return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED);
- }
-
- try {
- $this->actionAuth->requireAction($user, 'dashboard-metadata.set-metadata');
- } catch (OCSForbiddenException) {
- return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN);
- }
-
- $dashboard = $this->loadDashboard(uuid: $uuid);
- if ($dashboard === null) {
- return new JSONResponse(
- data: ['error' => 'Dashboard not found'],
- statusCode: Http::STATUS_NOT_FOUND
- );
- }
-
- // H5: delegate to PermissionService — canEditDashboardMetadata is
- // owner-only for personal dashboards; admin-only for admin templates.
- // This replaces the previous inline canWrite() which incorrectly
- // allowed any group member to write group-shared metadata.
- if ($this->permissionService->canEditDashboardMetadata(
- userId: $this->userId,
- dashboardId: $dashboard->getId()
- ) === false
- ) {
- return ResponseHelper::forbidden();
- }
-
- try {
- $updated = $this->metadataService->setMetadataForDashboard(
- dashboardUuid: $uuid,
- keyValues: $metadata
- );
- } catch (InvalidMetadataFieldException $exception) {
- return new JSONResponse(
- data: [
- 'error' => InvalidMetadataFieldException::ERROR_CODE,
- 'message' => $exception->getMessage(),
- ],
- statusCode: Http::STATUS_BAD_REQUEST
- );
- }
-
- return ResponseHelper::success(data: $updated);
- }//end setMetadata()
-
- /**
- * Resolve a UUID to a dashboard or null.
- *
- * @param string $uuid The UUID.
- *
- * @return Dashboard|null The dashboard or null.
- */
- private function loadDashboard(string $uuid): ?Dashboard
- {
- try {
- return $this->dashboardMapper->findByUuid(uuid: $uuid);
- } catch (DoesNotExistException) {
- return null;
- }
- }//end loadDashboard()
+class DashboardMetadataController extends Controller {
+ /**
+ * Constructor.
+ *
+ * @param IRequest $request The HTTP request.
+ * @param MetadataService $metadataService The metadata service facade.
+ * @param DashboardMapper $dashboardMapper For dashboard lookup.
+ * @param PermissionService $permissionService Authoritative ACL service
+ * (replaces inline canRead /
+ * canWrite helpers — H5).
+ * @param ActionAuthService $actionAuth ADR-023 action authorization.
+ * @param IUserSession $userSession User session (IUser resolution).
+ * @param string|null $userId The active user id.
+ */
+ public function __construct(
+ IRequest $request,
+ private readonly MetadataService $metadataService,
+ private readonly DashboardMapper $dashboardMapper,
+ private readonly PermissionService $permissionService,
+ private readonly ActionAuthService $actionAuth,
+ private readonly IUserSession $userSession,
+ private readonly ?string $userId,
+ ) {
+ parent::__construct(
+ appName: Application::APP_ID,
+ request: $request
+ );
+ }//end __construct()
+
+ /**
+ * `GET /api/dashboards/{uuid}/metadata` — REQ-MDFL-004 / REQ-MDFL-008.
+ *
+ * @param string $uuid The dashboard UUID.
+ *
+ * @return JSONResponse 200 + flat metadata, 404 when missing,
+ * 403 when the caller cannot see the dashboard.
+ *
+ * @spec openspec/specs/dashboard-metadata-fields/spec.md
+ */
+ #[NoAdminRequired]
+ public function getMetadata(string $uuid): JSONResponse {
+ if ($this->userId === null) {
+ return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED);
+ }
+
+ $user = $this->userSession->getUser();
+ if ($user === null) {
+ return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED);
+ }
+
+ try {
+ $this->actionAuth->requireAction($user, 'dashboard-metadata.get-metadata');
+ } catch (OCSForbiddenException) {
+ return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN);
+ }
+
+ $dashboard = $this->loadDashboard(uuid: $uuid);
+ if ($dashboard === null) {
+ return new JSONResponse(
+ data: ['error' => 'Dashboard not found'],
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ }
+
+ // H5: delegate to PermissionService — the single ACL source of truth.
+ if ($this->permissionService->canViewDashboard(
+ userId: $this->userId,
+ dashboardId: $dashboard->getId()
+ ) === false
+ ) {
+ return ResponseHelper::forbidden();
+ }
+
+ $metadata = $this->metadataService->getMetadataForDashboard(
+ dashboardUuid: $uuid
+ );
+
+ return ResponseHelper::success(data: $metadata);
+ }//end getMetadata()
+
+ /**
+ * `PUT /api/dashboards/{uuid}/metadata` — REQ-MDFL-005 / REQ-MDFL-008.
+ *
+ * Body: flat key-value object. Omitted keys are NOT removed; only
+ * keys present in the payload are upserted.
+ *
+ * @param string $uuid The dashboard UUID.
+ * @param array $metadata The patch payload.
+ *
+ * @return JSONResponse 200 + updated metadata, 400 on validation
+ * failure, 404 when missing, 403 otherwise.
+ *
+ * @spec openspec/specs/dashboard-metadata-fields/spec.md
+ */
+ #[NoAdminRequired]
+ public function setMetadata(string $uuid, array $metadata = []): JSONResponse {
+ if ($this->userId === null) {
+ return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED);
+ }
+
+ $user = $this->userSession->getUser();
+ if ($user === null) {
+ return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED);
+ }
+
+ try {
+ $this->actionAuth->requireAction($user, 'dashboard-metadata.set-metadata');
+ } catch (OCSForbiddenException) {
+ return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN);
+ }
+
+ $dashboard = $this->loadDashboard(uuid: $uuid);
+ if ($dashboard === null) {
+ return new JSONResponse(
+ data: ['error' => 'Dashboard not found'],
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ }
+
+ // H5: delegate to PermissionService — canEditDashboardMetadata is
+ // owner-only for personal dashboards; admin-only for admin templates.
+ // This replaces the previous inline canWrite() which incorrectly
+ // allowed any group member to write group-shared metadata.
+ if ($this->permissionService->canEditDashboardMetadata(
+ userId: $this->userId,
+ dashboardId: $dashboard->getId()
+ ) === false
+ ) {
+ return ResponseHelper::forbidden();
+ }
+
+ try {
+ $updated = $this->metadataService->setMetadataForDashboard(
+ dashboardUuid: $uuid,
+ keyValues: $metadata
+ );
+ } catch (InvalidMetadataFieldException $exception) {
+ return new JSONResponse(
+ data: [
+ 'error' => InvalidMetadataFieldException::ERROR_CODE,
+ 'message' => $exception->getMessage(),
+ ],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ }
+
+ return ResponseHelper::success(data: $updated);
+ }//end setMetadata()
+
+ /**
+ * Resolve a UUID to a dashboard or null.
+ *
+ * @param string $uuid The UUID.
+ *
+ * @return Dashboard|null The dashboard or null.
+ */
+ private function loadDashboard(string $uuid): ?Dashboard {
+ try {
+ return $this->dashboardMapper->findByUuid(uuid: $uuid);
+ } catch (DoesNotExistException) {
+ return null;
+ }
+ }//end loadDashboard()
}//end class
diff --git a/lib/Controller/DashboardReactionApiController.php b/lib/Controller/DashboardReactionApiController.php
index c6625b4d..18598ce6 100644
--- a/lib/Controller/DashboardReactionApiController.php
+++ b/lib/Controller/DashboardReactionApiController.php
@@ -30,8 +30,8 @@
use OCA\LaunchPad\AppInfo\Application;
use OCA\LaunchPad\Service\ActionAuthService;
use OCA\LaunchPad\Service\PermissionDeniedException;
-use OCA\LaunchPad\Service\ReactionService;
use OCA\LaunchPad\Service\ReactionsDisabledException;
+use OCA\LaunchPad\Service\ReactionService;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Http;
@@ -56,238 +56,234 @@
* and four exception types
* across four routes.
*/
-class DashboardReactionApiController extends Controller
-{
- /**
- * Constructor
- *
- * @param IRequest $request The request.
- * @param ReactionService $reactionService The reaction service.
- * @param ActionAuthService $actionAuth ADR-023 action authorization.
- * @param IUserSession $userSession The current user session.
- * @param LoggerInterface $logger PSR logger.
- * @param string|null $userId The acting user ID.
- */
- public function __construct(
- IRequest $request,
- private readonly ReactionService $reactionService,
- private readonly ActionAuthService $actionAuth,
- private readonly IUserSession $userSession,
- private readonly LoggerInterface $logger,
- private readonly ?string $userId,
- ) {
- parent::__construct(
- appName: Application::APP_ID,
- request: $request
- );
- }//end __construct()
+class DashboardReactionApiController extends Controller {
+ /**
+ * Constructor
+ *
+ * @param IRequest $request The request.
+ * @param ReactionService $reactionService The reaction service.
+ * @param ActionAuthService $actionAuth ADR-023 action authorization.
+ * @param IUserSession $userSession The current user session.
+ * @param LoggerInterface $logger PSR logger.
+ * @param string|null $userId The acting user ID.
+ */
+ public function __construct(
+ IRequest $request,
+ private readonly ReactionService $reactionService,
+ private readonly ActionAuthService $actionAuth,
+ private readonly IUserSession $userSession,
+ private readonly LoggerInterface $logger,
+ private readonly ?string $userId,
+ ) {
+ parent::__construct(
+ appName: Application::APP_ID,
+ request: $request
+ );
+ }//end __construct()
- /**
- * GET /api/dashboards/{uuid}/reactions — return the
- * `{counts, mine, enabled}` summary. REQ-RXN-003.
- *
- * @param string $uuid The dashboard UUID.
- *
- * @return JSONResponse The summary.
- *
- * @spec openspec/specs/dashboard-reactions/spec.md
- */
- #[NoAdminRequired]
- public function getReactions(string $uuid): JSONResponse
- {
- $user = $this->userSession->getUser();
- if ($user === null) {
- return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED);
- }
+ /**
+ * GET /api/dashboards/{uuid}/reactions — return the
+ * `{counts, mine, enabled}` summary. REQ-RXN-003.
+ *
+ * @param string $uuid The dashboard UUID.
+ *
+ * @return JSONResponse The summary.
+ *
+ * @spec openspec/specs/dashboard-reactions/spec.md
+ */
+ #[NoAdminRequired]
+ public function getReactions(string $uuid): JSONResponse {
+ $user = $this->userSession->getUser();
+ if ($user === null) {
+ return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED);
+ }
- $this->actionAuth->requireAction($user, 'dashboard-reaction.get-reactions');
+ $this->actionAuth->requireAction($user, 'dashboard-reaction.get-reactions');
- try {
- $summary = $this->reactionService->getReactionsSummary(
- dashboardUuid: $uuid,
- userId: $this->userId
- );
- } catch (DoesNotExistException) {
- return new JSONResponse(
- data: ['error' => 'Dashboard not found'],
- statusCode: Http::STATUS_NOT_FOUND
- );
- } catch (PermissionDeniedException $e) {
- return ResponseHelper::forbidden(message: $e->getMessage());
- } catch (Throwable $e) {
- $this->logger->error(
- message: 'getReactions failed: '.$e->getMessage(),
- context: ['exception' => $e]
- );
- return new JSONResponse(
- data: ['error' => 'Operation failed'],
- statusCode: Http::STATUS_INTERNAL_SERVER_ERROR
- );
- }//end try
+ try {
+ $summary = $this->reactionService->getReactionsSummary(
+ dashboardUuid: $uuid,
+ userId: $this->userId
+ );
+ } catch (DoesNotExistException) {
+ return new JSONResponse(
+ data: ['error' => 'Dashboard not found'],
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ } catch (PermissionDeniedException $e) {
+ return ResponseHelper::forbidden(message: $e->getMessage());
+ } catch (Throwable $e) {
+ $this->logger->error(
+ message: 'getReactions failed: ' . $e->getMessage(),
+ context: ['exception' => $e]
+ );
+ return new JSONResponse(
+ data: ['error' => 'Operation failed'],
+ statusCode: Http::STATUS_INTERNAL_SERVER_ERROR
+ );
+ }//end try
- return ResponseHelper::success(data: $summary);
- }//end getReactions()
+ return ResponseHelper::success(data: $summary);
+ }//end getReactions()
- /**
- * POST /api/dashboards/{uuid}/reactions — add the calling user's
- * reaction. Idempotent (REQ-RXN-001 scenario "User re-posts the
- * same emoji").
- *
- * @param string $uuid The dashboard UUID.
- * @param string $emoji The emoji to add (request body field).
- *
- * @return JSONResponse The updated summary.
- *
- * @spec openspec/specs/dashboard-reactions/spec.md
- */
- #[NoAdminRequired]
- public function addReaction(string $uuid, string $emoji=''): JSONResponse
- {
- $user = $this->userSession->getUser();
- if ($user === null) {
- return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED);
- }
+ /**
+ * POST /api/dashboards/{uuid}/reactions — add the calling user's
+ * reaction. Idempotent (REQ-RXN-001 scenario "User re-posts the
+ * same emoji").
+ *
+ * @param string $uuid The dashboard UUID.
+ * @param string $emoji The emoji to add (request body field).
+ *
+ * @return JSONResponse The updated summary.
+ *
+ * @spec openspec/specs/dashboard-reactions/spec.md
+ */
+ #[NoAdminRequired]
+ public function addReaction(string $uuid, string $emoji = ''): JSONResponse {
+ $user = $this->userSession->getUser();
+ if ($user === null) {
+ return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED);
+ }
- $this->actionAuth->requireAction($user, 'dashboard-reaction.add-reaction');
+ $this->actionAuth->requireAction($user, 'dashboard-reaction.add-reaction');
- try {
- $summary = $this->reactionService->addReaction(
- dashboardUuid: $uuid,
- userId: $this->userId,
- emoji: $emoji
- );
- } catch (DoesNotExistException) {
- return new JSONResponse(
- data: ['error' => 'Dashboard not found'],
- statusCode: Http::STATUS_NOT_FOUND
- );
- } catch (PermissionDeniedException $e) {
- return ResponseHelper::forbidden(message: $e->getMessage());
- } catch (ReactionsDisabledException $e) {
- return ResponseHelper::forbidden(message: $e->getMessage());
- } catch (InvalidArgumentException $e) {
- return new JSONResponse(
- data: ['error' => $e->getMessage()],
- statusCode: Http::STATUS_BAD_REQUEST
- );
- } catch (Throwable $e) {
- $this->logger->error(
- message: 'addReaction failed: '.$e->getMessage(),
- context: ['exception' => $e]
- );
- return new JSONResponse(
- data: ['error' => 'Operation failed'],
- statusCode: Http::STATUS_INTERNAL_SERVER_ERROR
- );
- }//end try
+ try {
+ $summary = $this->reactionService->addReaction(
+ dashboardUuid: $uuid,
+ userId: $this->userId,
+ emoji: $emoji
+ );
+ } catch (DoesNotExistException) {
+ return new JSONResponse(
+ data: ['error' => 'Dashboard not found'],
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ } catch (PermissionDeniedException $e) {
+ return ResponseHelper::forbidden(message: $e->getMessage());
+ } catch (ReactionsDisabledException $e) {
+ return ResponseHelper::forbidden(message: $e->getMessage());
+ } catch (InvalidArgumentException $e) {
+ return new JSONResponse(
+ data: ['error' => $e->getMessage()],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ } catch (Throwable $e) {
+ $this->logger->error(
+ message: 'addReaction failed: ' . $e->getMessage(),
+ context: ['exception' => $e]
+ );
+ return new JSONResponse(
+ data: ['error' => 'Operation failed'],
+ statusCode: Http::STATUS_INTERNAL_SERVER_ERROR
+ );
+ }//end try
- return ResponseHelper::success(data: $summary);
- }//end addReaction()
+ return ResponseHelper::success(data: $summary);
+ }//end addReaction()
- /**
- * DELETE /api/dashboards/{uuid}/reactions/{emoji} — remove the
- * calling user's reaction. Idempotent (REQ-RXN-002).
- *
- * @param string $uuid The dashboard UUID.
- * @param string $emoji The emoji to remove.
- *
- * @return JSONResponse Empty 204 response.
- *
- * @spec openspec/specs/dashboard-reactions/spec.md
- */
- #[NoAdminRequired]
- public function removeReaction(string $uuid, string $emoji): JSONResponse
- {
- $user = $this->userSession->getUser();
- if ($user === null) {
- return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED);
- }
+ /**
+ * DELETE /api/dashboards/{uuid}/reactions/{emoji} — remove the
+ * calling user's reaction. Idempotent (REQ-RXN-002).
+ *
+ * @param string $uuid The dashboard UUID.
+ * @param string $emoji The emoji to remove.
+ *
+ * @return JSONResponse Empty 204 response.
+ *
+ * @spec openspec/specs/dashboard-reactions/spec.md
+ */
+ #[NoAdminRequired]
+ public function removeReaction(string $uuid, string $emoji): JSONResponse {
+ $user = $this->userSession->getUser();
+ if ($user === null) {
+ return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED);
+ }
- $this->actionAuth->requireAction($user, 'dashboard-reaction.remove-reaction');
+ $this->actionAuth->requireAction($user, 'dashboard-reaction.remove-reaction');
- try {
- $this->reactionService->removeReaction(
- dashboardUuid: $uuid,
- userId: $this->userId,
- emoji: $emoji
- );
- } catch (DoesNotExistException) {
- return new JSONResponse(
- data: ['error' => 'Dashboard not found'],
- statusCode: Http::STATUS_NOT_FOUND
- );
- } catch (PermissionDeniedException $e) {
- return ResponseHelper::forbidden(message: $e->getMessage());
- } catch (Throwable $e) {
- $this->logger->error(
- message: 'removeReaction failed: '.$e->getMessage(),
- context: ['exception' => $e]
- );
- return new JSONResponse(
- data: ['error' => 'Operation failed'],
- statusCode: Http::STATUS_INTERNAL_SERVER_ERROR
- );
- }//end try
+ try {
+ $this->reactionService->removeReaction(
+ dashboardUuid: $uuid,
+ userId: $this->userId,
+ emoji: $emoji
+ );
+ } catch (DoesNotExistException) {
+ return new JSONResponse(
+ data: ['error' => 'Dashboard not found'],
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ } catch (PermissionDeniedException $e) {
+ return ResponseHelper::forbidden(message: $e->getMessage());
+ } catch (Throwable $e) {
+ $this->logger->error(
+ message: 'removeReaction failed: ' . $e->getMessage(),
+ context: ['exception' => $e]
+ );
+ return new JSONResponse(
+ data: ['error' => 'Operation failed'],
+ statusCode: Http::STATUS_INTERNAL_SERVER_ERROR
+ );
+ }//end try
- // 204 No Content — JSONResponse with empty body and explicit
- // status (the framework still emits headers/body shape, but
- // the contract is "204 always" per REQ-RXN-002).
- return new JSONResponse(
- data: [],
- statusCode: Http::STATUS_NO_CONTENT
- );
- }//end removeReaction()
+ // 204 No Content — JSONResponse with empty body and explicit
+ // status (the framework still emits headers/body shape, but
+ // the contract is "204 always" per REQ-RXN-002).
+ return new JSONResponse(
+ data: [],
+ statusCode: Http::STATUS_NO_CONTENT
+ );
+ }//end removeReaction()
- /**
- * GET /api/dashboards/{uuid}/reactions/{emoji}/users — return the
- * paginated list of reactors. REQ-RXN-004.
- *
- * @param string $uuid The dashboard UUID.
- * @param string $emoji The emoji.
- * @param string|null $cursor Optional opaque cursor (offset).
- *
- * @return JSONResponse The reactors page.
- *
- * @spec openspec/specs/dashboard-reactions/spec.md
- */
- #[NoAdminRequired]
- public function getReactorsByEmoji(
- string $uuid,
- string $emoji,
- ?string $cursor=null
- ): JSONResponse {
- $user = $this->userSession->getUser();
- if ($user === null) {
- return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED);
- }
+ /**
+ * GET /api/dashboards/{uuid}/reactions/{emoji}/users — return the
+ * paginated list of reactors. REQ-RXN-004.
+ *
+ * @param string $uuid The dashboard UUID.
+ * @param string $emoji The emoji.
+ * @param string|null $cursor Optional opaque cursor (offset).
+ *
+ * @return JSONResponse The reactors page.
+ *
+ * @spec openspec/specs/dashboard-reactions/spec.md
+ */
+ #[NoAdminRequired]
+ public function getReactorsByEmoji(
+ string $uuid,
+ string $emoji,
+ ?string $cursor = null,
+ ): JSONResponse {
+ $user = $this->userSession->getUser();
+ if ($user === null) {
+ return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED);
+ }
- $this->actionAuth->requireAction($user, 'dashboard-reaction.get-reactors-by-emoji');
+ $this->actionAuth->requireAction($user, 'dashboard-reaction.get-reactors-by-emoji');
- try {
- $page = $this->reactionService->getReactorsByEmoji(
- dashboardUuid: $uuid,
- emoji: $emoji,
- userId: $this->userId,
- cursor: $cursor
- );
- } catch (DoesNotExistException) {
- return new JSONResponse(
- data: ['error' => 'Dashboard not found'],
- statusCode: Http::STATUS_NOT_FOUND
- );
- } catch (PermissionDeniedException $e) {
- return ResponseHelper::forbidden(message: $e->getMessage());
- } catch (Throwable $e) {
- $this->logger->error(
- message: 'getReactorsByEmoji failed: '.$e->getMessage(),
- context: ['exception' => $e]
- );
- return new JSONResponse(
- data: ['error' => 'Operation failed'],
- statusCode: Http::STATUS_INTERNAL_SERVER_ERROR
- );
- }//end try
+ try {
+ $page = $this->reactionService->getReactorsByEmoji(
+ dashboardUuid: $uuid,
+ emoji: $emoji,
+ userId: $this->userId,
+ cursor: $cursor
+ );
+ } catch (DoesNotExistException) {
+ return new JSONResponse(
+ data: ['error' => 'Dashboard not found'],
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ } catch (PermissionDeniedException $e) {
+ return ResponseHelper::forbidden(message: $e->getMessage());
+ } catch (Throwable $e) {
+ $this->logger->error(
+ message: 'getReactorsByEmoji failed: ' . $e->getMessage(),
+ context: ['exception' => $e]
+ );
+ return new JSONResponse(
+ data: ['error' => 'Operation failed'],
+ statusCode: Http::STATUS_INTERNAL_SERVER_ERROR
+ );
+ }//end try
- return ResponseHelper::success(data: $page);
- }//end getReactorsByEmoji()
+ return ResponseHelper::success(data: $page);
+ }//end getReactorsByEmoji()
}//end class
diff --git a/lib/Controller/DashboardShareApiController.php b/lib/Controller/DashboardShareApiController.php
index bbaabcd3..8d3145ef 100644
--- a/lib/Controller/DashboardShareApiController.php
+++ b/lib/Controller/DashboardShareApiController.php
@@ -44,308 +44,303 @@
*
* @spec openspec/specs/dashboard-sharing/spec.md
*/
-class DashboardShareApiController extends Controller
-{
- /**
- * Constructor
- *
- * @param IRequest $request The request.
- * @param DashboardShareService $shareService The share service.
- * @param IUserManager $userManager Nextcloud user manager (sharee lookup).
- * @param IGroupManager $groupManager Nextcloud group manager (sharee lookup).
- * @param string|null $userId The calling user ID.
- */
- public function __construct(
- IRequest $request,
- private readonly DashboardShareService $shareService,
- private readonly IUserManager $userManager,
- private readonly IGroupManager $groupManager,
- private readonly ?string $userId,
- ) {
- parent::__construct(
- appName: Application::APP_ID,
- request: $request
- );
- }//end __construct()
+class DashboardShareApiController extends Controller {
+ /**
+ * Constructor
+ *
+ * @param IRequest $request The request.
+ * @param DashboardShareService $shareService The share service.
+ * @param IUserManager $userManager Nextcloud user manager (sharee lookup).
+ * @param IGroupManager $groupManager Nextcloud group manager (sharee lookup).
+ * @param string|null $userId The calling user ID.
+ */
+ public function __construct(
+ IRequest $request,
+ private readonly DashboardShareService $shareService,
+ private readonly IUserManager $userManager,
+ private readonly IGroupManager $groupManager,
+ private readonly ?string $userId,
+ ) {
+ parent::__construct(
+ appName: Application::APP_ID,
+ request: $request
+ );
+ }//end __construct()
- /**
- * List all shares for a dashboard.
- *
- * @param int $id The dashboard ID.
- *
- * @return DataResponse The list of shares.
- *
- * @spec openspec/specs/dashboard-sharing/spec.md
- */
- #[NoAdminRequired]
- public function index(int $id): DataResponse
- {
- if ($this->userId === null) {
- return new DataResponse(
- data: ['error' => 'Not logged in'],
- statusCode: Http::STATUS_UNAUTHORIZED
- );
- }
+ /**
+ * List all shares for a dashboard.
+ *
+ * @param int $id The dashboard ID.
+ *
+ * @return DataResponse The list of shares.
+ *
+ * @spec openspec/specs/dashboard-sharing/spec.md
+ */
+ #[NoAdminRequired]
+ public function index(int $id): DataResponse {
+ if ($this->userId === null) {
+ return new DataResponse(
+ data: ['error' => 'Not logged in'],
+ statusCode: Http::STATUS_UNAUTHORIZED
+ );
+ }
- try {
- $shares = $this->shareService->listShares(
- dashboardId: $id,
- userId: $this->userId
- );
- $serialized = array_map(
- callback: static fn($share) => $share->jsonSerialize(),
- array: $shares
- );
- return new DataResponse(data: $serialized);
- } catch (DoesNotExistException) {
- return new DataResponse(
- data: ['error' => 'Dashboard not found'],
- statusCode: Http::STATUS_NOT_FOUND
- );
- } catch (Exception $e) {
- return new DataResponse(
- data: ['error' => $e->getMessage()],
- statusCode: Http::STATUS_FORBIDDEN
- );
- }//end try
- }//end index()
+ try {
+ $shares = $this->shareService->listShares(
+ dashboardId: $id,
+ userId: $this->userId
+ );
+ $serialized = array_map(
+ callback: static fn ($share) => $share->jsonSerialize(),
+ array: $shares
+ );
+ return new DataResponse(data: $serialized);
+ } catch (DoesNotExistException) {
+ return new DataResponse(
+ data: ['error' => 'Dashboard not found'],
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ } catch (Exception $e) {
+ return new DataResponse(
+ data: ['error' => $e->getMessage()],
+ statusCode: Http::STATUS_FORBIDDEN
+ );
+ }//end try
+ }//end index()
- /**
- * Add or upsert a single share. REQ-SHARE-001.
- *
- * @param int $id The dashboard ID.
- * @param string|null $shareType The share type.
- * @param string|null $shareWith The recipient.
- * @param string|null $permissionLevel The permission level.
- *
- * @return DataResponse The created/updated share.
- *
- * @spec openspec/specs/dashboard-sharing/spec.md
- */
- #[NoAdminRequired]
- public function create(
- int $id,
- ?string $shareType=null,
- ?string $shareWith=null,
- ?string $permissionLevel=null
- ): DataResponse {
- if ($this->userId === null) {
- return new DataResponse(
- data: ['error' => 'Not logged in'],
- statusCode: Http::STATUS_UNAUTHORIZED
- );
- }
+ /**
+ * Add or upsert a single share. REQ-SHARE-001.
+ *
+ * @param int $id The dashboard ID.
+ * @param string|null $shareType The share type.
+ * @param string|null $shareWith The recipient.
+ * @param string|null $permissionLevel The permission level.
+ *
+ * @return DataResponse The created/updated share.
+ *
+ * @spec openspec/specs/dashboard-sharing/spec.md
+ */
+ #[NoAdminRequired]
+ public function create(
+ int $id,
+ ?string $shareType = null,
+ ?string $shareWith = null,
+ ?string $permissionLevel = null,
+ ): DataResponse {
+ if ($this->userId === null) {
+ return new DataResponse(
+ data: ['error' => 'Not logged in'],
+ statusCode: Http::STATUS_UNAUTHORIZED
+ );
+ }
- try {
- $share = $this->shareService->addShare(
- dashboardId: $id,
- shareType: (string) $shareType,
- shareWith: (string) $shareWith,
- permissionLevel: (string) $permissionLevel,
- callerId: $this->userId
- );
- return new DataResponse(
- data: $share->jsonSerialize(),
- statusCode: Http::STATUS_CREATED
- );
- } catch (InvalidArgumentException $e) {
- return new DataResponse(
- data: ['error' => $e->getMessage()],
- statusCode: Http::STATUS_BAD_REQUEST
- );
- } catch (DoesNotExistException) {
- return new DataResponse(
- data: ['error' => 'Dashboard not found'],
- statusCode: Http::STATUS_NOT_FOUND
- );
- } catch (Exception $e) {
- return new DataResponse(
- data: ['error' => $e->getMessage()],
- statusCode: Http::STATUS_FORBIDDEN
- );
- }//end try
- }//end create()
+ try {
+ $share = $this->shareService->addShare(
+ dashboardId: $id,
+ shareType: (string)$shareType,
+ shareWith: (string)$shareWith,
+ permissionLevel: (string)$permissionLevel,
+ callerId: $this->userId
+ );
+ return new DataResponse(
+ data: $share->jsonSerialize(),
+ statusCode: Http::STATUS_CREATED
+ );
+ } catch (InvalidArgumentException $e) {
+ return new DataResponse(
+ data: ['error' => $e->getMessage()],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ } catch (DoesNotExistException) {
+ return new DataResponse(
+ data: ['error' => 'Dashboard not found'],
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ } catch (Exception $e) {
+ return new DataResponse(
+ data: ['error' => $e->getMessage()],
+ statusCode: Http::STATUS_FORBIDDEN
+ );
+ }//end try
+ }//end create()
- /**
- * Remove a share by ID. REQ-SHARE-001.
- *
- * @param int $shareId The share ID.
- *
- * @return DataResponse Empty 204 on success.
- *
- * @spec openspec/specs/dashboard-sharing/spec.md
- */
- #[NoAdminRequired]
- public function destroy(int $shareId): DataResponse
- {
- if ($this->userId === null) {
- return new DataResponse(
- data: ['error' => 'Not logged in'],
- statusCode: Http::STATUS_UNAUTHORIZED
- );
- }
+ /**
+ * Remove a share by ID. REQ-SHARE-001.
+ *
+ * @param int $shareId The share ID.
+ *
+ * @return DataResponse Empty 204 on success.
+ *
+ * @spec openspec/specs/dashboard-sharing/spec.md
+ */
+ #[NoAdminRequired]
+ public function destroy(int $shareId): DataResponse {
+ if ($this->userId === null) {
+ return new DataResponse(
+ data: ['error' => 'Not logged in'],
+ statusCode: Http::STATUS_UNAUTHORIZED
+ );
+ }
- try {
- $this->shareService->removeShare(
- shareId: $shareId,
- callerId: $this->userId
- );
- return new DataResponse(data: [], statusCode: Http::STATUS_NO_CONTENT);
- } catch (DoesNotExistException) {
- return new DataResponse(
- data: ['error' => 'Share not found'],
- statusCode: Http::STATUS_NOT_FOUND
- );
- } catch (Exception $e) {
- return new DataResponse(
- data: ['error' => $e->getMessage()],
- statusCode: Http::STATUS_FORBIDDEN
- );
- }//end try
- }//end destroy()
+ try {
+ $this->shareService->removeShare(
+ shareId: $shareId,
+ callerId: $this->userId
+ );
+ return new DataResponse(data: [], statusCode: Http::STATUS_NO_CONTENT);
+ } catch (DoesNotExistException) {
+ return new DataResponse(
+ data: ['error' => 'Share not found'],
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ } catch (Exception $e) {
+ return new DataResponse(
+ data: ['error' => $e->getMessage()],
+ statusCode: Http::STATUS_FORBIDDEN
+ );
+ }//end try
+ }//end destroy()
- /**
- * Atomically replace all shares for a dashboard. REQ-SHARE-009.
- *
- * @param int $id The dashboard ID.
- * @param array|null $shares The new share list.
- *
- * @return DataResponse The new full share list.
- *
- * @spec openspec/specs/dashboard-sharing/spec.md
- */
- #[NoAdminRequired]
- public function replace(int $id, ?array $shares=null): DataResponse
- {
- if ($this->userId === null) {
- return new DataResponse(
- data: ['error' => 'Not logged in'],
- statusCode: Http::STATUS_UNAUTHORIZED
- );
- }
+ /**
+ * Atomically replace all shares for a dashboard. REQ-SHARE-009.
+ *
+ * @param int $id The dashboard ID.
+ * @param array|null $shares The new share list.
+ *
+ * @return DataResponse The new full share list.
+ *
+ * @spec openspec/specs/dashboard-sharing/spec.md
+ */
+ #[NoAdminRequired]
+ public function replace(int $id, ?array $shares = null): DataResponse {
+ if ($this->userId === null) {
+ return new DataResponse(
+ data: ['error' => 'Not logged in'],
+ statusCode: Http::STATUS_UNAUTHORIZED
+ );
+ }
- if ($shares === null) {
- $shares = [];
- }
+ if ($shares === null) {
+ $shares = [];
+ }
- try {
- $newShares = $this->shareService->replaceShares(
- dashboardId: $id,
- shares: $shares,
- userId: $this->userId
- );
- $serialized = array_map(
- callback: static fn($share) => $share->jsonSerialize(),
- array: $newShares
- );
- return new DataResponse(data: $serialized);
- } catch (InvalidArgumentException $e) {
- return new DataResponse(
- data: ['error' => $e->getMessage()],
- statusCode: Http::STATUS_BAD_REQUEST
- );
- } catch (DoesNotExistException) {
- return new DataResponse(
- data: ['error' => 'Dashboard not found'],
- statusCode: Http::STATUS_NOT_FOUND
- );
- } catch (Exception $e) {
- return new DataResponse(
- data: ['error' => $e->getMessage()],
- statusCode: Http::STATUS_FORBIDDEN
- );
- }//end try
- }//end replace()
+ try {
+ $newShares = $this->shareService->replaceShares(
+ dashboardId: $id,
+ shares: $shares,
+ userId: $this->userId
+ );
+ $serialized = array_map(
+ callback: static fn ($share) => $share->jsonSerialize(),
+ array: $newShares
+ );
+ return new DataResponse(data: $serialized);
+ } catch (InvalidArgumentException $e) {
+ return new DataResponse(
+ data: ['error' => $e->getMessage()],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ } catch (DoesNotExistException) {
+ return new DataResponse(
+ data: ['error' => 'Dashboard not found'],
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ } catch (Exception $e) {
+ return new DataResponse(
+ data: ['error' => $e->getMessage()],
+ statusCode: Http::STATUS_FORBIDDEN
+ );
+ }//end try
+ }//end replace()
- /**
- * Revoke all shares the caller has granted to a specific recipient.
- * REQ-SHARE-010.
- *
- * @param string $shareType The share type.
- * @param string $shareWith The recipient user/group ID.
- *
- * @return DataResponse The count of deleted rows.
- *
- * @spec openspec/specs/dashboard-sharing/spec.md
- */
- #[NoAdminRequired]
- public function revokeForRecipient(
- string $shareType,
- string $shareWith
- ): DataResponse {
- if ($this->userId === null) {
- return new DataResponse(
- data: ['error' => 'Not logged in'],
- statusCode: Http::STATUS_UNAUTHORIZED
- );
- }
+ /**
+ * Revoke all shares the caller has granted to a specific recipient.
+ * REQ-SHARE-010.
+ *
+ * @param string $shareType The share type.
+ * @param string $shareWith The recipient user/group ID.
+ *
+ * @return DataResponse The count of deleted rows.
+ *
+ * @spec openspec/specs/dashboard-sharing/spec.md
+ */
+ #[NoAdminRequired]
+ public function revokeForRecipient(
+ string $shareType,
+ string $shareWith,
+ ): DataResponse {
+ if ($this->userId === null) {
+ return new DataResponse(
+ data: ['error' => 'Not logged in'],
+ statusCode: Http::STATUS_UNAUTHORIZED
+ );
+ }
- try {
- $count = $this->shareService->revokeAllForRecipient(
- shareType: $shareType,
- shareWith: $shareWith,
- callerId: $this->userId
- );
- return new DataResponse(data: ['deleted' => $count]);
- } catch (InvalidArgumentException $e) {
- return new DataResponse(
- data: ['error' => $e->getMessage()],
- statusCode: Http::STATUS_BAD_REQUEST
- );
- }
- }//end revokeForRecipient()
+ try {
+ $count = $this->shareService->revokeAllForRecipient(
+ shareType: $shareType,
+ shareWith: $shareWith,
+ callerId: $this->userId
+ );
+ return new DataResponse(data: ['deleted' => $count]);
+ } catch (InvalidArgumentException $e) {
+ return new DataResponse(
+ data: ['error' => $e->getMessage()],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ }
+ }//end revokeForRecipient()
- /**
- * Search users and groups for the share autocomplete picker.
- * REQ-SHARE-006.
- *
- * @param string $query The search query.
- *
- * @return DataResponse The matching users and groups.
- *
- * @spec openspec/specs/dashboard-sharing/spec.md
- */
- #[NoAdminRequired]
- public function searchSharees(string $query=''): DataResponse
- {
- if ($this->userId === null) {
- return new DataResponse(
- data: ['error' => 'Not logged in'],
- statusCode: Http::STATUS_UNAUTHORIZED
- );
- }
+ /**
+ * Search users and groups for the share autocomplete picker.
+ * REQ-SHARE-006.
+ *
+ * @param string $query The search query.
+ *
+ * @return DataResponse The matching users and groups.
+ *
+ * @spec openspec/specs/dashboard-sharing/spec.md
+ */
+ #[NoAdminRequired]
+ public function searchSharees(string $query = ''): DataResponse {
+ if ($this->userId === null) {
+ return new DataResponse(
+ data: ['error' => 'Not logged in'],
+ statusCode: Http::STATUS_UNAUTHORIZED
+ );
+ }
- $trimmed = trim(string: $query);
- // M3: single-character a..z sweeps stay blocked (directory
- // enumeration guard, consistent with NC share picker) — but an
- // EMPTY query returns a bounded suggestion list so the picker is
- // never blank on focus (parity with the core share dialog).
- if (strlen(string: $trimmed) === 1) {
- return new DataResponse(data: ['users' => [], 'groups' => []]);
- }
+ $trimmed = trim(string: $query);
+ // M3: single-character a..z sweeps stay blocked (directory
+ // enumeration guard, consistent with NC share picker) — but an
+ // EMPTY query returns a bounded suggestion list so the picker is
+ // never blank on focus (parity with the core share dialog).
+ if (strlen(string: $trimmed) === 1) {
+ return new DataResponse(data: ['users' => [], 'groups' => []]);
+ }
- $users = [];
- foreach ($this->userManager->search(pattern: $trimmed, limit: 10) as $user) {
- if ($user->getUID() === $this->userId) {
- continue;
- }
+ $users = [];
+ foreach ($this->userManager->search(pattern: $trimmed, limit: 10) as $user) {
+ if ($user->getUID() === $this->userId) {
+ continue;
+ }
- $users[] = [
- 'id' => $user->getUID(),
- 'displayName' => $user->getDisplayName(),
- ];
- }
+ $users[] = [
+ 'id' => $user->getUID(),
+ 'displayName' => $user->getDisplayName(),
+ ];
+ }
- $groups = [];
- foreach ($this->groupManager->search(search: $trimmed, limit: 10) as $group) {
- $groups[] = [
- 'id' => $group->getGID(),
- 'displayName' => $group->getDisplayName(),
- ];
- }
+ $groups = [];
+ foreach ($this->groupManager->search(search: $trimmed, limit: 10) as $group) {
+ $groups[] = [
+ 'id' => $group->getGID(),
+ 'displayName' => $group->getDisplayName(),
+ ];
+ }
- return new DataResponse(
- data: ['users' => $users, 'groups' => $groups]
- );
- }//end searchSharees()
+ return new DataResponse(
+ data: ['users' => $users, 'groups' => $groups]
+ );
+ }//end searchSharees()
}//end class
diff --git a/lib/Controller/DashboardTranslationApiController.php b/lib/Controller/DashboardTranslationApiController.php
index 41faf0c4..7b419f7d 100644
--- a/lib/Controller/DashboardTranslationApiController.php
+++ b/lib/Controller/DashboardTranslationApiController.php
@@ -46,665 +46,653 @@
*
* @spec openspec/specs/dashboard-language-content/spec.md
*/
-class DashboardTranslationApiController extends Controller
-{
- /**
- * Constructor
- *
- * @param IRequest $request The request.
- * @param DashboardMapper $dashboardMapper Dashboard mapper
- * (used for the
- * ownership check
- * before any
- * translation
- * mutation).
- * @param DashboardTranslationService $translationService Translation
- * service.
- * @param ActionAuthService $actionAuth ADR-023 action
- * authorization.
- * @param IUserSession $userSession User session
- * (IUser resolution).
- * @param string|null $userId The user ID.
- */
- public function __construct(
- IRequest $request,
- private readonly DashboardMapper $dashboardMapper,
- private readonly DashboardTranslationService $translationService,
- private readonly ActionAuthService $actionAuth,
- private readonly IUserSession $userSession,
- private readonly ?string $userId,
- ) {
- parent::__construct(
- appName: Application::APP_ID,
- request: $request
- );
- }//end __construct()
-
- /**
- * GET /api/dashboards/{uuid}/translations — list every translation
- * variant for a dashboard. Returns 403 when the dashboard belongs
- * to another user. REQ-DASH-038.
- *
- * @param string $uuid The dashboard UUID.
- *
- * @return JSONResponse The list payload.
- *
- * @spec openspec/specs/dashboard-language-content/spec.md
- */
- #[NoAdminRequired]
- public function list(string $uuid): JSONResponse
- {
- $user = $this->userSession->getUser();
- if ($this->userId === null || $user === null) {
- return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED);
- }
-
- try {
- $this->actionAuth->requireAction($user, 'dashboard-translation.list');
- } catch (OCSForbiddenException) {
- return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN);
- }
-
- $ownerCheck = $this->assertOwner(uuid: $uuid);
- if ($ownerCheck !== null) {
- return $ownerCheck;
- }
-
- $variants = $this->translationService->listVariants(
- dashboardUuid: $uuid
- );
-
- $serialized = ResponseHelper::serializeList(entities: $variants);
-
- return ResponseHelper::success(
- data: ['translations' => $serialized]
- );
- }//end list()
-
- /**
- * POST /api/dashboards/{uuid}/translations — create a new variant.
- *
- * Body: `{languageCode, name?, description?, widgetTreeJson?, copyFrom?}`.
- * Returns 201 with the created entity. Maps duplicate-language
- * conflicts to HTTP 409. REQ-DASH-040.
- *
- * @param string $uuid The dashboard UUID.
- * @param string|null $languageCode The language code from the body.
- * @param string|null $name The optional name.
- * @param string|null $description The optional description.
- * @param string|null $widgetTreeJson The optional widget tree JSON.
- * @param string|null $copyFrom Optional source language.
- *
- * @return JSONResponse The created variant.
- *
- * @spec openspec/specs/dashboard-language-content/spec.md
- */
- #[NoAdminRequired]
- public function create(
- string $uuid,
- ?string $languageCode=null,
- ?string $name=null,
- ?string $description=null,
- ?string $widgetTreeJson=null,
- ?string $copyFrom=null
- ): JSONResponse {
- $user = $this->userSession->getUser();
- if ($this->userId === null || $user === null) {
- return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED);
- }
-
- try {
- $this->actionAuth->requireAction($user, 'dashboard-translation.create');
- } catch (OCSForbiddenException) {
- return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN);
- }
-
- $ownerCheck = $this->assertOwner(uuid: $uuid);
- if ($ownerCheck !== null) {
- return $ownerCheck;
- }
-
- if ($this->isBlank(value: $languageCode) === true) {
- return self::invalidArgument(
- message: DashboardTranslationService::ERR_INVALID_LANGUAGE
- );
- }
-
- try {
- $variant = $this->translationService->createVariant(
- dashboardUuid: $uuid,
- languageCode: (string) $languageCode,
- name: $name,
- description: $description,
- widgetTreeJson: $widgetTreeJson,
- copyFromLanguage: $copyFrom
- );
-
- return new JSONResponse(
- data: ['translation' => $variant->jsonSerialize()],
- statusCode: Http::STATUS_CREATED
- );
- } catch (InvalidArgumentException $e) {
- return self::invalidArgument(message: $e->getMessage());
- } catch (Exception $e) {
- return $this->mapCreateFailure(exception: $e);
- }//end try
- }//end create()
-
- /**
- * Test whether an optional string parameter carries no value.
- *
- * A missing body key arrives as `null` and an explicitly blank one as
- * `''`; both are rejected identically by the create endpoint.
- *
- * @param string|null $value The parameter value.
- *
- * @return bool True when the parameter carries no value.
- */
- private function isBlank(?string $value): bool
- {
- return ($value === null || $value === '');
- }//end isBlank()
-
- /**
- * Build the shared HTTP 400 invalid-argument envelope.
- *
- * @param string $message The validation message.
- *
- * @return JSONResponse The 400 response.
- */
- private static function invalidArgument(string $message): JSONResponse
- {
- return new JSONResponse(
- data: [
- 'status' => 'error',
- 'error' => 'invalid_argument',
- 'message' => $message,
- ],
- statusCode: Http::STATUS_BAD_REQUEST
- );
- }//end invalidArgument()
-
- /**
- * Map a create-variant failure onto its HTTP envelope.
- *
- * A duplicate-language collision is the one domain failure with a
- * dedicated status (HTTP 409); everything else falls through to the
- * generic error envelope. REQ-DASH-040.
- *
- * @param Exception $exception The failure thrown by the service.
- *
- * @return JSONResponse The mapped response.
- */
- private function mapCreateFailure(Exception $exception): JSONResponse
- {
- if ($exception->getMessage() === DashboardTranslationService::ERR_LANGUAGE_EXISTS) {
- return new JSONResponse(
- data: [
- 'status' => 'error',
- 'error' => 'language_exists',
- 'message' => $exception->getMessage(),
- ],
- statusCode: Http::STATUS_CONFLICT
- );
- }
-
- return ResponseHelper::error(exception: $exception);
- }//end mapCreateFailure()
-
- /**
- * PUT /api/dashboards/{uuid}/translations/{lang} — update a variant.
- *
- * Body: `{name?, description?, widgetTreeJson?}`. Returns 200 with
- * the updated entity. REQ-DASH-041.
- *
- * @param string $uuid The dashboard UUID.
- * @param string $lang The language code from the URL.
- * @param string|null $name Optional new name.
- * @param string|null $description Optional new description.
- * @param string|null $widgetTreeJson Optional new widget tree JSON.
- *
- * @return JSONResponse The updated variant.
- *
- * @spec openspec/specs/dashboard-language-content/spec.md
- */
- #[NoAdminRequired]
- public function update(
- string $uuid,
- string $lang,
- ?string $name=null,
- ?string $description=null,
- ?string $widgetTreeJson=null
- ): JSONResponse {
- $user = $this->userSession->getUser();
- if ($this->userId === null || $user === null) {
- return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED);
- }
-
- try {
- $this->actionAuth->requireAction($user, 'dashboard-translation.update');
- } catch (OCSForbiddenException) {
- return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN);
- }
-
- $ownerCheck = $this->assertOwner(uuid: $uuid);
- if ($ownerCheck !== null) {
- return $ownerCheck;
- }
-
- $patch = $this->buildPatch(
- name: $name,
- description: $description,
- widgetTreeJson: $widgetTreeJson
- );
-
- try {
- $variant = $this->translationService->updateVariant(
- dashboardUuid: $uuid,
- languageCode: $lang,
- patch: $patch
- );
-
- return ResponseHelper::success(
- data: ['translation' => $variant->jsonSerialize()]
- );
- } catch (DoesNotExistException) {
- return new JSONResponse(
- data: [
- 'status' => 'error',
- 'error' => 'not_found',
- ],
- statusCode: Http::STATUS_NOT_FOUND
- );
- } catch (Exception $e) {
- return ResponseHelper::error(exception: $e);
- }//end try
- }//end update()
-
- /**
- * DELETE /api/dashboards/{uuid}/translations/{lang} — delete a
- * variant. Maps last-variant / primary-variant guards to HTTP 400.
- * REQ-DASH-042.
- *
- * @param string $uuid The dashboard UUID.
- * @param string $lang The language code from the URL.
- *
- * @return JSONResponse The status payload.
- *
- * @spec openspec/specs/dashboard-language-content/spec.md
- */
- #[NoAdminRequired]
- public function destroy(string $uuid, string $lang): JSONResponse
- {
- $user = $this->userSession->getUser();
- if ($this->userId === null || $user === null) {
- return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED);
- }
-
- try {
- $this->actionAuth->requireAction($user, 'dashboard-translation.destroy');
- } catch (OCSForbiddenException) {
- return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN);
- }
-
- $ownerCheck = $this->assertOwner(uuid: $uuid);
- if ($ownerCheck !== null) {
- return $ownerCheck;
- }
-
- try {
- $this->translationService->deleteVariant(
- dashboardUuid: $uuid,
- languageCode: $lang
- );
-
- return ResponseHelper::success(data: ['status' => 'ok']);
- } catch (DoesNotExistException) {
- return new JSONResponse(
- data: [
- 'status' => 'error',
- 'error' => 'not_found',
- ],
- statusCode: Http::STATUS_NOT_FOUND
- );
- } catch (Exception $e) {
- $errorCode = 'invalid_state';
- if ($e->getMessage() === DashboardTranslationService::ERR_LAST_VARIANT) {
- $errorCode = 'last_variant';
- } else if ($e->getMessage() === DashboardTranslationService::ERR_DELETE_PRIMARY) {
- $errorCode = 'primary_variant';
- }
-
- return new JSONResponse(
- data: [
- 'status' => 'error',
- 'error' => $errorCode,
- 'message' => $e->getMessage(),
- ],
- statusCode: Http::STATUS_BAD_REQUEST
- );
- }//end try
- }//end destroy()
-
- /**
- * POST /api/dashboards/{uuid}/translations/{lang}/set-primary —
- * promote a variant to primary. Idempotent. REQ-DASH-043.
- *
- * @param string $uuid The dashboard UUID.
- * @param string $lang The language code from the URL.
- *
- * @return JSONResponse The promoted variant.
- *
- * @spec openspec/specs/dashboard-language-content/spec.md
- */
- #[NoAdminRequired]
- public function setPrimary(string $uuid, string $lang): JSONResponse
- {
- $user = $this->userSession->getUser();
- if ($this->userId === null || $user === null) {
- return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED);
- }
-
- try {
- $this->actionAuth->requireAction($user, 'dashboard-translation.set-primary');
- } catch (OCSForbiddenException) {
- return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN);
- }
-
- $ownerCheck = $this->assertOwner(uuid: $uuid);
- if ($ownerCheck !== null) {
- return $ownerCheck;
- }
-
- try {
- $variant = $this->translationService->promoteVariantToPrimary(
- dashboardUuid: $uuid,
- languageCode: $lang
- );
-
- return ResponseHelper::success(
- data: ['translation' => $variant->jsonSerialize()]
- );
- } catch (DoesNotExistException) {
- return new JSONResponse(
- data: [
- 'status' => 'error',
- 'error' => 'not_found',
- ],
- statusCode: Http::STATUS_NOT_FOUND
- );
- } catch (Exception $e) {
- return ResponseHelper::error(exception: $e);
- }
- }//end setPrimary()
-
- /**
- * GET /api/dashboards/{uuid}/resolved — resolve the dashboard's
- * content for the viewer's locale. Optional `?lang=` query
- * parameter overrides the user's Nextcloud locale; in strict mode
- * an unknown explicit lang returns 404 instead of falling back.
- * REQ-DASH-039.
- *
- * Response shape:
- * - `dashboard`: the dashboard entity payload
- * - `translation`: the matched translation row
- * - `availableLanguages`: sorted list of codes
- * - `currentLanguage`: the matched code
- * - `isFallback`: true when the primary fallback was used
- *
- * @param string $uuid The dashboard UUID.
- *
- * @return JSONResponse The resolved payload.
- *
- * @spec openspec/specs/dashboard-language-content/spec.md
- */
- #[NoAdminRequired]
- public function resolved(string $uuid): JSONResponse
- {
- $user = $this->userSession->getUser();
- if ($this->userId === null || $user === null) {
- return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED);
- }
-
- try {
- $this->actionAuth->requireAction($user, 'dashboard-translation.resolved');
- } catch (OCSForbiddenException) {
- return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN);
- }
-
- try {
- $dashboard = $this->dashboardMapper->findByUuid(uuid: $uuid);
- } catch (DoesNotExistException) {
- return new JSONResponse(
- data: [
- 'status' => 'error',
- 'error' => 'not_found',
- ],
- statusCode: Http::STATUS_NOT_FOUND
- );
- }
-
- $variant = $this->resolveVariant(
- uuid: $uuid,
- dashboard: $dashboard,
- explicitLang: $this->request->getParam(key: 'lang')
- );
-
- // Only the strict explicit-lang path yields null; the locale path
- // always materialises a variant. REQ-DASH-039 strict scenario.
- if ($variant === null) {
- return new JSONResponse(
- data: [
- 'status' => 'error',
- 'error' => 'language_not_available',
- ],
- statusCode: Http::STATUS_NOT_FOUND
- );
- }
-
- return ResponseHelper::success(
- data: [
- 'dashboard' => $dashboard->jsonSerialize(),
- 'translation' => $variant['translation']->jsonSerialize(),
- 'availableLanguages' => $this->resolveAvailableLanguages(
- uuid: $uuid,
- variant: $variant
- ),
- 'currentLanguage' => $variant['translation']->getLanguageCode(),
- 'isFallback' => $variant['isFallback'],
- ]
- );
- }//end resolved()
-
- /**
- * Resolve the translation variant this request should render.
- *
- * A usable `?lang=` query parameter selects the strict exact-match
- * path (which may report "no such language" by returning null); its
- * absence — or a blank / non-string value — selects the viewer's own
- * locale, which always yields a variant. REQ-DASH-039.
- *
- * @param string $uuid The dashboard UUID.
- * @param Dashboard $dashboard The dashboard entity (legacy source).
- * @param mixed $explicitLang The raw `lang` query parameter.
- *
- * @return array{translation: mixed, isFallback: bool}|null The variant,
- * or null.
- */
- private function resolveVariant(
- string $uuid,
- Dashboard $dashboard,
- mixed $explicitLang
- ): ?array {
- if (is_string($explicitLang) === false || $explicitLang === '') {
- return $this->resolveLocaleVariant(uuid: $uuid, dashboard: $dashboard);
- }
-
- return $this->resolveExactVariant(uuid: $uuid, explicitLang: $explicitLang);
- }//end resolveVariant()
-
- /**
- * Resolve a variant that matches the requested code exactly.
- *
- * Strict mode for the explicit `?lang=` parameter — when no exact
- * match exists for the requested code the caller must return 404
- * instead of the primary-fallback envelope, so a near-miss (the
- * service's own fallback) is reported as "no match" here.
- * REQ-DASH-039 strict scenario.
- *
- * @param string $uuid The dashboard UUID.
- * @param string $explicitLang The requested language code.
- *
- * @return array{translation: mixed, isFallback: bool}|null The exact
- * match, or
- * null.
- */
- private function resolveExactVariant(string $uuid, string $explicitLang): ?array
- {
- $variant = $this->translationService->resolveForLocale(
- dashboardUuid: $uuid,
- preferredLanguage: $explicitLang
- );
-
- $requested = DashboardTranslationMapper::normaliseLanguageCode(
- raw: $explicitLang
- );
- $matched = null;
- if ($variant !== null) {
- $matched = (string) $variant['translation']->getLanguageCode();
- }
-
- if ($matched !== $requested) {
- return null;
- }
-
- return $variant;
- }//end resolveExactVariant()
-
- /**
- * Resolve the variant for the viewer's own locale.
- *
- * Legacy fallback — dashboards predating REQ-DASH-038 may have no
- * translation rows yet. Materialise an in-memory variant from the
- * dashboard's own fields so the response envelope shape stays
- * uniform. REQ-DASH-044.
- *
- * @param string $uuid The dashboard UUID.
- * @param Dashboard $dashboard The dashboard entity (legacy source).
- *
- * @return array{translation: mixed, isFallback: bool} The variant.
- */
- private function resolveLocaleVariant(string $uuid, Dashboard $dashboard): array
- {
- $variant = $this->translationService->resolveForLocale(
- dashboardUuid: $uuid,
- preferredLanguage: ''
- );
-
- if ($variant !== null) {
- return $variant;
- }
-
- return [
- 'translation' => $this->translationService
- ->materialiseLegacyVariant(dashboard: $dashboard),
- 'isFallback' => true,
- ];
- }//end resolveLocaleVariant()
-
- /**
- * List the language codes offered for this dashboard.
- *
- * A dashboard with no stored translation rows still advertises the
- * one code the resolved variant carries, so the language switcher is
- * never empty when content is being shown.
- *
- * @param string $uuid The dashboard UUID.
- * @param array{translation: mixed, isFallback: bool} $variant The resolved variant.
- *
- * @return array The available codes.
- */
- private function resolveAvailableLanguages(string $uuid, array $variant): array
- {
- $available = $this->translationService->listAvailableLanguages(
- dashboardUuid: $uuid
- );
- if (count($available) > 0) {
- return $available;
- }
-
- $code = (string) $variant['translation']->getLanguageCode();
- if ($code === '') {
- return $available;
- }
-
- return [$code];
- }//end resolveAvailableLanguages()
-
- /**
- * Look up the dashboard and verify the current user is the owner.
- *
- * Returns null when the check passes; an HTTP 403 / 404 envelope
- * when it fails. Group-shared dashboards are not addressable via
- * the personal-scope translation endpoints — they short-circuit to
- * 403 (the group-scoped translation flow lives separately).
- *
- * @param string $uuid The dashboard UUID.
- *
- * @return JSONResponse|null The error envelope or null on success.
- */
- private function assertOwner(string $uuid): ?JSONResponse
- {
- try {
- $dashboard = $this->dashboardMapper->findByUuid(uuid: $uuid);
- } catch (DoesNotExistException) {
- return new JSONResponse(
- data: [
- 'status' => 'error',
- 'error' => 'not_found',
- ],
- statusCode: Http::STATUS_NOT_FOUND
- );
- }
-
- if ($dashboard->getUserId() !== $this->userId) {
- return ResponseHelper::forbidden();
- }
-
- return null;
- }//end assertOwner()
-
- /**
- * Build the patch payload from individual nullable parameters.
- *
- * `null` means "not in payload" (skip the key); anything else (incl.
- * the empty string) means "set it explicitly". The service then
- * inspects key presence with `array_key_exists`.
- *
- * @param string|null $name The new name.
- * @param string|null $description The new description.
- * @param string|null $widgetTreeJson The new widget tree JSON.
- *
- * @return array The patch payload.
- */
- private function buildPatch(
- ?string $name,
- ?string $description,
- ?string $widgetTreeJson
- ): array {
- $patch = [];
- if ($name !== null) {
- $patch['name'] = $name;
- }
-
- if ($description !== null) {
- $patch['description'] = $description;
- }
-
- if ($widgetTreeJson !== null) {
- $patch['widgetTreeJson'] = $widgetTreeJson;
- }
-
- return $patch;
- }//end buildPatch()
+class DashboardTranslationApiController extends Controller {
+ /**
+ * Constructor
+ *
+ * @param IRequest $request The request.
+ * @param DashboardMapper $dashboardMapper Dashboard mapper
+ * (used for the
+ * ownership check
+ * before any
+ * translation
+ * mutation).
+ * @param DashboardTranslationService $translationService Translation
+ * service.
+ * @param ActionAuthService $actionAuth ADR-023 action
+ * authorization.
+ * @param IUserSession $userSession User session
+ * (IUser resolution).
+ * @param string|null $userId The user ID.
+ */
+ public function __construct(
+ IRequest $request,
+ private readonly DashboardMapper $dashboardMapper,
+ private readonly DashboardTranslationService $translationService,
+ private readonly ActionAuthService $actionAuth,
+ private readonly IUserSession $userSession,
+ private readonly ?string $userId,
+ ) {
+ parent::__construct(
+ appName: Application::APP_ID,
+ request: $request
+ );
+ }//end __construct()
+
+ /**
+ * GET /api/dashboards/{uuid}/translations — list every translation
+ * variant for a dashboard. Returns 403 when the dashboard belongs
+ * to another user. REQ-DASH-038.
+ *
+ * @param string $uuid The dashboard UUID.
+ *
+ * @return JSONResponse The list payload.
+ *
+ * @spec openspec/specs/dashboard-language-content/spec.md
+ */
+ #[NoAdminRequired]
+ public function list(string $uuid): JSONResponse {
+ $user = $this->userSession->getUser();
+ if ($this->userId === null || $user === null) {
+ return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED);
+ }
+
+ try {
+ $this->actionAuth->requireAction($user, 'dashboard-translation.list');
+ } catch (OCSForbiddenException) {
+ return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN);
+ }
+
+ $ownerCheck = $this->assertOwner(uuid: $uuid);
+ if ($ownerCheck !== null) {
+ return $ownerCheck;
+ }
+
+ $variants = $this->translationService->listVariants(
+ dashboardUuid: $uuid
+ );
+
+ $serialized = ResponseHelper::serializeList(entities: $variants);
+
+ return ResponseHelper::success(
+ data: ['translations' => $serialized]
+ );
+ }//end list()
+
+ /**
+ * POST /api/dashboards/{uuid}/translations — create a new variant.
+ *
+ * Body: `{languageCode, name?, description?, widgetTreeJson?, copyFrom?}`.
+ * Returns 201 with the created entity. Maps duplicate-language
+ * conflicts to HTTP 409. REQ-DASH-040.
+ *
+ * @param string $uuid The dashboard UUID.
+ * @param string|null $languageCode The language code from the body.
+ * @param string|null $name The optional name.
+ * @param string|null $description The optional description.
+ * @param string|null $widgetTreeJson The optional widget tree JSON.
+ * @param string|null $copyFrom Optional source language.
+ *
+ * @return JSONResponse The created variant.
+ *
+ * @spec openspec/specs/dashboard-language-content/spec.md
+ */
+ #[NoAdminRequired]
+ public function create(
+ string $uuid,
+ ?string $languageCode = null,
+ ?string $name = null,
+ ?string $description = null,
+ ?string $widgetTreeJson = null,
+ ?string $copyFrom = null,
+ ): JSONResponse {
+ $user = $this->userSession->getUser();
+ if ($this->userId === null || $user === null) {
+ return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED);
+ }
+
+ try {
+ $this->actionAuth->requireAction($user, 'dashboard-translation.create');
+ } catch (OCSForbiddenException) {
+ return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN);
+ }
+
+ $ownerCheck = $this->assertOwner(uuid: $uuid);
+ if ($ownerCheck !== null) {
+ return $ownerCheck;
+ }
+
+ if ($this->isBlank(value: $languageCode) === true) {
+ return self::invalidArgument(
+ message: DashboardTranslationService::ERR_INVALID_LANGUAGE
+ );
+ }
+
+ try {
+ $variant = $this->translationService->createVariant(
+ dashboardUuid: $uuid,
+ languageCode: (string)$languageCode,
+ name: $name,
+ description: $description,
+ widgetTreeJson: $widgetTreeJson,
+ copyFromLanguage: $copyFrom
+ );
+
+ return new JSONResponse(
+ data: ['translation' => $variant->jsonSerialize()],
+ statusCode: Http::STATUS_CREATED
+ );
+ } catch (InvalidArgumentException $e) {
+ return self::invalidArgument(message: $e->getMessage());
+ } catch (Exception $e) {
+ return $this->mapCreateFailure(exception: $e);
+ }//end try
+ }//end create()
+
+ /**
+ * Test whether an optional string parameter carries no value.
+ *
+ * A missing body key arrives as `null` and an explicitly blank one as
+ * `''`; both are rejected identically by the create endpoint.
+ *
+ * @param string|null $value The parameter value.
+ *
+ * @return bool True when the parameter carries no value.
+ */
+ private function isBlank(?string $value): bool {
+ return ($value === null || $value === '');
+ }//end isBlank()
+
+ /**
+ * Build the shared HTTP 400 invalid-argument envelope.
+ *
+ * @param string $message The validation message.
+ *
+ * @return JSONResponse The 400 response.
+ */
+ private static function invalidArgument(string $message): JSONResponse {
+ return new JSONResponse(
+ data: [
+ 'status' => 'error',
+ 'error' => 'invalid_argument',
+ 'message' => $message,
+ ],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ }//end invalidArgument()
+
+ /**
+ * Map a create-variant failure onto its HTTP envelope.
+ *
+ * A duplicate-language collision is the one domain failure with a
+ * dedicated status (HTTP 409); everything else falls through to the
+ * generic error envelope. REQ-DASH-040.
+ *
+ * @param Exception $exception The failure thrown by the service.
+ *
+ * @return JSONResponse The mapped response.
+ */
+ private function mapCreateFailure(Exception $exception): JSONResponse {
+ if ($exception->getMessage() === DashboardTranslationService::ERR_LANGUAGE_EXISTS) {
+ return new JSONResponse(
+ data: [
+ 'status' => 'error',
+ 'error' => 'language_exists',
+ 'message' => $exception->getMessage(),
+ ],
+ statusCode: Http::STATUS_CONFLICT
+ );
+ }
+
+ return ResponseHelper::error(exception: $exception);
+ }//end mapCreateFailure()
+
+ /**
+ * PUT /api/dashboards/{uuid}/translations/{lang} — update a variant.
+ *
+ * Body: `{name?, description?, widgetTreeJson?}`. Returns 200 with
+ * the updated entity. REQ-DASH-041.
+ *
+ * @param string $uuid The dashboard UUID.
+ * @param string $lang The language code from the URL.
+ * @param string|null $name Optional new name.
+ * @param string|null $description Optional new description.
+ * @param string|null $widgetTreeJson Optional new widget tree JSON.
+ *
+ * @return JSONResponse The updated variant.
+ *
+ * @spec openspec/specs/dashboard-language-content/spec.md
+ */
+ #[NoAdminRequired]
+ public function update(
+ string $uuid,
+ string $lang,
+ ?string $name = null,
+ ?string $description = null,
+ ?string $widgetTreeJson = null,
+ ): JSONResponse {
+ $user = $this->userSession->getUser();
+ if ($this->userId === null || $user === null) {
+ return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED);
+ }
+
+ try {
+ $this->actionAuth->requireAction($user, 'dashboard-translation.update');
+ } catch (OCSForbiddenException) {
+ return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN);
+ }
+
+ $ownerCheck = $this->assertOwner(uuid: $uuid);
+ if ($ownerCheck !== null) {
+ return $ownerCheck;
+ }
+
+ $patch = $this->buildPatch(
+ name: $name,
+ description: $description,
+ widgetTreeJson: $widgetTreeJson
+ );
+
+ try {
+ $variant = $this->translationService->updateVariant(
+ dashboardUuid: $uuid,
+ languageCode: $lang,
+ patch: $patch
+ );
+
+ return ResponseHelper::success(
+ data: ['translation' => $variant->jsonSerialize()]
+ );
+ } catch (DoesNotExistException) {
+ return new JSONResponse(
+ data: [
+ 'status' => 'error',
+ 'error' => 'not_found',
+ ],
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ } catch (Exception $e) {
+ return ResponseHelper::error(exception: $e);
+ }//end try
+ }//end update()
+
+ /**
+ * DELETE /api/dashboards/{uuid}/translations/{lang} — delete a
+ * variant. Maps last-variant / primary-variant guards to HTTP 400.
+ * REQ-DASH-042.
+ *
+ * @param string $uuid The dashboard UUID.
+ * @param string $lang The language code from the URL.
+ *
+ * @return JSONResponse The status payload.
+ *
+ * @spec openspec/specs/dashboard-language-content/spec.md
+ */
+ #[NoAdminRequired]
+ public function destroy(string $uuid, string $lang): JSONResponse {
+ $user = $this->userSession->getUser();
+ if ($this->userId === null || $user === null) {
+ return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED);
+ }
+
+ try {
+ $this->actionAuth->requireAction($user, 'dashboard-translation.destroy');
+ } catch (OCSForbiddenException) {
+ return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN);
+ }
+
+ $ownerCheck = $this->assertOwner(uuid: $uuid);
+ if ($ownerCheck !== null) {
+ return $ownerCheck;
+ }
+
+ try {
+ $this->translationService->deleteVariant(
+ dashboardUuid: $uuid,
+ languageCode: $lang
+ );
+
+ return ResponseHelper::success(data: ['status' => 'ok']);
+ } catch (DoesNotExistException) {
+ return new JSONResponse(
+ data: [
+ 'status' => 'error',
+ 'error' => 'not_found',
+ ],
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ } catch (Exception $e) {
+ $errorCode = 'invalid_state';
+ if ($e->getMessage() === DashboardTranslationService::ERR_LAST_VARIANT) {
+ $errorCode = 'last_variant';
+ } elseif ($e->getMessage() === DashboardTranslationService::ERR_DELETE_PRIMARY) {
+ $errorCode = 'primary_variant';
+ }
+
+ return new JSONResponse(
+ data: [
+ 'status' => 'error',
+ 'error' => $errorCode,
+ 'message' => $e->getMessage(),
+ ],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ }//end try
+ }//end destroy()
+
+ /**
+ * POST /api/dashboards/{uuid}/translations/{lang}/set-primary —
+ * promote a variant to primary. Idempotent. REQ-DASH-043.
+ *
+ * @param string $uuid The dashboard UUID.
+ * @param string $lang The language code from the URL.
+ *
+ * @return JSONResponse The promoted variant.
+ *
+ * @spec openspec/specs/dashboard-language-content/spec.md
+ */
+ #[NoAdminRequired]
+ public function setPrimary(string $uuid, string $lang): JSONResponse {
+ $user = $this->userSession->getUser();
+ if ($this->userId === null || $user === null) {
+ return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED);
+ }
+
+ try {
+ $this->actionAuth->requireAction($user, 'dashboard-translation.set-primary');
+ } catch (OCSForbiddenException) {
+ return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN);
+ }
+
+ $ownerCheck = $this->assertOwner(uuid: $uuid);
+ if ($ownerCheck !== null) {
+ return $ownerCheck;
+ }
+
+ try {
+ $variant = $this->translationService->promoteVariantToPrimary(
+ dashboardUuid: $uuid,
+ languageCode: $lang
+ );
+
+ return ResponseHelper::success(
+ data: ['translation' => $variant->jsonSerialize()]
+ );
+ } catch (DoesNotExistException) {
+ return new JSONResponse(
+ data: [
+ 'status' => 'error',
+ 'error' => 'not_found',
+ ],
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ } catch (Exception $e) {
+ return ResponseHelper::error(exception: $e);
+ }
+ }//end setPrimary()
+
+ /**
+ * GET /api/dashboards/{uuid}/resolved — resolve the dashboard's
+ * content for the viewer's locale. Optional `?lang=` query
+ * parameter overrides the user's Nextcloud locale; in strict mode
+ * an unknown explicit lang returns 404 instead of falling back.
+ * REQ-DASH-039.
+ *
+ * Response shape:
+ * - `dashboard`: the dashboard entity payload
+ * - `translation`: the matched translation row
+ * - `availableLanguages`: sorted list of codes
+ * - `currentLanguage`: the matched code
+ * - `isFallback`: true when the primary fallback was used
+ *
+ * @param string $uuid The dashboard UUID.
+ *
+ * @return JSONResponse The resolved payload.
+ *
+ * @spec openspec/specs/dashboard-language-content/spec.md
+ */
+ #[NoAdminRequired]
+ public function resolved(string $uuid): JSONResponse {
+ $user = $this->userSession->getUser();
+ if ($this->userId === null || $user === null) {
+ return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED);
+ }
+
+ try {
+ $this->actionAuth->requireAction($user, 'dashboard-translation.resolved');
+ } catch (OCSForbiddenException) {
+ return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN);
+ }
+
+ try {
+ $dashboard = $this->dashboardMapper->findByUuid(uuid: $uuid);
+ } catch (DoesNotExistException) {
+ return new JSONResponse(
+ data: [
+ 'status' => 'error',
+ 'error' => 'not_found',
+ ],
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ }
+
+ $variant = $this->resolveVariant(
+ uuid: $uuid,
+ dashboard: $dashboard,
+ explicitLang: $this->request->getParam(key: 'lang')
+ );
+
+ // Only the strict explicit-lang path yields null; the locale path
+ // always materialises a variant. REQ-DASH-039 strict scenario.
+ if ($variant === null) {
+ return new JSONResponse(
+ data: [
+ 'status' => 'error',
+ 'error' => 'language_not_available',
+ ],
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ }
+
+ return ResponseHelper::success(
+ data: [
+ 'dashboard' => $dashboard->jsonSerialize(),
+ 'translation' => $variant['translation']->jsonSerialize(),
+ 'availableLanguages' => $this->resolveAvailableLanguages(
+ uuid: $uuid,
+ variant: $variant
+ ),
+ 'currentLanguage' => $variant['translation']->getLanguageCode(),
+ 'isFallback' => $variant['isFallback'],
+ ]
+ );
+ }//end resolved()
+
+ /**
+ * Resolve the translation variant this request should render.
+ *
+ * A usable `?lang=` query parameter selects the strict exact-match
+ * path (which may report "no such language" by returning null); its
+ * absence — or a blank / non-string value — selects the viewer's own
+ * locale, which always yields a variant. REQ-DASH-039.
+ *
+ * @param string $uuid The dashboard UUID.
+ * @param Dashboard $dashboard The dashboard entity (legacy source).
+ * @param mixed $explicitLang The raw `lang` query parameter.
+ *
+ * @return array{translation: mixed, isFallback: bool}|null The variant,
+ * or null.
+ */
+ private function resolveVariant(
+ string $uuid,
+ Dashboard $dashboard,
+ mixed $explicitLang,
+ ): ?array {
+ if (is_string($explicitLang) === false || $explicitLang === '') {
+ return $this->resolveLocaleVariant(uuid: $uuid, dashboard: $dashboard);
+ }
+
+ return $this->resolveExactVariant(uuid: $uuid, explicitLang: $explicitLang);
+ }//end resolveVariant()
+
+ /**
+ * Resolve a variant that matches the requested code exactly.
+ *
+ * Strict mode for the explicit `?lang=` parameter — when no exact
+ * match exists for the requested code the caller must return 404
+ * instead of the primary-fallback envelope, so a near-miss (the
+ * service's own fallback) is reported as "no match" here.
+ * REQ-DASH-039 strict scenario.
+ *
+ * @param string $uuid The dashboard UUID.
+ * @param string $explicitLang The requested language code.
+ *
+ * @return array{translation: mixed, isFallback: bool}|null The exact
+ * match, or
+ * null.
+ */
+ private function resolveExactVariant(string $uuid, string $explicitLang): ?array {
+ $variant = $this->translationService->resolveForLocale(
+ dashboardUuid: $uuid,
+ preferredLanguage: $explicitLang
+ );
+
+ $requested = DashboardTranslationMapper::normaliseLanguageCode(
+ raw: $explicitLang
+ );
+ $matched = null;
+ if ($variant !== null) {
+ $matched = (string)$variant['translation']->getLanguageCode();
+ }
+
+ if ($matched !== $requested) {
+ return null;
+ }
+
+ return $variant;
+ }//end resolveExactVariant()
+
+ /**
+ * Resolve the variant for the viewer's own locale.
+ *
+ * Legacy fallback — dashboards predating REQ-DASH-038 may have no
+ * translation rows yet. Materialise an in-memory variant from the
+ * dashboard's own fields so the response envelope shape stays
+ * uniform. REQ-DASH-044.
+ *
+ * @param string $uuid The dashboard UUID.
+ * @param Dashboard $dashboard The dashboard entity (legacy source).
+ *
+ * @return array{translation: mixed, isFallback: bool} The variant.
+ */
+ private function resolveLocaleVariant(string $uuid, Dashboard $dashboard): array {
+ $variant = $this->translationService->resolveForLocale(
+ dashboardUuid: $uuid,
+ preferredLanguage: ''
+ );
+
+ if ($variant !== null) {
+ return $variant;
+ }
+
+ return [
+ 'translation' => $this->translationService
+ ->materialiseLegacyVariant(dashboard: $dashboard),
+ 'isFallback' => true,
+ ];
+ }//end resolveLocaleVariant()
+
+ /**
+ * List the language codes offered for this dashboard.
+ *
+ * A dashboard with no stored translation rows still advertises the
+ * one code the resolved variant carries, so the language switcher is
+ * never empty when content is being shown.
+ *
+ * @param string $uuid The dashboard UUID.
+ * @param array{translation: mixed, isFallback: bool} $variant The resolved variant.
+ *
+ * @return array The available codes.
+ */
+ private function resolveAvailableLanguages(string $uuid, array $variant): array {
+ $available = $this->translationService->listAvailableLanguages(
+ dashboardUuid: $uuid
+ );
+ if (count($available) > 0) {
+ return $available;
+ }
+
+ $code = (string)$variant['translation']->getLanguageCode();
+ if ($code === '') {
+ return $available;
+ }
+
+ return [$code];
+ }//end resolveAvailableLanguages()
+
+ /**
+ * Look up the dashboard and verify the current user is the owner.
+ *
+ * Returns null when the check passes; an HTTP 403 / 404 envelope
+ * when it fails. Group-shared dashboards are not addressable via
+ * the personal-scope translation endpoints — they short-circuit to
+ * 403 (the group-scoped translation flow lives separately).
+ *
+ * @param string $uuid The dashboard UUID.
+ *
+ * @return JSONResponse|null The error envelope or null on success.
+ */
+ private function assertOwner(string $uuid): ?JSONResponse {
+ try {
+ $dashboard = $this->dashboardMapper->findByUuid(uuid: $uuid);
+ } catch (DoesNotExistException) {
+ return new JSONResponse(
+ data: [
+ 'status' => 'error',
+ 'error' => 'not_found',
+ ],
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ }
+
+ if ($dashboard->getUserId() !== $this->userId) {
+ return ResponseHelper::forbidden();
+ }
+
+ return null;
+ }//end assertOwner()
+
+ /**
+ * Build the patch payload from individual nullable parameters.
+ *
+ * `null` means "not in payload" (skip the key); anything else (incl.
+ * the empty string) means "set it explicitly". The service then
+ * inspects key presence with `array_key_exists`.
+ *
+ * @param string|null $name The new name.
+ * @param string|null $description The new description.
+ * @param string|null $widgetTreeJson The new widget tree JSON.
+ *
+ * @return array The patch payload.
+ */
+ private function buildPatch(
+ ?string $name,
+ ?string $description,
+ ?string $widgetTreeJson,
+ ): array {
+ $patch = [];
+ if ($name !== null) {
+ $patch['name'] = $name;
+ }
+
+ if ($description !== null) {
+ $patch['description'] = $description;
+ }
+
+ if ($widgetTreeJson !== null) {
+ $patch['widgetTreeJson'] = $widgetTreeJson;
+ }
+
+ return $patch;
+ }//end buildPatch()
}//end class
diff --git a/lib/Controller/DashboardVersionApiController.php b/lib/Controller/DashboardVersionApiController.php
index 3fd5ab57..79ff6067 100644
--- a/lib/Controller/DashboardVersionApiController.php
+++ b/lib/Controller/DashboardVersionApiController.php
@@ -48,292 +48,289 @@
/**
* Controller for dashboard version endpoints (REQ-VERS-001..009).
*/
-class DashboardVersionApiController extends Controller
-{
- /**
- * Constructor
- *
- * @param IRequest $request NC request.
- * @param DashboardMapper $dashboardMapper Dashboard row lookup.
- * @param DashboardVersionService $versionService Version service.
- * @param ActionAuthService $actionAuth ADR-023 action authorization.
- * @param IUserSession $userSession User session (IUser resolution).
- * @param LoggerInterface $logger PSR logger.
- * @param string|null $userId Current user ID.
- */
- public function __construct(
- IRequest $request,
- private readonly DashboardMapper $dashboardMapper,
- private readonly DashboardVersionService $versionService,
- private readonly ActionAuthService $actionAuth,
- private readonly IUserSession $userSession,
- private readonly LoggerInterface $logger,
- private readonly ?string $userId,
- ) {
- parent::__construct(
- appName: Application::APP_ID,
- request: $request
- );
- }//end __construct()
-
- /**
- * List the versions for a dashboard, newest-first (REQ-VERS-003).
- *
- * @param string $uuid The dashboard UUID.
- *
- * @return JSONResponse The version list envelope.
- *
- * @spec openspec/specs/dashboard-versioning/spec.md
- */
- #[NoAdminRequired]
- public function listVersions(string $uuid): JSONResponse
- {
- if ($this->userId === null) {
- return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED);
- }
-
- $user = $this->userSession->getUser();
- if ($user === null) {
- return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED);
- }
-
- try {
- $this->actionAuth->requireAction($user, 'dashboard-version.list-versions');
- } catch (OCSForbiddenException) {
- return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN);
- }
-
- try {
- $dashboard = $this->dashboardMapper->findByUuid(uuid: $uuid);
- } catch (DoesNotExistException) {
- return new JSONResponse(
- data: ['error' => 'Dashboard not found'],
- statusCode: Http::STATUS_NOT_FOUND
- );
- }
-
- try {
- $envelope = $this->versionService->listVersions(
- dashboard: $dashboard,
- requestingUser: $this->userId
- );
- } catch (Exception $e) {
- return $this->mapServiceException(exception: $e);
- }
-
- return new JSONResponse(data: $envelope, statusCode: Http::STATUS_OK);
- }//end listVersions()
-
- /**
- * Fetch a single snapshot body (REQ-VERS-004).
- *
- * @param string $uuid The dashboard UUID.
- * @param integer $versionNumber The version number.
- *
- * @return JSONResponse The full snapshot body.
- *
- * @spec openspec/specs/dashboard-versioning/spec.md
- */
- #[NoAdminRequired]
- public function fetchVersion(
- string $uuid,
- int $versionNumber
- ): JSONResponse {
- if ($this->userId === null) {
- return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED);
- }
-
- $user = $this->userSession->getUser();
- if ($user === null) {
- return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED);
- }
-
- try {
- $this->actionAuth->requireAction($user, 'dashboard-version.fetch-version');
- } catch (OCSForbiddenException) {
- return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN);
- }
-
- try {
- $dashboard = $this->dashboardMapper->findByUuid(uuid: $uuid);
- } catch (DoesNotExistException) {
- return new JSONResponse(
- data: ['error' => 'Dashboard not found'],
- statusCode: Http::STATUS_NOT_FOUND
- );
- }
-
- try {
- $version = $this->versionService->fetchSnapshot(
- dashboard: $dashboard,
- versionNumber: $versionNumber,
- requestingUser: $this->userId
- );
- } catch (DoesNotExistException) {
- return new JSONResponse(
- data: ['error' => 'Version not found'],
- statusCode: Http::STATUS_NOT_FOUND
- );
- } catch (Exception $e) {
- return $this->mapServiceException(exception: $e);
- }
-
- return new JSONResponse(
- data: [
- 'version' => $version->jsonSerialize(),
- 'snapshot' => $version->getSnapshotJson(),
- ],
- statusCode: Http::STATUS_OK
- );
- }//end fetchVersion()
-
- /**
- * Create an explicit snapshot (REQ-VERS-002). Bypasses the
- * 60-second debounce window. The optional `note` field is read
- * from the request body.
- *
- * @param string $uuid The dashboard UUID.
- * @param string|null $note Optional snapshot note (request body).
- *
- * @return JSONResponse The persisted version row.
- *
- * @spec openspec/specs/dashboard-versioning/spec.md
- */
- #[NoAdminRequired]
- public function createVersion(
- string $uuid,
- ?string $note=null
- ): JSONResponse {
- if ($this->userId === null) {
- return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED);
- }
-
- $user = $this->userSession->getUser();
- if ($user === null) {
- return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED);
- }
-
- try {
- $this->actionAuth->requireAction($user, 'dashboard-version.create-version');
- } catch (OCSForbiddenException) {
- return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN);
- }
-
- try {
- $dashboard = $this->dashboardMapper->findByUuid(uuid: $uuid);
- } catch (DoesNotExistException) {
- return new JSONResponse(
- data: ['error' => 'Dashboard not found'],
- statusCode: Http::STATUS_NOT_FOUND
- );
- }
-
- try {
- $version = $this->versionService->createExplicitSnapshot(
- dashboard: $dashboard,
- requestingUser: $this->userId,
- note: $note
- );
- } catch (Exception $e) {
- return $this->mapServiceException(exception: $e);
- }
-
- return new JSONResponse(
- data: ['version' => $version->jsonSerialize()],
- statusCode: Http::STATUS_CREATED
- );
- }//end createVersion()
-
- /**
- * Restore a snapshot (REQ-VERS-005). Captures the pre-restore
- * state as a new snapshot before applying the historical body.
- *
- * @param string $uuid The dashboard UUID.
- * @param integer $versionNumber The version number to restore.
- *
- * @return JSONResponse The restored snapshot envelope.
- *
- * @spec openspec/specs/dashboard-versioning/spec.md
- */
- #[NoAdminRequired]
- public function restoreVersion(
- string $uuid,
- int $versionNumber
- ): JSONResponse {
- if ($this->userId === null) {
- return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED);
- }
-
- $user = $this->userSession->getUser();
- if ($user === null) {
- return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED);
- }
-
- try {
- $this->actionAuth->requireAction($user, 'dashboard-version.restore-version');
- } catch (OCSForbiddenException) {
- return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN);
- }
-
- try {
- $dashboard = $this->dashboardMapper->findByUuid(uuid: $uuid);
- } catch (DoesNotExistException) {
- return new JSONResponse(
- data: ['error' => 'Dashboard not found'],
- statusCode: Http::STATUS_NOT_FOUND
- );
- }
-
- try {
- $result = $this->versionService->restoreVersion(
- dashboard: $dashboard,
- versionNumber: $versionNumber,
- restoringUser: $this->userId
- );
- } catch (DoesNotExistException) {
- return new JSONResponse(
- data: ['error' => 'Version not found'],
- statusCode: Http::STATUS_NOT_FOUND
- );
- } catch (Exception $e) {
- return $this->mapServiceException(exception: $e);
- }
-
- return new JSONResponse(
- data: [
- 'version' => $result['version']->jsonSerialize(),
- 'snapshot' => $result['snapshot'],
- ],
- statusCode: Http::STATUS_OK
- );
- }//end restoreVersion()
-
- /**
- * Map a service-layer Exception to the appropriate JSON envelope.
- *
- * @param Exception $exception The exception.
- *
- * @return JSONResponse The mapped HTTP response.
- */
- private function mapServiceException(Exception $exception): JSONResponse
- {
- $message = $exception->getMessage();
-
- if ($message === DashboardVersionService::ERR_FORBIDDEN_NOT_OWNER_OR_ADMIN) {
- return new JSONResponse(
- data: ['error' => 'forbidden'],
- statusCode: Http::STATUS_FORBIDDEN
- );
- }
-
- $this->logger->error(
- message: 'launchpad: version operation failed',
- context: ['exception' => $exception]
- );
-
- return new JSONResponse(
- data: ['error' => 'Operation failed'],
- statusCode: Http::STATUS_INTERNAL_SERVER_ERROR
- );
- }//end mapServiceException()
+class DashboardVersionApiController extends Controller {
+ /**
+ * Constructor
+ *
+ * @param IRequest $request NC request.
+ * @param DashboardMapper $dashboardMapper Dashboard row lookup.
+ * @param DashboardVersionService $versionService Version service.
+ * @param ActionAuthService $actionAuth ADR-023 action authorization.
+ * @param IUserSession $userSession User session (IUser resolution).
+ * @param LoggerInterface $logger PSR logger.
+ * @param string|null $userId Current user ID.
+ */
+ public function __construct(
+ IRequest $request,
+ private readonly DashboardMapper $dashboardMapper,
+ private readonly DashboardVersionService $versionService,
+ private readonly ActionAuthService $actionAuth,
+ private readonly IUserSession $userSession,
+ private readonly LoggerInterface $logger,
+ private readonly ?string $userId,
+ ) {
+ parent::__construct(
+ appName: Application::APP_ID,
+ request: $request
+ );
+ }//end __construct()
+
+ /**
+ * List the versions for a dashboard, newest-first (REQ-VERS-003).
+ *
+ * @param string $uuid The dashboard UUID.
+ *
+ * @return JSONResponse The version list envelope.
+ *
+ * @spec openspec/specs/dashboard-versioning/spec.md
+ */
+ #[NoAdminRequired]
+ public function listVersions(string $uuid): JSONResponse {
+ if ($this->userId === null) {
+ return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED);
+ }
+
+ $user = $this->userSession->getUser();
+ if ($user === null) {
+ return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED);
+ }
+
+ try {
+ $this->actionAuth->requireAction($user, 'dashboard-version.list-versions');
+ } catch (OCSForbiddenException) {
+ return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN);
+ }
+
+ try {
+ $dashboard = $this->dashboardMapper->findByUuid(uuid: $uuid);
+ } catch (DoesNotExistException) {
+ return new JSONResponse(
+ data: ['error' => 'Dashboard not found'],
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ }
+
+ try {
+ $envelope = $this->versionService->listVersions(
+ dashboard: $dashboard,
+ requestingUser: $this->userId
+ );
+ } catch (Exception $e) {
+ return $this->mapServiceException(exception: $e);
+ }
+
+ return new JSONResponse(data: $envelope, statusCode: Http::STATUS_OK);
+ }//end listVersions()
+
+ /**
+ * Fetch a single snapshot body (REQ-VERS-004).
+ *
+ * @param string $uuid The dashboard UUID.
+ * @param integer $versionNumber The version number.
+ *
+ * @return JSONResponse The full snapshot body.
+ *
+ * @spec openspec/specs/dashboard-versioning/spec.md
+ */
+ #[NoAdminRequired]
+ public function fetchVersion(
+ string $uuid,
+ int $versionNumber,
+ ): JSONResponse {
+ if ($this->userId === null) {
+ return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED);
+ }
+
+ $user = $this->userSession->getUser();
+ if ($user === null) {
+ return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED);
+ }
+
+ try {
+ $this->actionAuth->requireAction($user, 'dashboard-version.fetch-version');
+ } catch (OCSForbiddenException) {
+ return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN);
+ }
+
+ try {
+ $dashboard = $this->dashboardMapper->findByUuid(uuid: $uuid);
+ } catch (DoesNotExistException) {
+ return new JSONResponse(
+ data: ['error' => 'Dashboard not found'],
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ }
+
+ try {
+ $version = $this->versionService->fetchSnapshot(
+ dashboard: $dashboard,
+ versionNumber: $versionNumber,
+ requestingUser: $this->userId
+ );
+ } catch (DoesNotExistException) {
+ return new JSONResponse(
+ data: ['error' => 'Version not found'],
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ } catch (Exception $e) {
+ return $this->mapServiceException(exception: $e);
+ }
+
+ return new JSONResponse(
+ data: [
+ 'version' => $version->jsonSerialize(),
+ 'snapshot' => $version->getSnapshotJson(),
+ ],
+ statusCode: Http::STATUS_OK
+ );
+ }//end fetchVersion()
+
+ /**
+ * Create an explicit snapshot (REQ-VERS-002). Bypasses the
+ * 60-second debounce window. The optional `note` field is read
+ * from the request body.
+ *
+ * @param string $uuid The dashboard UUID.
+ * @param string|null $note Optional snapshot note (request body).
+ *
+ * @return JSONResponse The persisted version row.
+ *
+ * @spec openspec/specs/dashboard-versioning/spec.md
+ */
+ #[NoAdminRequired]
+ public function createVersion(
+ string $uuid,
+ ?string $note = null,
+ ): JSONResponse {
+ if ($this->userId === null) {
+ return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED);
+ }
+
+ $user = $this->userSession->getUser();
+ if ($user === null) {
+ return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED);
+ }
+
+ try {
+ $this->actionAuth->requireAction($user, 'dashboard-version.create-version');
+ } catch (OCSForbiddenException) {
+ return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN);
+ }
+
+ try {
+ $dashboard = $this->dashboardMapper->findByUuid(uuid: $uuid);
+ } catch (DoesNotExistException) {
+ return new JSONResponse(
+ data: ['error' => 'Dashboard not found'],
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ }
+
+ try {
+ $version = $this->versionService->createExplicitSnapshot(
+ dashboard: $dashboard,
+ requestingUser: $this->userId,
+ note: $note
+ );
+ } catch (Exception $e) {
+ return $this->mapServiceException(exception: $e);
+ }
+
+ return new JSONResponse(
+ data: ['version' => $version->jsonSerialize()],
+ statusCode: Http::STATUS_CREATED
+ );
+ }//end createVersion()
+
+ /**
+ * Restore a snapshot (REQ-VERS-005). Captures the pre-restore
+ * state as a new snapshot before applying the historical body.
+ *
+ * @param string $uuid The dashboard UUID.
+ * @param integer $versionNumber The version number to restore.
+ *
+ * @return JSONResponse The restored snapshot envelope.
+ *
+ * @spec openspec/specs/dashboard-versioning/spec.md
+ */
+ #[NoAdminRequired]
+ public function restoreVersion(
+ string $uuid,
+ int $versionNumber,
+ ): JSONResponse {
+ if ($this->userId === null) {
+ return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED);
+ }
+
+ $user = $this->userSession->getUser();
+ if ($user === null) {
+ return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED);
+ }
+
+ try {
+ $this->actionAuth->requireAction($user, 'dashboard-version.restore-version');
+ } catch (OCSForbiddenException) {
+ return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN);
+ }
+
+ try {
+ $dashboard = $this->dashboardMapper->findByUuid(uuid: $uuid);
+ } catch (DoesNotExistException) {
+ return new JSONResponse(
+ data: ['error' => 'Dashboard not found'],
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ }
+
+ try {
+ $result = $this->versionService->restoreVersion(
+ dashboard: $dashboard,
+ versionNumber: $versionNumber,
+ restoringUser: $this->userId
+ );
+ } catch (DoesNotExistException) {
+ return new JSONResponse(
+ data: ['error' => 'Version not found'],
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ } catch (Exception $e) {
+ return $this->mapServiceException(exception: $e);
+ }
+
+ return new JSONResponse(
+ data: [
+ 'version' => $result['version']->jsonSerialize(),
+ 'snapshot' => $result['snapshot'],
+ ],
+ statusCode: Http::STATUS_OK
+ );
+ }//end restoreVersion()
+
+ /**
+ * Map a service-layer Exception to the appropriate JSON envelope.
+ *
+ * @param Exception $exception The exception.
+ *
+ * @return JSONResponse The mapped HTTP response.
+ */
+ private function mapServiceException(Exception $exception): JSONResponse {
+ $message = $exception->getMessage();
+
+ if ($message === DashboardVersionService::ERR_FORBIDDEN_NOT_OWNER_OR_ADMIN) {
+ return new JSONResponse(
+ data: ['error' => 'forbidden'],
+ statusCode: Http::STATUS_FORBIDDEN
+ );
+ }
+
+ $this->logger->error(
+ message: 'launchpad: version operation failed',
+ context: ['exception' => $exception]
+ );
+
+ return new JSONResponse(
+ data: ['error' => 'Operation failed'],
+ statusCode: Http::STATUS_INTERNAL_SERVER_ERROR
+ );
+ }//end mapServiceException()
}//end class
diff --git a/lib/Controller/FileController.php b/lib/Controller/FileController.php
index 126b6c29..e7d94e8d 100644
--- a/lib/Controller/FileController.php
+++ b/lib/Controller/FileController.php
@@ -48,129 +48,126 @@
/**
* Controller for the link-button-widget createFile flow.
*/
-class FileController extends Controller
-{
- /**
- * Constructor.
- *
- * @param IRequest $request The HTTP request.
- * @param FileService $fileService File-creation pipeline.
- * @param IUserSession $userSession Session accessor.
- * @param LoggerInterface $logger PSR logger.
- */
- public function __construct(
- IRequest $request,
- private readonly FileService $fileService,
- private readonly IUserSession $userSession,
- private readonly LoggerInterface $logger,
- ) {
- parent::__construct(
- appName: Application::APP_ID,
- request: $request
- );
- }//end __construct()
+class FileController extends Controller {
+ /**
+ * Constructor.
+ *
+ * @param IRequest $request The HTTP request.
+ * @param FileService $fileService File-creation pipeline.
+ * @param IUserSession $userSession Session accessor.
+ * @param LoggerInterface $logger PSR logger.
+ */
+ public function __construct(
+ IRequest $request,
+ private readonly FileService $fileService,
+ private readonly IUserSession $userSession,
+ private readonly LoggerInterface $logger,
+ ) {
+ parent::__construct(
+ appName: Application::APP_ID,
+ request: $request
+ );
+ }//end __construct()
- /**
- * Handle `POST /api/files/create` (REQ-LBN-004).
- *
- * @param string|null $filename Leaf filename.
- * @param string|null $dir Target subdirectory (default `/`).
- * @param string|null $content Bytes to write (default empty).
- *
- * @return JSONResponse Either `{status, fileId, url}` on HTTP 200
- * or `{status, error, message}` on failure.
- *
- * @NoCSRFRequired
- *
- * @spec openspec/specs/resource-uploads/spec.md
- */
- #[NoAdminRequired]
- public function createFile(
- ?string $filename=null,
- ?string $dir='/',
- ?string $content=''
- ): JSONResponse {
- try {
- $userId = $this->resolveUserId();
+ /**
+ * Handle `POST /api/files/create` (REQ-LBN-004).
+ *
+ * @param string|null $filename Leaf filename.
+ * @param string|null $dir Target subdirectory (default `/`).
+ * @param string|null $content Bytes to write (default empty).
+ *
+ * @return JSONResponse Either `{status, fileId, url}` on HTTP 200
+ * or `{status, error, message}` on failure.
+ *
+ * @NoCSRFRequired
+ *
+ * @spec openspec/specs/resource-uploads/spec.md
+ */
+ #[NoAdminRequired]
+ public function createFile(
+ ?string $filename = null,
+ ?string $dir = '/',
+ ?string $content = '',
+ ): JSONResponse {
+ try {
+ $userId = $this->resolveUserId();
- $result = $this->fileService->createFile(
- userId: $userId,
- filename: ($filename ?? ''),
- dir: ($dir ?? '/'),
- content: ($content ?? '')
- );
+ $result = $this->fileService->createFile(
+ userId: $userId,
+ filename: ($filename ?? ''),
+ dir: ($dir ?? '/'),
+ content: ($content ?? '')
+ );
- return new JSONResponse(
- data: $result,
- statusCode: Http::STATUS_OK
- );
- } catch (ForbiddenException $e) {
- return new JSONResponse(
- data: [
- 'status' => 'error',
- 'error' => 'forbidden',
- 'message' => 'Authentication required',
- ],
- statusCode: Http::STATUS_UNAUTHORIZED
- );
- } catch (ResourceException $e) {
- if ($e instanceof StorageFailureException) {
- $this->logger->error(
- message: 'File create storage failure',
- context: ['exception' => $e->getMessage()]
- );
- }
+ return new JSONResponse(
+ data: $result,
+ statusCode: Http::STATUS_OK
+ );
+ } catch (ForbiddenException $e) {
+ return new JSONResponse(
+ data: [
+ 'status' => 'error',
+ 'error' => 'forbidden',
+ 'message' => 'Authentication required',
+ ],
+ statusCode: Http::STATUS_UNAUTHORIZED
+ );
+ } catch (ResourceException $e) {
+ if ($e instanceof StorageFailureException) {
+ $this->logger->error(
+ message: 'File create storage failure',
+ context: ['exception' => $e->getMessage()]
+ );
+ }
- return $this->errorResponse(exception: $e);
- } catch (Throwable $e) {
- // Defence in depth — never leak raw messages on
- // truly unexpected paths.
- $this->logger->error(
- message: 'Unexpected file create failure',
- context: ['exception' => $e->getMessage()]
- );
+ return $this->errorResponse(exception: $e);
+ } catch (Throwable $e) {
+ // Defence in depth — never leak raw messages on
+ // truly unexpected paths.
+ $this->logger->error(
+ message: 'Unexpected file create failure',
+ context: ['exception' => $e->getMessage()]
+ );
- $fallback = new StorageFailureException(
- message: 'Failed to create file'
- );
+ $fallback = new StorageFailureException(
+ message: 'Failed to create file'
+ );
- return $this->errorResponse(exception: $fallback);
- }//end try
- }//end createFile()
+ return $this->errorResponse(exception: $fallback);
+ }//end try
+ }//end createFile()
- /**
- * Resolve the logged-in user's ID.
- *
- * @return string The user's UID.
- *
- * @throws ForbiddenException When the request is not authenticated.
- */
- private function resolveUserId(): string
- {
- $user = $this->userSession->getUser();
- if ($user === null) {
- throw new ForbiddenException();
- }
+ /**
+ * Resolve the logged-in user's ID.
+ *
+ * @return string The user's UID.
+ *
+ * @throws ForbiddenException When the request is not authenticated.
+ */
+ private function resolveUserId(): string {
+ $user = $this->userSession->getUser();
+ if ($user === null) {
+ throw new ForbiddenException();
+ }
- return $user->getUID();
- }//end resolveUserId()
+ return $user->getUID();
+ }//end resolveUserId()
- /**
- * Build the standardised error envelope from a typed exception.
- *
- * @param ResourceException $exception The typed exception.
- *
- * @return JSONResponse The error response.
- */
- private function errorResponse(ResourceException $exception): JSONResponse
- {
- return new JSONResponse(
- data: [
- 'status' => 'error',
- 'error' => $exception->getErrorCode(),
- 'message' => $exception->getDisplayMessage(),
- ],
- statusCode: $exception->getHttpStatus()
- );
- }//end errorResponse()
+ /**
+ * Build the standardised error envelope from a typed exception.
+ *
+ * @param ResourceException $exception The typed exception.
+ *
+ * @return JSONResponse The error response.
+ */
+ private function errorResponse(ResourceException $exception): JSONResponse {
+ return new JSONResponse(
+ data: [
+ 'status' => 'error',
+ 'error' => $exception->getErrorCode(),
+ 'message' => $exception->getDisplayMessage(),
+ ],
+ statusCode: $exception->getHttpStatus()
+ );
+ }//end errorResponse()
}//end class
diff --git a/lib/Controller/FilesWidgetController.php b/lib/Controller/FilesWidgetController.php
index 2221c01c..2ef0a299 100644
--- a/lib/Controller/FilesWidgetController.php
+++ b/lib/Controller/FilesWidgetController.php
@@ -61,405 +61,397 @@
* underlying service.
* @spec openspec/specs/files-widget/spec.md
*/
-class FilesWidgetController extends Controller
-{
- /**
- * Constructor.
- *
- * @param IRequest $request HTTP request.
- * @param FilesWidgetService $service Files widget service.
- * @param WidgetPlacementMapper $placementMapper Placement entity mapper.
- * @param PermissionService $permissionService Dashboard permission gate.
- * @param IUserSession $userSession Session accessor.
- * @param LoggerInterface $logger PSR logger.
- */
- public function __construct(
- IRequest $request,
- private readonly FilesWidgetService $service,
- private readonly WidgetPlacementMapper $placementMapper,
- private readonly PermissionService $permissionService,
- private readonly IUserSession $userSession,
- private readonly LoggerInterface $logger,
- ) {
- parent::__construct(
- appName: Application::APP_ID,
- request: $request
- );
- }//end __construct()
-
- /**
- * `GET /api/widgets/files/{placementId}/contents`
- *
- * Returns the configured folder's contents as
- * `{items: [...], nextCursor: ?string}`. Empty folder is HTTP 200
- * with `items: []`. Missing folder is HTTP 404. Read-denied folder
- * is HTTP 403.
- *
- * @param integer $placementId The widget placement id.
- * @param string $currentPath Sub-path inside the configured folder.
- * @param integer $limit Page size (capped server-side).
- * @param string $cursor Opaque pagination cursor.
- *
- * @return JSONResponse
- *
- * @spec openspec/specs/files-widget/spec.md
- */
- #[NoAdminRequired]
- #[NoCSRFRequired]
- public function contents(
- int $placementId,
- string $currentPath='/',
- int $limit=FilesWidgetService::DEFAULT_LIMIT,
- string $cursor=''
- ): JSONResponse {
- $userId = $this->resolveUserId();
- if ($userId === null) {
- return $this->unauthorised();
- }
-
- $config = $this->loadConfig(placementId: $placementId, userId: $userId);
- if ($config === null) {
- return new JSONResponse(
- data: ['status' => 'error', 'error' => 'forbidden'],
- statusCode: Http::STATUS_FORBIDDEN
- );
- }
-
- try {
- $page = $this->service->getContentsForPlacement(
- userId: $userId,
- config: $config,
- currentSubPath: $currentPath,
- limit: $limit,
- cursor: $cursor
- );
-
- return new JSONResponse(
- data: $page,
- statusCode: Http::STATUS_OK
- );
- } catch (FolderNotFoundException $e) {
- return $this->errorResponse(
- error: 'folder_not_found',
- status: Http::STATUS_NOT_FOUND,
- message: $e->getDisplayMessage()
- );
- } catch (NoAccessException $e) {
- return $this->errorResponse(
- error: 'no_access',
- status: Http::STATUS_FORBIDDEN,
- message: $e->getDisplayMessage()
- );
- } catch (Throwable $e) {
- $this->logger->error(
- message: 'Unexpected files widget contents failure',
- context: ['exception' => $e->getMessage()]
- );
- return $this->errorResponse(
- error: 'unknown_error',
- status: Http::STATUS_INTERNAL_SERVER_ERROR
- );
- }//end try
- }//end contents()
-
- /**
- * `POST /api/widgets/files/{placementId}/upload`
- *
- * Accepts `multipart/form-data` with one or more `files[]` entries
- * and writes them into the placement-configured folder (or a
- * sub-path of it, if `currentPath` is supplied).
- *
- * @param integer $placementId The widget placement id.
- * @param string $currentPath Sub-path inside the configured folder.
- *
- * @return JSONResponse
- *
- * @spec openspec/specs/files-widget/spec.md
- */
- #[NoAdminRequired]
- public function upload(int $placementId, string $currentPath='/'): JSONResponse
- {
- $userId = $this->resolveUserId();
- if ($userId === null) {
- return $this->unauthorised();
- }
-
- $config = $this->loadConfig(placementId: $placementId, userId: $userId);
- if ($config === null) {
- return new JSONResponse(
- data: ['status' => 'error', 'error' => 'forbidden'],
- statusCode: Http::STATUS_FORBIDDEN
- );
- }
-
- $files = $this->normaliseUploadedFiles();
-
- try {
- $result = $this->service->uploadFiles(
- userId: $userId,
- config: $config,
- currentSubPath: $currentPath,
- uploadedFiles: $files
- );
-
- return new JSONResponse(
- data: $result,
- statusCode: Http::STATUS_OK
- );
- } catch (FolderNotFoundException $e) {
- return $this->errorResponse(
- error: 'folder_not_found',
- status: Http::STATUS_NOT_FOUND,
- message: $e->getDisplayMessage()
- );
- } catch (NoAccessException $e) {
- return $this->errorResponse(
- error: 'no_access',
- status: Http::STATUS_FORBIDDEN,
- message: $e->getDisplayMessage()
- );
- } catch (Throwable $e) {
- $this->logger->error(
- message: 'Unexpected files widget upload failure',
- context: ['exception' => $e->getMessage()]
- );
- return $this->errorResponse(
- error: 'unknown_error',
- status: Http::STATUS_INTERNAL_SERVER_ERROR
- );
- }//end try
- }//end upload()
-
- /**
- * `DELETE /api/widgets/files/{placementId}/files/{fileId}`
- *
- * Moves the supplied file into the user's trash bin.
- *
- * @param integer $placementId The widget placement id.
- * @param integer $fileId File id (must live inside the
- * configured folder).
- *
- * @return JSONResponse
- *
- * @spec openspec/specs/files-widget/spec.md
- */
- #[NoAdminRequired]
- public function destroy(int $placementId, int $fileId): JSONResponse
- {
- $userId = $this->resolveUserId();
- if ($userId === null) {
- return $this->unauthorised();
- }
-
- $config = $this->loadConfig(placementId: $placementId, userId: $userId);
- if ($config === null) {
- return new JSONResponse(
- data: ['status' => 'error', 'error' => 'forbidden'],
- statusCode: Http::STATUS_FORBIDDEN
- );
- }
-
- try {
- $result = $this->service->deleteFile(
- userId: $userId,
- config: $config,
- fileId: $fileId
- );
-
- return new JSONResponse(
- data: $result,
- statusCode: Http::STATUS_OK
- );
- } catch (FolderNotFoundException $e) {
- return $this->errorResponse(
- error: 'folder_not_found',
- status: Http::STATUS_NOT_FOUND,
- message: $e->getDisplayMessage()
- );
- } catch (NoAccessException $e) {
- return $this->errorResponse(
- error: 'no_access',
- status: Http::STATUS_FORBIDDEN,
- message: $e->getDisplayMessage()
- );
- } catch (Throwable $e) {
- $this->logger->error(
- message: 'Unexpected files widget delete failure',
- context: ['exception' => $e->getMessage()]
- );
- return $this->errorResponse(
- error: 'unknown_error',
- status: Http::STATUS_INTERNAL_SERVER_ERROR
- );
- }//end try
- }//end destroy()
-
- /**
- * Resolve the active user's UID, or `null` for anonymous.
- *
- * @return string|null
- */
- private function resolveUserId(): ?string
- {
- $user = $this->userSession->getUser();
- if ($user === null) {
- return null;
- }
-
- return $user->getUID();
- }//end resolveUserId()
-
- /**
- * Load the placement, gate it through {@see PermissionService}, and
- * return the parsed `widgetContent` config blob.
- *
- * Returns `null` when the placement is missing OR the user cannot
- * view the underlying dashboard. The caller maps `null` to a
- * forbidden response so missing-vs-no-access is indistinguishable
- * to the client.
- *
- * @param integer $placementId Widget placement id.
- * @param string $userId Viewing user's UID.
- *
- * @return array|null
- */
- private function loadConfig(int $placementId, string $userId): ?array
- {
- try {
- $placement = $this->placementMapper->find(id: $placementId);
- } catch (Throwable $e) {
- return null;
- }
-
- // L2: upload is a write operation — require write-level permission
- // (canAddWidget), not read-level (canViewDashboard).
- if ($this->permissionService->canAddWidget(
- userId: $userId,
- dashboardId: $placement->getDashboardId()
- ) === false
- ) {
- return null;
- }
-
- // Registry-driven custom widgets persist their per-type config
- // in the `content` column (added in Version001025). Older rows
- // that pre-date the column may still carry the blob inside the
- // legacy `style_config.content` slot, so we fall back to that
- // shape when the dedicated column is empty.
- $content = $placement->getContentArray();
- if ($content !== []) {
- return $content;
- }
-
- $legacy = $placement->getStyleConfigArray();
- if (isset($legacy['content']) === true && is_array($legacy['content']) === true) {
- return $legacy['content'];
- }
-
- return $legacy;
- }//end loadConfig()
-
- /**
- * Convert PHP's `$_FILES` super-global into a flat list of
- * upload entries. Supports both single (`files=...`) and
- * multi-part (`files[]=...`) submissions.
- *
- * @return list
- *
- * @SuppressWarnings(PHPMD.Superglobals) — required for multipart file uploads.
- */
- private function normaliseUploadedFiles(): array
- {
- // @phpstan-ignore-next-line — superglobal access is mixed.
- $raw = $_FILES['files'] ?? null;
- if (is_array($raw) === false) {
- return [];
- }
-
- $names = ($raw['name'] ?? null);
- $tmps = ($raw['tmp_name'] ?? null);
- $sizes = ($raw['size'] ?? null);
- $errors = ($raw['error'] ?? null);
-
- $entries = [];
- if (is_array($names) === true) {
- $count = count($names);
- if (is_array($tmps) === false) {
- $tmps = [];
- }
-
- if (is_array($sizes) === false) {
- $sizes = [];
- }
-
- if (is_array($errors) === false) {
- $errors = [];
- }
-
- for ($i = 0; $i < $count; $i++) {
- $entries[] = [
- 'name' => (string) ($names[$i] ?? ''),
- 'tmp_name' => (string) ($tmps[$i] ?? ''),
- 'size' => (int) ($sizes[$i] ?? 0),
- 'error' => (int) ($errors[$i] ?? UPLOAD_ERR_NO_FILE),
- ];
- }
- } else if ($names !== null) {
- $entries[] = [
- 'name' => (string) $names,
- 'tmp_name' => (string) ($tmps ?? ''),
- 'size' => (int) ($sizes ?? 0),
- 'error' => (int) ($errors ?? UPLOAD_ERR_NO_FILE),
- ];
- }//end if
-
- return $entries;
- }//end normaliseUploadedFiles()
-
- /**
- * Build a 401 envelope for the anonymous case.
- *
- * @return JSONResponse
- */
- private function unauthorised(): JSONResponse
- {
- return new JSONResponse(
- data: [
- 'status' => 'error',
- 'error' => 'unauthorized',
- 'message' => 'Authentication required',
- ],
- statusCode: Http::STATUS_UNAUTHORIZED
- );
- }//end unauthorised()
-
- /**
- * Build a typed error envelope.
- *
- * The status code is restricted to the union of HTTP status codes
- * accepted by {@see JSONResponse::__construct()} so that PHPStan
- * can verify the literal at every call-site.
- *
- * @param string $error Machine-readable error code.
- * @param int<100,511> $status HTTP status code.
- * @param string|null $message Optional human-readable message.
- *
- * @return JSONResponse
- */
- private function errorResponse(string $error, int $status=Http::STATUS_BAD_REQUEST, ?string $message=null): JSONResponse
- {
- $payload = [
- 'status' => 'error',
- 'error' => $error,
- ];
-
- if ($message !== null) {
- $payload['message'] = $message;
- }
-
- return new JSONResponse(
- data: $payload,
- statusCode: $status
- );
- }//end errorResponse()
+class FilesWidgetController extends Controller {
+ /**
+ * Constructor.
+ *
+ * @param IRequest $request HTTP request.
+ * @param FilesWidgetService $service Files widget service.
+ * @param WidgetPlacementMapper $placementMapper Placement entity mapper.
+ * @param PermissionService $permissionService Dashboard permission gate.
+ * @param IUserSession $userSession Session accessor.
+ * @param LoggerInterface $logger PSR logger.
+ */
+ public function __construct(
+ IRequest $request,
+ private readonly FilesWidgetService $service,
+ private readonly WidgetPlacementMapper $placementMapper,
+ private readonly PermissionService $permissionService,
+ private readonly IUserSession $userSession,
+ private readonly LoggerInterface $logger,
+ ) {
+ parent::__construct(
+ appName: Application::APP_ID,
+ request: $request
+ );
+ }//end __construct()
+
+ /**
+ * `GET /api/widgets/files/{placementId}/contents`
+ *
+ * Returns the configured folder's contents as
+ * `{items: [...], nextCursor: ?string}`. Empty folder is HTTP 200
+ * with `items: []`. Missing folder is HTTP 404. Read-denied folder
+ * is HTTP 403.
+ *
+ * @param integer $placementId The widget placement id.
+ * @param string $currentPath Sub-path inside the configured folder.
+ * @param integer $limit Page size (capped server-side).
+ * @param string $cursor Opaque pagination cursor.
+ *
+ * @return JSONResponse
+ *
+ * @spec openspec/specs/files-widget/spec.md
+ */
+ #[NoAdminRequired]
+ #[NoCSRFRequired]
+ public function contents(
+ int $placementId,
+ string $currentPath = '/',
+ int $limit = FilesWidgetService::DEFAULT_LIMIT,
+ string $cursor = '',
+ ): JSONResponse {
+ $userId = $this->resolveUserId();
+ if ($userId === null) {
+ return $this->unauthorised();
+ }
+
+ $config = $this->loadConfig(placementId: $placementId, userId: $userId);
+ if ($config === null) {
+ return new JSONResponse(
+ data: ['status' => 'error', 'error' => 'forbidden'],
+ statusCode: Http::STATUS_FORBIDDEN
+ );
+ }
+
+ try {
+ $page = $this->service->getContentsForPlacement(
+ userId: $userId,
+ config: $config,
+ currentSubPath: $currentPath,
+ limit: $limit,
+ cursor: $cursor
+ );
+
+ return new JSONResponse(
+ data: $page,
+ statusCode: Http::STATUS_OK
+ );
+ } catch (FolderNotFoundException $e) {
+ return $this->errorResponse(
+ error: 'folder_not_found',
+ status: Http::STATUS_NOT_FOUND,
+ message: $e->getDisplayMessage()
+ );
+ } catch (NoAccessException $e) {
+ return $this->errorResponse(
+ error: 'no_access',
+ status: Http::STATUS_FORBIDDEN,
+ message: $e->getDisplayMessage()
+ );
+ } catch (Throwable $e) {
+ $this->logger->error(
+ message: 'Unexpected files widget contents failure',
+ context: ['exception' => $e->getMessage()]
+ );
+ return $this->errorResponse(
+ error: 'unknown_error',
+ status: Http::STATUS_INTERNAL_SERVER_ERROR
+ );
+ }//end try
+ }//end contents()
+
+ /**
+ * `POST /api/widgets/files/{placementId}/upload`
+ *
+ * Accepts `multipart/form-data` with one or more `files[]` entries
+ * and writes them into the placement-configured folder (or a
+ * sub-path of it, if `currentPath` is supplied).
+ *
+ * @param integer $placementId The widget placement id.
+ * @param string $currentPath Sub-path inside the configured folder.
+ *
+ * @return JSONResponse
+ *
+ * @spec openspec/specs/files-widget/spec.md
+ */
+ #[NoAdminRequired]
+ public function upload(int $placementId, string $currentPath = '/'): JSONResponse {
+ $userId = $this->resolveUserId();
+ if ($userId === null) {
+ return $this->unauthorised();
+ }
+
+ $config = $this->loadConfig(placementId: $placementId, userId: $userId);
+ if ($config === null) {
+ return new JSONResponse(
+ data: ['status' => 'error', 'error' => 'forbidden'],
+ statusCode: Http::STATUS_FORBIDDEN
+ );
+ }
+
+ $files = $this->normaliseUploadedFiles();
+
+ try {
+ $result = $this->service->uploadFiles(
+ userId: $userId,
+ config: $config,
+ currentSubPath: $currentPath,
+ uploadedFiles: $files
+ );
+
+ return new JSONResponse(
+ data: $result,
+ statusCode: Http::STATUS_OK
+ );
+ } catch (FolderNotFoundException $e) {
+ return $this->errorResponse(
+ error: 'folder_not_found',
+ status: Http::STATUS_NOT_FOUND,
+ message: $e->getDisplayMessage()
+ );
+ } catch (NoAccessException $e) {
+ return $this->errorResponse(
+ error: 'no_access',
+ status: Http::STATUS_FORBIDDEN,
+ message: $e->getDisplayMessage()
+ );
+ } catch (Throwable $e) {
+ $this->logger->error(
+ message: 'Unexpected files widget upload failure',
+ context: ['exception' => $e->getMessage()]
+ );
+ return $this->errorResponse(
+ error: 'unknown_error',
+ status: Http::STATUS_INTERNAL_SERVER_ERROR
+ );
+ }//end try
+ }//end upload()
+
+ /**
+ * `DELETE /api/widgets/files/{placementId}/files/{fileId}`
+ *
+ * Moves the supplied file into the user's trash bin.
+ *
+ * @param integer $placementId The widget placement id.
+ * @param integer $fileId File id (must live inside the
+ * configured folder).
+ *
+ * @return JSONResponse
+ *
+ * @spec openspec/specs/files-widget/spec.md
+ */
+ #[NoAdminRequired]
+ public function destroy(int $placementId, int $fileId): JSONResponse {
+ $userId = $this->resolveUserId();
+ if ($userId === null) {
+ return $this->unauthorised();
+ }
+
+ $config = $this->loadConfig(placementId: $placementId, userId: $userId);
+ if ($config === null) {
+ return new JSONResponse(
+ data: ['status' => 'error', 'error' => 'forbidden'],
+ statusCode: Http::STATUS_FORBIDDEN
+ );
+ }
+
+ try {
+ $result = $this->service->deleteFile(
+ userId: $userId,
+ config: $config,
+ fileId: $fileId
+ );
+
+ return new JSONResponse(
+ data: $result,
+ statusCode: Http::STATUS_OK
+ );
+ } catch (FolderNotFoundException $e) {
+ return $this->errorResponse(
+ error: 'folder_not_found',
+ status: Http::STATUS_NOT_FOUND,
+ message: $e->getDisplayMessage()
+ );
+ } catch (NoAccessException $e) {
+ return $this->errorResponse(
+ error: 'no_access',
+ status: Http::STATUS_FORBIDDEN,
+ message: $e->getDisplayMessage()
+ );
+ } catch (Throwable $e) {
+ $this->logger->error(
+ message: 'Unexpected files widget delete failure',
+ context: ['exception' => $e->getMessage()]
+ );
+ return $this->errorResponse(
+ error: 'unknown_error',
+ status: Http::STATUS_INTERNAL_SERVER_ERROR
+ );
+ }//end try
+ }//end destroy()
+
+ /**
+ * Resolve the active user's UID, or `null` for anonymous.
+ *
+ * @return string|null
+ */
+ private function resolveUserId(): ?string {
+ $user = $this->userSession->getUser();
+ if ($user === null) {
+ return null;
+ }
+
+ return $user->getUID();
+ }//end resolveUserId()
+
+ /**
+ * Load the placement, gate it through {@see PermissionService}, and
+ * return the parsed `widgetContent` config blob.
+ *
+ * Returns `null` when the placement is missing OR the user cannot
+ * view the underlying dashboard. The caller maps `null` to a
+ * forbidden response so missing-vs-no-access is indistinguishable
+ * to the client.
+ *
+ * @param integer $placementId Widget placement id.
+ * @param string $userId Viewing user's UID.
+ *
+ * @return array|null
+ */
+ private function loadConfig(int $placementId, string $userId): ?array {
+ try {
+ $placement = $this->placementMapper->find(id: $placementId);
+ } catch (Throwable $e) {
+ return null;
+ }
+
+ // L2: upload is a write operation — require write-level permission
+ // (canAddWidget), not read-level (canViewDashboard).
+ if ($this->permissionService->canAddWidget(
+ userId: $userId,
+ dashboardId: $placement->getDashboardId()
+ ) === false
+ ) {
+ return null;
+ }
+
+ // Registry-driven custom widgets persist their per-type config
+ // in the `content` column (added in Version001025). Older rows
+ // that pre-date the column may still carry the blob inside the
+ // legacy `style_config.content` slot, so we fall back to that
+ // shape when the dedicated column is empty.
+ $content = $placement->getContentArray();
+ if ($content !== []) {
+ return $content;
+ }
+
+ $legacy = $placement->getStyleConfigArray();
+ if (isset($legacy['content']) === true && is_array($legacy['content']) === true) {
+ return $legacy['content'];
+ }
+
+ return $legacy;
+ }//end loadConfig()
+
+ /**
+ * Convert PHP's `$_FILES` super-global into a flat list of
+ * upload entries. Supports both single (`files=...`) and
+ * multi-part (`files[]=...`) submissions.
+ *
+ * @return list
+ *
+ * @SuppressWarnings(PHPMD.Superglobals) — required for multipart file uploads.
+ */
+ private function normaliseUploadedFiles(): array {
+ // @phpstan-ignore-next-line — superglobal access is mixed.
+ $raw = $_FILES['files'] ?? null;
+ if (is_array($raw) === false) {
+ return [];
+ }
+
+ $names = ($raw['name'] ?? null);
+ $tmps = ($raw['tmp_name'] ?? null);
+ $sizes = ($raw['size'] ?? null);
+ $errors = ($raw['error'] ?? null);
+
+ $entries = [];
+ if (is_array($names) === true) {
+ $count = count($names);
+ if (is_array($tmps) === false) {
+ $tmps = [];
+ }
+
+ if (is_array($sizes) === false) {
+ $sizes = [];
+ }
+
+ if (is_array($errors) === false) {
+ $errors = [];
+ }
+
+ for ($i = 0; $i < $count; $i++) {
+ $entries[] = [
+ 'name' => (string)($names[$i] ?? ''),
+ 'tmp_name' => (string)($tmps[$i] ?? ''),
+ 'size' => (int)($sizes[$i] ?? 0),
+ 'error' => (int)($errors[$i] ?? UPLOAD_ERR_NO_FILE),
+ ];
+ }
+ } elseif ($names !== null) {
+ $entries[] = [
+ 'name' => (string)$names,
+ 'tmp_name' => (string)($tmps ?? ''),
+ 'size' => (int)($sizes ?? 0),
+ 'error' => (int)($errors ?? UPLOAD_ERR_NO_FILE),
+ ];
+ }//end if
+
+ return $entries;
+ }//end normaliseUploadedFiles()
+
+ /**
+ * Build a 401 envelope for the anonymous case.
+ *
+ * @return JSONResponse
+ */
+ private function unauthorised(): JSONResponse {
+ return new JSONResponse(
+ data: [
+ 'status' => 'error',
+ 'error' => 'unauthorized',
+ 'message' => 'Authentication required',
+ ],
+ statusCode: Http::STATUS_UNAUTHORIZED
+ );
+ }//end unauthorised()
+
+ /**
+ * Build a typed error envelope.
+ *
+ * The status code is restricted to the union of HTTP status codes
+ * accepted by {@see JSONResponse::__construct()} so that PHPStan
+ * can verify the literal at every call-site.
+ *
+ * @param string $error Machine-readable error code.
+ * @param int<100,511> $status HTTP status code.
+ * @param string|null $message Optional human-readable message.
+ *
+ * @return JSONResponse
+ */
+ private function errorResponse(string $error, int $status = Http::STATUS_BAD_REQUEST, ?string $message = null): JSONResponse {
+ $payload = [
+ 'status' => 'error',
+ 'error' => $error,
+ ];
+
+ if ($message !== null) {
+ $payload['message'] = $message;
+ }
+
+ return new JSONResponse(
+ data: $payload,
+ statusCode: $status
+ );
+ }//end errorResponse()
}//end class
diff --git a/lib/Controller/HealthController.php b/lib/Controller/HealthController.php
index a6ecc1e8..2f721958 100644
--- a/lib/Controller/HealthController.php
+++ b/lib/Controller/HealthController.php
@@ -49,88 +49,86 @@
*
* @spec openspec/changes/adopt-apphost/specs/prometheus-metrics/spec.md — Requirement: Health Check Endpoint (REQ-PROM-007)
*/
-class HealthController extends Controller
-{
- /**
- * Constructor.
- *
- * @param string $appName This leaf's app id (`launchpad`).
- * @param IRequest $request The HTTP request.
- * @param object|null $manifestLoader OpenRegister's ManifestLoader, or null when
- * OpenRegister is unavailable. Untyped on
- * purpose: a parameter TYPE is also a
- * compile-time reference to a class that may
- * not exist.
- * @param object|null $executor OpenRegister's HealthCheckExecutor, or null.
- */
- public function __construct(
- string $appName,
- IRequest $request,
- private readonly ?object $manifestLoader=null,
- private readonly ?object $executor=null,
- ) {
- parent::__construct(appName: $appName, request: $request);
- }//end __construct()
+class HealthController extends Controller {
+ /**
+ * Constructor.
+ *
+ * @param string $appName This leaf's app id (`launchpad`).
+ * @param IRequest $request The HTTP request.
+ * @param object|null $manifestLoader OpenRegister's ManifestLoader, or null when
+ * OpenRegister is unavailable. Untyped on
+ * purpose: a parameter TYPE is also a
+ * compile-time reference to a class that may
+ * not exist.
+ * @param object|null $executor OpenRegister's HealthCheckExecutor, or null.
+ */
+ public function __construct(
+ string $appName,
+ IRequest $request,
+ private readonly ?object $manifestLoader = null,
+ private readonly ?object $executor = null,
+ ) {
+ parent::__construct(appName: $appName, request: $request);
+ }//end __construct()
- /**
- * GET /api/health — declarative health check (ADR-006), public.
- *
- * Reports `status: unavailable` with HTTP 503 when the engine is absent,
- * which is a meaningful answer for a monitoring probe: the app is reachable,
- * its declarative health engine is not.
- *
- * @return JSONResponse `{status, app, version, checks}`.
- *
- * @spec openspec/changes/adopt-apphost/specs/prometheus-metrics/spec.md — Requirement: Health Check Endpoint (REQ-PROM-007)
- */
- #[PublicPage]
- #[NoCSRFRequired]
- public function index(): JSONResponse
- {
- $appId = $this->appName;
+ /**
+ * GET /api/health — declarative health check (ADR-006), public.
+ *
+ * Reports `status: unavailable` with HTTP 503 when the engine is absent,
+ * which is a meaningful answer for a monitoring probe: the app is reachable,
+ * its declarative health engine is not.
+ *
+ * @return JSONResponse `{status, app, version, checks}`.
+ *
+ * @spec openspec/changes/adopt-apphost/specs/prometheus-metrics/spec.md — Requirement: Health Check Endpoint (REQ-PROM-007)
+ */
+ #[PublicPage]
+ #[NoCSRFRequired]
+ public function index(): JSONResponse {
+ $appId = $this->appName;
- if ($this->manifestLoader === null || $this->executor === null) {
- return new JSONResponse(
- [
- 'status' => 'unavailable',
- 'app' => $appId,
- 'error' => 'OpenRegister AppHost observability engine unavailable',
- 'checks' => [],
- ],
- Http::STATUS_SERVICE_UNAVAILABLE
- );
- }
+ if ($this->manifestLoader === null || $this->executor === null) {
+ return new JSONResponse(
+ [
+ 'status' => 'unavailable',
+ 'app' => $appId,
+ 'error' => 'OpenRegister AppHost observability engine unavailable',
+ 'checks' => [],
+ ],
+ Http::STATUS_SERVICE_UNAVAILABLE
+ );
+ }
- try {
- $manifest = $this->manifestLoader->load(appId: $appId);
- $result = $this->executor->execute(manifest: $manifest);
+ try {
+ $manifest = $this->manifestLoader->load(appId: $appId);
+ $result = $this->executor->execute(manifest: $manifest);
- $response = new JSONResponse(
- [
- 'status' => $result->status,
- 'app' => $appId,
- 'version' => $this->manifestLoader->appVersion(appId: $appId),
- 'checks' => $result->checks,
- ],
- $result->httpStatusCode
- );
+ $response = new JSONResponse(
+ [
+ 'status' => $result->status,
+ 'app' => $appId,
+ 'version' => $this->manifestLoader->appVersion(appId: $appId),
+ 'checks' => $result->checks,
+ ],
+ $result->httpStatusCode
+ );
- if ($manifest->cors === true) {
- $response->addHeader('Access-Control-Allow-Origin', '*');
- $response->addHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
- }
+ if ($manifest->cors === true) {
+ $response->addHeader('Access-Control-Allow-Origin', '*');
+ $response->addHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
+ }
- return $response;
- } catch (Throwable $e) {
- return new JSONResponse(
- [
- 'status' => 'unavailable',
- 'app' => $appId,
- 'error' => $e->getMessage(),
- 'checks' => [],
- ],
- Http::STATUS_SERVICE_UNAVAILABLE
- );
- }//end try
- }//end index()
+ return $response;
+ } catch (Throwable $e) {
+ return new JSONResponse(
+ [
+ 'status' => 'unavailable',
+ 'app' => $appId,
+ 'error' => $e->getMessage(),
+ 'checks' => [],
+ ],
+ Http::STATUS_SERVICE_UNAVAILABLE
+ );
+ }//end try
+ }//end index()
}//end class
diff --git a/lib/Controller/HealthPingController.php b/lib/Controller/HealthPingController.php
index 033a0b7a..afdbc393 100644
--- a/lib/Controller/HealthPingController.php
+++ b/lib/Controller/HealthPingController.php
@@ -51,140 +51,136 @@
*
* @spec openspec/specs/service-health-ping/spec.md
*/
-class HealthPingController extends Controller
-{
- /**
- * Constructor.
- *
- * @param IRequest $request HTTP request.
- * @param HealthPingService $healthPingService Resolves + caches + validates health-ping badges.
- * @param PermissionService $permissionService Dashboard/placement permission gate.
- * @param IUserSession $userSession Session accessor.
- * @param LoggerInterface $logger PSR logger.
- */
- public function __construct(
- IRequest $request,
- private readonly HealthPingService $healthPingService,
- private readonly PermissionService $permissionService,
- private readonly IUserSession $userSession,
- private readonly LoggerInterface $logger,
- ) {
- parent::__construct(
- appName: Application::APP_ID,
- request: $request
- );
- }//end __construct()
-
- /**
- * `GET /api/health-ping/{placementId}`
- *
- * Returns the health badge for one placement. Returns 401 when
- * anonymous, 403 when the caller may not view the underlying
- * dashboard (REQ-HPING-003 "Caller authorization" — the ping is NEVER
- * performed in that case), 404 when the placement does not exist or
- * has no ping configured, else 200 with the badge (possibly
- * `stale: true`).
- *
- * @param integer $placementId The widget placement id.
- *
- * @return JSONResponse
- *
- * @spec openspec/specs/service-health-ping/spec.md
- */
- #[NoAdminRequired]
- #[NoCSRFRequired]
- public function show(int $placementId): JSONResponse
- {
- $userId = $this->resolveUserId();
- if ($userId === null) {
- return new JSONResponse(
- data: ['status' => 'error', 'error' => 'unauthorized'],
- statusCode: Http::STATUS_UNAUTHORIZED
- );
- }
-
- // REQ-HPING-003 "Caller authorization" — the auth guard runs
- // BEFORE any resolution/ping is attempted.
- if ($this->permissionService->canViewPlacement(userId: $userId, placementId: $placementId) === false) {
- return new JSONResponse(
- data: ['status' => 'error', 'error' => 'forbidden'],
- statusCode: Http::STATUS_FORBIDDEN
- );
- }
-
- try {
- $badge = $this->healthPingService->resolveForPlacement(placementId: $placementId);
- } catch (Throwable $exception) {
- $this->logger->error(
- message: 'Unexpected health-ping resolution failure',
- context: ['app' => Application::APP_ID, 'exception' => $exception->getMessage()]
- );
- return new JSONResponse(
- data: ['status' => 'error', 'error' => 'unknown_error'],
- statusCode: Http::STATUS_INTERNAL_SERVER_ERROR
- );
- }
-
- if (isset($badge['error']) === true) {
- return new JSONResponse(
- data: ['status' => 'error', 'error' => $badge['error']],
- statusCode: Http::STATUS_NOT_FOUND
- );
- }
-
- return new JSONResponse(
- data: $badge,
- statusCode: Http::STATUS_OK
- );
- }//end show()
-
- /**
- * `POST /api/health-ping/validate`
- *
- * Validates a candidate health-ping config before the author saves
- * the placement (REQ-HPING-001 "rejected at save time" — host
- * allow-list, fail-closed). Performs NO ping — only the allow-list
- * check that `resolveForPlacement()` would apply.
- *
- * @return JSONResponse `{valid: bool, errors: string[]}`.
- *
- * @spec openspec/specs/service-health-ping/spec.md
- */
- #[NoAdminRequired]
- public function validate(): JSONResponse
- {
- if ($this->resolveUserId() === null) {
- return new JSONResponse(
- data: ['status' => 'error', 'error' => 'unauthorized'],
- statusCode: Http::STATUS_UNAUTHORIZED
- );
- }
-
- $config = $this->request->getParam(key: 'config');
- if (is_array(value: $config) === false) {
- $config = [];
- }
-
- $errors = $this->healthPingService->validateConfig(config: $config);
-
- return new JSONResponse(
- data: ['valid' => ($errors === []), 'errors' => $errors],
- statusCode: Http::STATUS_OK
- );
- }//end validate()
-
- /**
- * Resolve the active user's UID, or `null` for anonymous.
- *
- * @return string|null
- */
- private function resolveUserId(): ?string
- {
- $user = $this->userSession->getUser();
- if ($user === null) {
- return null;
- }
-
- return $user->getUID();
- }//end resolveUserId()
+class HealthPingController extends Controller {
+ /**
+ * Constructor.
+ *
+ * @param IRequest $request HTTP request.
+ * @param HealthPingService $healthPingService Resolves + caches + validates health-ping badges.
+ * @param PermissionService $permissionService Dashboard/placement permission gate.
+ * @param IUserSession $userSession Session accessor.
+ * @param LoggerInterface $logger PSR logger.
+ */
+ public function __construct(
+ IRequest $request,
+ private readonly HealthPingService $healthPingService,
+ private readonly PermissionService $permissionService,
+ private readonly IUserSession $userSession,
+ private readonly LoggerInterface $logger,
+ ) {
+ parent::__construct(
+ appName: Application::APP_ID,
+ request: $request
+ );
+ }//end __construct()
+
+ /**
+ * `GET /api/health-ping/{placementId}`
+ *
+ * Returns the health badge for one placement. Returns 401 when
+ * anonymous, 403 when the caller may not view the underlying
+ * dashboard (REQ-HPING-003 "Caller authorization" — the ping is NEVER
+ * performed in that case), 404 when the placement does not exist or
+ * has no ping configured, else 200 with the badge (possibly
+ * `stale: true`).
+ *
+ * @param integer $placementId The widget placement id.
+ *
+ * @return JSONResponse
+ *
+ * @spec openspec/specs/service-health-ping/spec.md
+ */
+ #[NoAdminRequired]
+ #[NoCSRFRequired]
+ public function show(int $placementId): JSONResponse {
+ $userId = $this->resolveUserId();
+ if ($userId === null) {
+ return new JSONResponse(
+ data: ['status' => 'error', 'error' => 'unauthorized'],
+ statusCode: Http::STATUS_UNAUTHORIZED
+ );
+ }
+
+ // REQ-HPING-003 "Caller authorization" — the auth guard runs
+ // BEFORE any resolution/ping is attempted.
+ if ($this->permissionService->canViewPlacement(userId: $userId, placementId: $placementId) === false) {
+ return new JSONResponse(
+ data: ['status' => 'error', 'error' => 'forbidden'],
+ statusCode: Http::STATUS_FORBIDDEN
+ );
+ }
+
+ try {
+ $badge = $this->healthPingService->resolveForPlacement(placementId: $placementId);
+ } catch (Throwable $exception) {
+ $this->logger->error(
+ message: 'Unexpected health-ping resolution failure',
+ context: ['app' => Application::APP_ID, 'exception' => $exception->getMessage()]
+ );
+ return new JSONResponse(
+ data: ['status' => 'error', 'error' => 'unknown_error'],
+ statusCode: Http::STATUS_INTERNAL_SERVER_ERROR
+ );
+ }
+
+ if (isset($badge['error']) === true) {
+ return new JSONResponse(
+ data: ['status' => 'error', 'error' => $badge['error']],
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ }
+
+ return new JSONResponse(
+ data: $badge,
+ statusCode: Http::STATUS_OK
+ );
+ }//end show()
+
+ /**
+ * `POST /api/health-ping/validate`
+ *
+ * Validates a candidate health-ping config before the author saves
+ * the placement (REQ-HPING-001 "rejected at save time" — host
+ * allow-list, fail-closed). Performs NO ping — only the allow-list
+ * check that `resolveForPlacement()` would apply.
+ *
+ * @return JSONResponse `{valid: bool, errors: string[]}`.
+ *
+ * @spec openspec/specs/service-health-ping/spec.md
+ */
+ #[NoAdminRequired]
+ public function validate(): JSONResponse {
+ if ($this->resolveUserId() === null) {
+ return new JSONResponse(
+ data: ['status' => 'error', 'error' => 'unauthorized'],
+ statusCode: Http::STATUS_UNAUTHORIZED
+ );
+ }
+
+ $config = $this->request->getParam(key: 'config');
+ if (is_array(value: $config) === false) {
+ $config = [];
+ }
+
+ $errors = $this->healthPingService->validateConfig(config: $config);
+
+ return new JSONResponse(
+ data: ['valid' => ($errors === []), 'errors' => $errors],
+ statusCode: Http::STATUS_OK
+ );
+ }//end validate()
+
+ /**
+ * Resolve the active user's UID, or `null` for anonymous.
+ *
+ * @return string|null
+ */
+ private function resolveUserId(): ?string {
+ $user = $this->userSession->getUser();
+ if ($user === null) {
+ return null;
+ }
+
+ return $user->getUID();
+ }//end resolveUserId()
}//end class
diff --git a/lib/Controller/IframeController.php b/lib/Controller/IframeController.php
index 36d58e09..5b96a05b 100644
--- a/lib/Controller/IframeController.php
+++ b/lib/Controller/IframeController.php
@@ -46,112 +46,108 @@
*
* @spec openspec/specs/iframe-embed-widget/spec.md
*/
-class IframeController extends Controller
-{
- /**
- * Constructor.
- *
- * @param IRequest $request HTTP request.
- * @param IframeService $iframeService Allow-list validation + sandbox sanitisation.
- * @param IUserSession $userSession Session accessor.
- */
- public function __construct(
- IRequest $request,
- private readonly IframeService $iframeService,
- private readonly IUserSession $userSession,
- ) {
- parent::__construct(
- appName: Application::APP_ID,
- request: $request
- );
- }//end __construct()
+class IframeController extends Controller {
+ /**
+ * Constructor.
+ *
+ * @param IRequest $request HTTP request.
+ * @param IframeService $iframeService Allow-list validation + sandbox sanitisation.
+ * @param IUserSession $userSession Session accessor.
+ */
+ public function __construct(
+ IRequest $request,
+ private readonly IframeService $iframeService,
+ private readonly IUserSession $userSession,
+ ) {
+ parent::__construct(
+ appName: Application::APP_ID,
+ request: $request
+ );
+ }//end __construct()
- /**
- * `POST /api/iframe/validate-url`
- *
- * Validates a candidate iframe config before the author saves the
- * placement (REQ-IFRAME-002 "rejected at save time" — host allow-list,
- * fail-closed). Requires only an authenticated caller — the allow-list
- * itself is admin-controlled, not per-user.
- *
- * @return JSONResponse `{valid: bool, errors: string[]}`.
- *
- * @spec openspec/specs/iframe-embed-widget/spec.md
- */
- #[NoAdminRequired]
- public function validateUrl(): JSONResponse
- {
- if ($this->resolveUserId() === null) {
- return new JSONResponse(
- data: ['status' => 'error', 'error' => 'unauthorized'],
- statusCode: Http::STATUS_UNAUTHORIZED
- );
- }
+ /**
+ * `POST /api/iframe/validate-url`
+ *
+ * Validates a candidate iframe config before the author saves the
+ * placement (REQ-IFRAME-002 "rejected at save time" — host allow-list,
+ * fail-closed). Requires only an authenticated caller — the allow-list
+ * itself is admin-controlled, not per-user.
+ *
+ * @return JSONResponse `{valid: bool, errors: string[]}`.
+ *
+ * @spec openspec/specs/iframe-embed-widget/spec.md
+ */
+ #[NoAdminRequired]
+ public function validateUrl(): JSONResponse {
+ if ($this->resolveUserId() === null) {
+ return new JSONResponse(
+ data: ['status' => 'error', 'error' => 'unauthorized'],
+ statusCode: Http::STATUS_UNAUTHORIZED
+ );
+ }
- $config = $this->request->getParam(key: 'config');
- if (is_array(value: $config) === false) {
- $config = [];
- }
+ $config = $this->request->getParam(key: 'config');
+ if (is_array(value: $config) === false) {
+ $config = [];
+ }
- $errors = $this->iframeService->validateConfig(config: $config);
+ $errors = $this->iframeService->validateConfig(config: $config);
- return new JSONResponse(
- data: ['valid' => ($errors === []), 'errors' => $errors],
- statusCode: Http::STATUS_OK
- );
- }//end validateUrl()
+ return new JSONResponse(
+ data: ['valid' => ($errors === []), 'errors' => $errors],
+ statusCode: Http::STATUS_OK
+ );
+ }//end validateUrl()
- /**
- * `POST /api/iframe/framable`
- *
- * Server-side check of whether a URL may actually be framed
- * (REQ-IFRAME-003 "graceful degradation"). The browser cannot tell an
- * `X-Frame-Options: DENY` / `frame-ancestors 'none'` refusal apart from a
- * normal cross-origin embed, so the widget calls this on mount and shows
- * the fallback card up front when the target refuses framing, instead of
- * a permanently blank frame. Allow-list fail-closed; never leaks the
- * target's response body.
- *
- * @return JSONResponse `{framable: bool, reason: string}`.
- *
- * @spec openspec/specs/iframe-embed-widget/spec.md
- */
- #[NoAdminRequired]
- public function checkFramable(): JSONResponse
- {
- if ($this->resolveUserId() === null) {
- return new JSONResponse(
- data: ['status' => 'error', 'error' => 'unauthorized'],
- statusCode: Http::STATUS_UNAUTHORIZED
- );
- }
+ /**
+ * `POST /api/iframe/framable`
+ *
+ * Server-side check of whether a URL may actually be framed
+ * (REQ-IFRAME-003 "graceful degradation"). The browser cannot tell an
+ * `X-Frame-Options: DENY` / `frame-ancestors 'none'` refusal apart from a
+ * normal cross-origin embed, so the widget calls this on mount and shows
+ * the fallback card up front when the target refuses framing, instead of
+ * a permanently blank frame. Allow-list fail-closed; never leaks the
+ * target's response body.
+ *
+ * @return JSONResponse `{framable: bool, reason: string}`.
+ *
+ * @spec openspec/specs/iframe-embed-widget/spec.md
+ */
+ #[NoAdminRequired]
+ public function checkFramable(): JSONResponse {
+ if ($this->resolveUserId() === null) {
+ return new JSONResponse(
+ data: ['status' => 'error', 'error' => 'unauthorized'],
+ statusCode: Http::STATUS_UNAUTHORIZED
+ );
+ }
- $url = trim(string: (string) $this->request->getParam(key: 'url', default: ''));
- if ($url === '') {
- return new JSONResponse(
- data: ['framable' => false, 'reason' => 'url_required'],
- statusCode: Http::STATUS_OK
- );
- }
+ $url = trim(string: (string)$this->request->getParam(key: 'url', default: ''));
+ if ($url === '') {
+ return new JSONResponse(
+ data: ['framable' => false, 'reason' => 'url_required'],
+ statusCode: Http::STATUS_OK
+ );
+ }
- return new JSONResponse(
- data: $this->iframeService->checkFramable(url: $url),
- statusCode: Http::STATUS_OK
- );
- }//end checkFramable()
+ return new JSONResponse(
+ data: $this->iframeService->checkFramable(url: $url),
+ statusCode: Http::STATUS_OK
+ );
+ }//end checkFramable()
- /**
- * Resolve the active user's UID, or `null` for anonymous.
- *
- * @return string|null
- */
- private function resolveUserId(): ?string
- {
- $user = $this->userSession->getUser();
- if ($user === null) {
- return null;
- }
+ /**
+ * Resolve the active user's UID, or `null` for anonymous.
+ *
+ * @return string|null
+ */
+ private function resolveUserId(): ?string {
+ $user = $this->userSession->getUser();
+ if ($user === null) {
+ return null;
+ }
- return $user->getUID();
- }//end resolveUserId()
+ return $user->getUID();
+ }//end resolveUserId()
}//end class
diff --git a/lib/Controller/KioskController.php b/lib/Controller/KioskController.php
index 96c3166e..a2fb497b 100644
--- a/lib/Controller/KioskController.php
+++ b/lib/Controller/KioskController.php
@@ -50,267 +50,262 @@
*
* @spec openspec/changes/dashboard-kiosk-mode/tasks.md#task-4
*/
-class KioskController extends Controller
-{
- /**
- * Constructor.
- *
- * @param IRequest $request The incoming request.
- * @param KioskService $kioskService The kiosk-playlist service.
- * @param PublicShareContext $shareContext Request-scoped read-only bearer marker.
- * @param LoggerInterface $logger PSR-3 logger.
- * @param string|null $userId Authenticated user ID (null on public route).
- */
- public function __construct(
- IRequest $request,
- private readonly KioskService $kioskService,
- private readonly PublicShareContext $shareContext,
- private readonly LoggerInterface $logger,
- private readonly ?string $userId,
- ) {
- parent::__construct(
- appName: Application::APP_ID,
- request: $request
- );
- }//end __construct()
+class KioskController extends Controller {
+ /**
+ * Constructor.
+ *
+ * @param IRequest $request The incoming request.
+ * @param KioskService $kioskService The kiosk-playlist service.
+ * @param PublicShareContext $shareContext Request-scoped read-only bearer marker.
+ * @param LoggerInterface $logger PSR-3 logger.
+ * @param string|null $userId Authenticated user ID (null on public route).
+ */
+ public function __construct(
+ IRequest $request,
+ private readonly KioskService $kioskService,
+ private readonly PublicShareContext $shareContext,
+ private readonly LoggerInterface $logger,
+ private readonly ?string $userId,
+ ) {
+ parent::__construct(
+ appName: Application::APP_ID,
+ request: $request
+ );
+ }//end __construct()
- /**
- * Create a kiosk playlist.
- *
- * Owner-or-admin per referenced dashboard (REQ-KIOSK-002).
- *
- * @param string|null $name Playlist name.
- * @param array|null $entries Entries [{dashboardUuid, dwellSeconds}, ...].
- * @param int|null $refreshSeconds Requested refresh interval.
- *
- * @return DataResponse HTTP 201 with playlist payload, 403, or 401.
- *
- * @spec openspec/changes/dashboard-kiosk-mode/tasks.md#task-4
- */
- #[NoAdminRequired]
- public function create(
- ?string $name=null,
- ?array $entries=null,
- ?int $refreshSeconds=null
- ): DataResponse {
- if ($this->userId === null) {
- return new DataResponse(
- data: ['error' => 'Not logged in'],
- statusCode: Http::STATUS_UNAUTHORIZED
- );
- }
+ /**
+ * Create a kiosk playlist.
+ *
+ * Owner-or-admin per referenced dashboard (REQ-KIOSK-002).
+ *
+ * @param string|null $name Playlist name.
+ * @param array|null $entries Entries [{dashboardUuid, dwellSeconds}, ...].
+ * @param int|null $refreshSeconds Requested refresh interval.
+ *
+ * @return DataResponse HTTP 201 with playlist payload, 403, or 401.
+ *
+ * @spec openspec/changes/dashboard-kiosk-mode/tasks.md#task-4
+ */
+ #[NoAdminRequired]
+ public function create(
+ ?string $name = null,
+ ?array $entries = null,
+ ?int $refreshSeconds = null,
+ ): DataResponse {
+ if ($this->userId === null) {
+ return new DataResponse(
+ data: ['error' => 'Not logged in'],
+ statusCode: Http::STATUS_UNAUTHORIZED
+ );
+ }
- try {
- $playlist = $this->kioskService->createPlaylist(
- name: (string) ($name ?? ''),
- entries: ($entries ?? []),
- refresh: (int) ($refreshSeconds ?? KioskService::REFRESH_DEFAULT),
- callerId: $this->userId
- );
- return new DataResponse(
- data: $playlist->jsonSerialize(),
- statusCode: Http::STATUS_CREATED
- );
- } catch (OCSForbiddenException) {
- return new DataResponse(
- data: ['error' => 'Not authorized'],
- statusCode: Http::STATUS_FORBIDDEN
- );
- } catch (Exception $e) {
- $this->logError(message: $e->getMessage());
- return new DataResponse(
- data: ['error' => 'Internal error'],
- statusCode: Http::STATUS_INTERNAL_SERVER_ERROR
- );
- }//end try
- }//end create()
+ try {
+ $playlist = $this->kioskService->createPlaylist(
+ name: (string)($name ?? ''),
+ entries: ($entries ?? []),
+ refresh: (int)($refreshSeconds ?? KioskService::REFRESH_DEFAULT),
+ callerId: $this->userId
+ );
+ return new DataResponse(
+ data: $playlist->jsonSerialize(),
+ statusCode: Http::STATUS_CREATED
+ );
+ } catch (OCSForbiddenException) {
+ return new DataResponse(
+ data: ['error' => 'Not authorized'],
+ statusCode: Http::STATUS_FORBIDDEN
+ );
+ } catch (Exception $e) {
+ $this->logError(message: $e->getMessage());
+ return new DataResponse(
+ data: ['error' => 'Internal error'],
+ statusCode: Http::STATUS_INTERNAL_SERVER_ERROR
+ );
+ }//end try
+ }//end create()
- /**
- * List playlists visible to the caller.
- *
- * Own playlists for users, all playlists for admins (REQ-KIOSK-002).
- *
- * @return DataResponse HTTP 200 array of playlists or 401.
- *
- * @spec openspec/changes/dashboard-kiosk-mode/tasks.md#task-4
- */
- #[NoAdminRequired]
- public function index(): DataResponse
- {
- if ($this->userId === null) {
- return new DataResponse(
- data: ['error' => 'Not logged in'],
- statusCode: Http::STATUS_UNAUTHORIZED
- );
- }
+ /**
+ * List playlists visible to the caller.
+ *
+ * Own playlists for users, all playlists for admins (REQ-KIOSK-002).
+ *
+ * @return DataResponse HTTP 200 array of playlists or 401.
+ *
+ * @spec openspec/changes/dashboard-kiosk-mode/tasks.md#task-4
+ */
+ #[NoAdminRequired]
+ public function index(): DataResponse {
+ if ($this->userId === null) {
+ return new DataResponse(
+ data: ['error' => 'Not logged in'],
+ statusCode: Http::STATUS_UNAUTHORIZED
+ );
+ }
- $playlists = $this->kioskService->listPlaylists(callerId: $this->userId);
- return new DataResponse(
- data: array_map(
- callback: static fn ($playlist) => $playlist->jsonSerialize(),
- array: $playlists
- )
- );
- }//end index()
+ $playlists = $this->kioskService->listPlaylists(callerId: $this->userId);
+ return new DataResponse(
+ data: array_map(
+ callback: static fn ($playlist) => $playlist->jsonSerialize(),
+ array: $playlists
+ )
+ );
+ }//end index()
- /**
- * Update a kiosk playlist.
- *
- * Owner-or-admin, re-validates every referenced dashboard (REQ-KIOSK-002).
- *
- * @param int $id Playlist primary key.
- * @param string|null $name Playlist name.
- * @param array|null $entries Entries [{dashboardUuid, dwellSeconds}, ...].
- * @param int|null $refreshSeconds Requested refresh interval.
- *
- * @return DataResponse HTTP 200 with playlist payload, 403, 404, or 401.
- *
- * @spec openspec/changes/dashboard-kiosk-mode/tasks.md#task-4
- */
- #[NoAdminRequired]
- public function update(
- int $id,
- ?string $name=null,
- ?array $entries=null,
- ?int $refreshSeconds=null
- ): DataResponse {
- if ($this->userId === null) {
- return new DataResponse(
- data: ['error' => 'Not logged in'],
- statusCode: Http::STATUS_UNAUTHORIZED
- );
- }
+ /**
+ * Update a kiosk playlist.
+ *
+ * Owner-or-admin, re-validates every referenced dashboard (REQ-KIOSK-002).
+ *
+ * @param int $id Playlist primary key.
+ * @param string|null $name Playlist name.
+ * @param array|null $entries Entries [{dashboardUuid, dwellSeconds}, ...].
+ * @param int|null $refreshSeconds Requested refresh interval.
+ *
+ * @return DataResponse HTTP 200 with playlist payload, 403, 404, or 401.
+ *
+ * @spec openspec/changes/dashboard-kiosk-mode/tasks.md#task-4
+ */
+ #[NoAdminRequired]
+ public function update(
+ int $id,
+ ?string $name = null,
+ ?array $entries = null,
+ ?int $refreshSeconds = null,
+ ): DataResponse {
+ if ($this->userId === null) {
+ return new DataResponse(
+ data: ['error' => 'Not logged in'],
+ statusCode: Http::STATUS_UNAUTHORIZED
+ );
+ }
- try {
- $playlist = $this->kioskService->updatePlaylist(
- id: $id,
- name: (string) ($name ?? ''),
- entries: ($entries ?? []),
- refresh: (int) ($refreshSeconds ?? KioskService::REFRESH_DEFAULT),
- callerId: $this->userId
- );
- return new DataResponse(data: $playlist->jsonSerialize());
- } catch (PlaylistNotFoundException) {
- return new DataResponse(
- data: ['error' => 'Not found'],
- statusCode: Http::STATUS_NOT_FOUND
- );
- } catch (OCSForbiddenException) {
- return new DataResponse(
- data: ['error' => 'Not authorized'],
- statusCode: Http::STATUS_FORBIDDEN
- );
- } catch (Exception $e) {
- $this->logError(message: $e->getMessage());
- return new DataResponse(
- data: ['error' => 'Internal error'],
- statusCode: Http::STATUS_INTERNAL_SERVER_ERROR
- );
- }//end try
- }//end update()
+ try {
+ $playlist = $this->kioskService->updatePlaylist(
+ id: $id,
+ name: (string)($name ?? ''),
+ entries: ($entries ?? []),
+ refresh: (int)($refreshSeconds ?? KioskService::REFRESH_DEFAULT),
+ callerId: $this->userId
+ );
+ return new DataResponse(data: $playlist->jsonSerialize());
+ } catch (PlaylistNotFoundException) {
+ return new DataResponse(
+ data: ['error' => 'Not found'],
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ } catch (OCSForbiddenException) {
+ return new DataResponse(
+ data: ['error' => 'Not authorized'],
+ statusCode: Http::STATUS_FORBIDDEN
+ );
+ } catch (Exception $e) {
+ $this->logError(message: $e->getMessage());
+ return new DataResponse(
+ data: ['error' => 'Internal error'],
+ statusCode: Http::STATUS_INTERNAL_SERVER_ERROR
+ );
+ }//end try
+ }//end update()
- /**
- * Soft-revoke a kiosk playlist.
- *
- * Owner-or-admin only, idempotent (REQ-KIOSK-002).
- *
- * @param int $id Playlist primary key.
- *
- * @return DataResponse HTTP 204, 403, 404, or 401.
- *
- * @spec openspec/changes/dashboard-kiosk-mode/tasks.md#task-4
- */
- #[NoAdminRequired]
- public function destroy(int $id): DataResponse
- {
- if ($this->userId === null) {
- return new DataResponse(
- data: ['error' => 'Not logged in'],
- statusCode: Http::STATUS_UNAUTHORIZED
- );
- }
+ /**
+ * Soft-revoke a kiosk playlist.
+ *
+ * Owner-or-admin only, idempotent (REQ-KIOSK-002).
+ *
+ * @param int $id Playlist primary key.
+ *
+ * @return DataResponse HTTP 204, 403, 404, or 401.
+ *
+ * @spec openspec/changes/dashboard-kiosk-mode/tasks.md#task-4
+ */
+ #[NoAdminRequired]
+ public function destroy(int $id): DataResponse {
+ if ($this->userId === null) {
+ return new DataResponse(
+ data: ['error' => 'Not logged in'],
+ statusCode: Http::STATUS_UNAUTHORIZED
+ );
+ }
- try {
- $this->kioskService->revokePlaylist(id: $id, callerId: $this->userId);
- return new DataResponse(data: [], statusCode: Http::STATUS_NO_CONTENT);
- } catch (PlaylistNotFoundException) {
- return new DataResponse(
- data: ['error' => 'Not found'],
- statusCode: Http::STATUS_NOT_FOUND
- );
- } catch (OCSForbiddenException) {
- return new DataResponse(
- data: ['error' => 'Not authorized'],
- statusCode: Http::STATUS_FORBIDDEN
- );
- } catch (Exception $e) {
- $this->logError(message: $e->getMessage());
- return new DataResponse(
- data: ['error' => 'Internal error'],
- statusCode: Http::STATUS_INTERNAL_SERVER_ERROR
- );
- }//end try
- }//end destroy()
+ try {
+ $this->kioskService->revokePlaylist(id: $id, callerId: $this->userId);
+ return new DataResponse(data: [], statusCode: Http::STATUS_NO_CONTENT);
+ } catch (PlaylistNotFoundException) {
+ return new DataResponse(
+ data: ['error' => 'Not found'],
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ } catch (OCSForbiddenException) {
+ return new DataResponse(
+ data: ['error' => 'Not authorized'],
+ statusCode: Http::STATUS_FORBIDDEN
+ );
+ } catch (Exception $e) {
+ $this->logError(message: $e->getMessage());
+ return new DataResponse(
+ data: ['error' => 'Internal error'],
+ statusCode: Http::STATUS_INTERNAL_SERVER_ERROR
+ );
+ }//end try
+ }//end destroy()
- /**
- * Anonymously render a kiosk playlist via its token.
- *
- * Returns the playlist descriptor and the read-only render payload for
- * every entry whose dashboard still exists. Unknown or revoked tokens
- * return HTTP 404 with an identical shape (no existence leak). Shares the
- * `launchpad_share_access` brute-force bucket with public-share renders.
- *
- * @param string $token The playlist token from the URL.
- *
- * @return DataResponse HTTP 200 render payload, 404 if invalid, 429 when throttled.
- *
- * @spec openspec/changes/dashboard-kiosk-mode/tasks.md#task-4
- */
- #[PublicPage]
- #[NoCSRFRequired]
- #[AnonRateLimit(limit: 60, period: 60)]
- #[BruteForceProtection(action: PublicShareService::ACTION_SHARE_ACCESS)]
- public function render(string $token): DataResponse
- {
- try {
- $result = $this->kioskService->renderPlaylist(token: $token);
+ /**
+ * Anonymously render a kiosk playlist via its token.
+ *
+ * Returns the playlist descriptor and the read-only render payload for
+ * every entry whose dashboard still exists. Unknown or revoked tokens
+ * return HTTP 404 with an identical shape (no existence leak). Shares the
+ * `launchpad_share_access` brute-force bucket with public-share renders.
+ *
+ * @param string $token The playlist token from the URL.
+ *
+ * @return DataResponse HTTP 200 render payload, 404 if invalid, 429 when throttled.
+ *
+ * @spec openspec/changes/dashboard-kiosk-mode/tasks.md#task-4
+ */
+ #[PublicPage]
+ #[NoCSRFRequired]
+ #[AnonRateLimit(limit: 60, period: 60)]
+ #[BruteForceProtection(action: PublicShareService::ACTION_SHARE_ACCESS)]
+ public function render(string $token): DataResponse {
+ try {
+ $result = $this->kioskService->renderPlaylist(token: $token);
- // Mark the request as a read-only bearer so any mutation service
- // touched during render-payload hydration trips
- // ShareReadOnlyException, mirroring public-share REQ-PSHR-006.
- $this->shareContext->markBearer(token: $token);
+ // Mark the request as a read-only bearer so any mutation service
+ // touched during render-payload hydration trips
+ // ShareReadOnlyException, mirroring public-share REQ-PSHR-006.
+ $this->shareContext->markBearer(token: $token);
- return new DataResponse(data: $result);
- } catch (PlaylistNotFoundException) {
- $response = new DataResponse(
- data: ['error' => 'Not found'],
- statusCode: Http::STATUS_NOT_FOUND
- );
- // Register a brute-force attempt on the shared bucket so token
- // scanning the kiosk route counts toward the same throttle.
- $response->throttle(['action' => PublicShareService::ACTION_SHARE_ACCESS]);
- return $response;
- } catch (Exception $e) {
- $this->logError(message: $e->getMessage());
- return new DataResponse(
- data: ['error' => 'Internal error'],
- statusCode: Http::STATUS_INTERNAL_SERVER_ERROR
- );
- }//end try
- }//end render()
+ return new DataResponse(data: $result);
+ } catch (PlaylistNotFoundException) {
+ $response = new DataResponse(
+ data: ['error' => 'Not found'],
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ // Register a brute-force attempt on the shared bucket so token
+ // scanning the kiosk route counts toward the same throttle.
+ $response->throttle(['action' => PublicShareService::ACTION_SHARE_ACCESS]);
+ return $response;
+ } catch (Exception $e) {
+ $this->logError(message: $e->getMessage());
+ return new DataResponse(
+ data: ['error' => 'Internal error'],
+ statusCode: Http::STATUS_INTERNAL_SERVER_ERROR
+ );
+ }//end try
+ }//end render()
- /**
- * Log a non-sensitive error message.
- *
- * @param string $message The error message.
- *
- * @return void
- */
- private function logError(string $message): void
- {
- $this->logger->warning(
- message: 'KioskController error: '.$message,
- context: ['app' => Application::APP_ID]
- );
- }//end logError()
+ /**
+ * Log a non-sensitive error message.
+ *
+ * @param string $message The error message.
+ *
+ * @return void
+ */
+ private function logError(string $message): void {
+ $this->logger->warning(
+ message: 'KioskController error: ' . $message,
+ context: ['app' => Application::APP_ID]
+ );
+ }//end logError()
}//end class
diff --git a/lib/Controller/LiveTileController.php b/lib/Controller/LiveTileController.php
index 98bce948..b9fba652 100644
--- a/lib/Controller/LiveTileController.php
+++ b/lib/Controller/LiveTileController.php
@@ -55,168 +55,163 @@
*
* @spec openspec/specs/live-data-tile-widget/spec.md
*/
-class LiveTileController extends Controller
-{
- /**
- * Constructor.
- *
- * @param IRequest $request HTTP request.
- * @param LiveTileService $liveTileService Resolves + caches + validates live-tile values.
- * @param PermissionService $permissionService Dashboard/placement permission gate.
- * @param IUserSession $userSession Session accessor.
- * @param LoggerInterface $logger PSR logger.
- */
- public function __construct(
- IRequest $request,
- private readonly LiveTileService $liveTileService,
- private readonly PermissionService $permissionService,
- private readonly IUserSession $userSession,
- private readonly LoggerInterface $logger,
- ) {
- parent::__construct(
- appName: Application::APP_ID,
- request: $request
- );
- }//end __construct()
-
- /**
- * `GET /api/livetile/{placementId}`
- *
- * Returns the live-tile value for one placement. Returns 401 when
- * anonymous, 403 when the caller may not view the underlying
- * dashboard (REQ-LIVETILE-003 "Caller authorization" — the fetch is
- * NEVER performed in that case), 404 when the placement does not
- * exist, else 200 with the value (possibly `stale: true`).
- *
- * @param integer $placementId The widget placement id.
- *
- * @return JSONResponse
- *
- * @spec openspec/specs/live-data-tile-widget/spec.md
- */
- #[NoAdminRequired]
- #[NoCSRFRequired]
- public function show(int $placementId): JSONResponse
- {
- $userId = $this->resolveUserId();
- if ($userId === null) {
- return new JSONResponse(
- data: ['status' => 'error', 'error' => 'unauthorized'],
- statusCode: Http::STATUS_UNAUTHORIZED
- );
- }
-
- // REQ-LIVETILE-003 "Caller authorization" — the auth guard runs
- // BEFORE any resolution/fetch is attempted.
- if ($this->permissionService->canViewPlacement(userId: $userId, placementId: $placementId) === false) {
- return new JSONResponse(
- data: ['status' => 'error', 'error' => 'forbidden'],
- statusCode: Http::STATUS_FORBIDDEN
- );
- }
-
- try {
- $reading = $this->liveTileService->resolveForPlacement(placementId: $placementId);
- } catch (Throwable $exception) {
- $this->logger->error(
- message: 'Unexpected live-tile resolution failure',
- context: ['app' => Application::APP_ID, 'exception' => $exception->getMessage()]
- );
- return new JSONResponse(
- data: ['status' => 'error', 'error' => 'unknown_error'],
- statusCode: Http::STATUS_INTERNAL_SERVER_ERROR
- );
- }
-
- if (isset($reading['error']) === true) {
- return new JSONResponse(
- data: ['status' => 'error', 'error' => $reading['error']],
- statusCode: Http::STATUS_NOT_FOUND
- );
- }
-
- return new JSONResponse(
- data: $reading,
- statusCode: Http::STATUS_OK
- );
- }//end show()
-
- /**
- * `GET /api/livetile/connector/status`
- *
- * Reports whether the OpenConnector `dashboard-http-datasource`
- * capability is currently available, so the config form can hide or
- * disable `connector` source mode (REQ-LIVETILE-005). Requires only
- * an authenticated caller — carries no placement-specific data.
- *
- * @return JSONResponse `{available: bool}`.
- *
- * @spec openspec/specs/live-data-tile-widget/spec.md
- */
- #[NoAdminRequired]
- #[NoCSRFRequired]
- public function connectorStatus(): JSONResponse
- {
- if ($this->resolveUserId() === null) {
- return new JSONResponse(
- data: ['status' => 'error', 'error' => 'unauthorized'],
- statusCode: Http::STATUS_UNAUTHORIZED
- );
- }
-
- return new JSONResponse(
- data: ['available' => $this->liveTileService->isConnectorAvailable()],
- statusCode: Http::STATUS_OK
- );
- }//end connectorStatus()
-
- /**
- * `POST /api/livetile/validate-source`
- *
- * Validates a candidate source config before the author saves the
- * placement (REQ-LIVETILE-002 "rejected at save time" — host
- * allow-list, fail-closed). Performs NO fetch — only the allow-list
- * / capability-probe checks that `resolveForPlacement()` would apply.
- *
- * @return JSONResponse `{valid: bool, errors: string[]}`.
- *
- * @spec openspec/specs/live-data-tile-widget/spec.md
- */
- #[NoAdminRequired]
- public function validateSource(): JSONResponse
- {
- if ($this->resolveUserId() === null) {
- return new JSONResponse(
- data: ['status' => 'error', 'error' => 'unauthorized'],
- statusCode: Http::STATUS_UNAUTHORIZED
- );
- }
-
- $config = $this->request->getParam(key: 'config');
- if (is_array(value: $config) === false) {
- $config = [];
- }
-
- $errors = $this->liveTileService->validateSourceConfig(config: $config);
-
- return new JSONResponse(
- data: ['valid' => ($errors === []), 'errors' => $errors],
- statusCode: Http::STATUS_OK
- );
- }//end validateSource()
-
- /**
- * Resolve the active user's UID, or `null` for anonymous.
- *
- * @return string|null
- */
- private function resolveUserId(): ?string
- {
- $user = $this->userSession->getUser();
- if ($user === null) {
- return null;
- }
-
- return $user->getUID();
- }//end resolveUserId()
+class LiveTileController extends Controller {
+ /**
+ * Constructor.
+ *
+ * @param IRequest $request HTTP request.
+ * @param LiveTileService $liveTileService Resolves + caches + validates live-tile values.
+ * @param PermissionService $permissionService Dashboard/placement permission gate.
+ * @param IUserSession $userSession Session accessor.
+ * @param LoggerInterface $logger PSR logger.
+ */
+ public function __construct(
+ IRequest $request,
+ private readonly LiveTileService $liveTileService,
+ private readonly PermissionService $permissionService,
+ private readonly IUserSession $userSession,
+ private readonly LoggerInterface $logger,
+ ) {
+ parent::__construct(
+ appName: Application::APP_ID,
+ request: $request
+ );
+ }//end __construct()
+
+ /**
+ * `GET /api/livetile/{placementId}`
+ *
+ * Returns the live-tile value for one placement. Returns 401 when
+ * anonymous, 403 when the caller may not view the underlying
+ * dashboard (REQ-LIVETILE-003 "Caller authorization" — the fetch is
+ * NEVER performed in that case), 404 when the placement does not
+ * exist, else 200 with the value (possibly `stale: true`).
+ *
+ * @param integer $placementId The widget placement id.
+ *
+ * @return JSONResponse
+ *
+ * @spec openspec/specs/live-data-tile-widget/spec.md
+ */
+ #[NoAdminRequired]
+ #[NoCSRFRequired]
+ public function show(int $placementId): JSONResponse {
+ $userId = $this->resolveUserId();
+ if ($userId === null) {
+ return new JSONResponse(
+ data: ['status' => 'error', 'error' => 'unauthorized'],
+ statusCode: Http::STATUS_UNAUTHORIZED
+ );
+ }
+
+ // REQ-LIVETILE-003 "Caller authorization" — the auth guard runs
+ // BEFORE any resolution/fetch is attempted.
+ if ($this->permissionService->canViewPlacement(userId: $userId, placementId: $placementId) === false) {
+ return new JSONResponse(
+ data: ['status' => 'error', 'error' => 'forbidden'],
+ statusCode: Http::STATUS_FORBIDDEN
+ );
+ }
+
+ try {
+ $reading = $this->liveTileService->resolveForPlacement(placementId: $placementId);
+ } catch (Throwable $exception) {
+ $this->logger->error(
+ message: 'Unexpected live-tile resolution failure',
+ context: ['app' => Application::APP_ID, 'exception' => $exception->getMessage()]
+ );
+ return new JSONResponse(
+ data: ['status' => 'error', 'error' => 'unknown_error'],
+ statusCode: Http::STATUS_INTERNAL_SERVER_ERROR
+ );
+ }
+
+ if (isset($reading['error']) === true) {
+ return new JSONResponse(
+ data: ['status' => 'error', 'error' => $reading['error']],
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ }
+
+ return new JSONResponse(
+ data: $reading,
+ statusCode: Http::STATUS_OK
+ );
+ }//end show()
+
+ /**
+ * `GET /api/livetile/connector/status`
+ *
+ * Reports whether the OpenConnector `dashboard-http-datasource`
+ * capability is currently available, so the config form can hide or
+ * disable `connector` source mode (REQ-LIVETILE-005). Requires only
+ * an authenticated caller — carries no placement-specific data.
+ *
+ * @return JSONResponse `{available: bool}`.
+ *
+ * @spec openspec/specs/live-data-tile-widget/spec.md
+ */
+ #[NoAdminRequired]
+ #[NoCSRFRequired]
+ public function connectorStatus(): JSONResponse {
+ if ($this->resolveUserId() === null) {
+ return new JSONResponse(
+ data: ['status' => 'error', 'error' => 'unauthorized'],
+ statusCode: Http::STATUS_UNAUTHORIZED
+ );
+ }
+
+ return new JSONResponse(
+ data: ['available' => $this->liveTileService->isConnectorAvailable()],
+ statusCode: Http::STATUS_OK
+ );
+ }//end connectorStatus()
+
+ /**
+ * `POST /api/livetile/validate-source`
+ *
+ * Validates a candidate source config before the author saves the
+ * placement (REQ-LIVETILE-002 "rejected at save time" — host
+ * allow-list, fail-closed). Performs NO fetch — only the allow-list
+ * / capability-probe checks that `resolveForPlacement()` would apply.
+ *
+ * @return JSONResponse `{valid: bool, errors: string[]}`.
+ *
+ * @spec openspec/specs/live-data-tile-widget/spec.md
+ */
+ #[NoAdminRequired]
+ public function validateSource(): JSONResponse {
+ if ($this->resolveUserId() === null) {
+ return new JSONResponse(
+ data: ['status' => 'error', 'error' => 'unauthorized'],
+ statusCode: Http::STATUS_UNAUTHORIZED
+ );
+ }
+
+ $config = $this->request->getParam(key: 'config');
+ if (is_array(value: $config) === false) {
+ $config = [];
+ }
+
+ $errors = $this->liveTileService->validateSourceConfig(config: $config);
+
+ return new JSONResponse(
+ data: ['valid' => ($errors === []), 'errors' => $errors],
+ statusCode: Http::STATUS_OK
+ );
+ }//end validateSource()
+
+ /**
+ * Resolve the active user's UID, or `null` for anonymous.
+ *
+ * @return string|null
+ */
+ private function resolveUserId(): ?string {
+ $user = $this->userSession->getUser();
+ if ($user === null) {
+ return null;
+ }
+
+ return $user->getUID();
+ }//end resolveUserId()
}//end class
diff --git a/lib/Controller/ManifestController.php b/lib/Controller/ManifestController.php
index abda58eb..1160278c 100644
--- a/lib/Controller/ManifestController.php
+++ b/lib/Controller/ManifestController.php
@@ -49,473 +49,463 @@
* rather than the individual manifest contributors precisely to keep this
* count from growing as sections are added.
*/
-class ManifestController extends Controller
-{
- /**
- * OpenRegister register slug for launchpad dashboards.
- *
- * @var string
- */
- private const REGISTER = 'launchpad';
-
- /**
- * OpenRegister schema slug for dashboard objects.
- *
- * @var string
- */
- private const SCHEMA = 'dashboard';
-
- /**
- * V2 manifest schema URL.
- *
- * @var string
- */
- private const SCHEMA_URL = 'https://raw.githubusercontent.com/ConductionNL/nextcloud-vue/main/src/schemas/app-manifest-v2.schema.json';
-
- /**
- * Constructor.
- *
- * @param IRequest $request The HTTP request.
- * @param ContainerInterface $container The Nextcloud DI container; used to
- * lazy-load ObjectService so that launchpad
- * degrades gracefully when OpenRegister
- * is not yet active.
- * @param ActionAuthService $actionAuth ADR-023 action authorization.
- * @param IUserSession $userSession User session (IUser resolution).
- * @param LoggerInterface $logger PSR logger.
- * @param string|null $userId The authenticated user ID, injected
- * by the DI container.
- */
- public function __construct(
- IRequest $request,
- private readonly ContainerInterface $container,
- private readonly ActionAuthService $actionAuth,
- private readonly IUserSession $userSession,
- private readonly LoggerInterface $logger,
- private readonly ?string $userId,
- ) {
- parent::__construct(
- appName: Application::APP_ID,
- request: $request
- );
- }//end __construct()
-
- /**
- * Build and return the v2 app manifest for the authenticated user.
- *
- * Reads the user's dashboard objects from OpenRegister. Each object with
- * a `slug` and `title` property becomes one page entry and one menu entry.
- * Objects the user owns, plus objects explicitly granted to them via
- * OpenRegister's per-object sharing primitive, are included.
- *
- * Route: GET /apps/launchpad/api/manifest
- *
- * @return JSONResponse A JSON document conforming to the v2 manifest
- * schema. Returns HTTP 401 when no user is
- * authenticated, HTTP 503 when OpenRegister is
- * unavailable.
- *
- * @spec manifest-v2-runtime:REQ-MVR-001
- * @spec openspec/specs/runtime-shell/spec.md
- */
- #[NoAdminRequired]
- #[NoCSRFRequired]
- public function index(): JSONResponse
- {
- if ($this->userId === null) {
- return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED);
- }
-
- $user = $this->userSession->getUser();
- if ($user === null) {
- return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED);
- }
-
- try {
- $this->actionAuth->requireAction($user, 'manifest.index');
- } catch (OCSForbiddenException) {
- return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN);
- }
-
- // Retrieve ObjectService lazily — OpenRegister may not be enabled on
- // every instance. Returning an empty manifest (not an error) lets the
- // frontend render its "no dashboards yet" CTA without a red alert.
- try {
- /*
- * @var \OCA\OpenRegister\Service\ObjectService $objectService
- */
-
- $objectService = $this->container->get('OCA\OpenRegister\Service\ObjectService');
- } catch (\Throwable $e) {
- $this->logger->warning(
- 'LaunchPad: OpenRegister ObjectService unavailable — returning empty manifest. '.$e->getMessage(),
- ['app' => Application::APP_ID]
- );
-
- return new JSONResponse($this->buildManifest(dashboards: [], userId: $this->userId));
- }//end try
-
- // Fetch all dashboard objects owned by or shared with the current user.
- $dashboards = $this->fetchUserDashboards(objectService: $objectService, userId: $this->userId);
-
- return new JSONResponse($this->buildManifest(dashboards: $dashboards, userId: $this->userId));
-
- }//end index()
-
- /**
- * Maximum number of granted dashboards folded into one manifest.
- *
- * A bound, not a policy: the grant lookup is cheap but the follow-up load is
- * one IN(...) query whose parameter list should not grow without limit. If a
- * user ever exceeds this, the manifest is truncated rather than slow — and
- * the truncation is logged rather than silent, so it cannot be mistaken for
- * "the user has no shared dashboards".
- *
- * @var int
- */
- private const MAX_GRANTED = 200;
-
- /**
- * Fetch dashboard objects from OpenRegister for the given user.
- *
- * C5 fix (REQ-MVR-001): replaces the non-existent `findObjects()` call
- * (which caused a BadMethodCallException silently swallowed by Throwable)
- * with the real `ObjectService::findAll()` API. The `owner` filter
- * constrains results to the calling user's records — without it every
- * user would receive the full dataset (latent IDOR on top of the API drift).
- *
- * Two sources, deduplicated: dashboards the user OWNS, and dashboards
- * explicitly GRANTED to them through OpenRegister's per-object sharing
- * primitive. The owner filter is deliberately kept on the first query rather
- * than replaced by "let RBAC decide" — see fetchGrantedDashboards() for why
- * the additive shape is the safe one.
- *
- * @param object $objectService The OpenRegister ObjectService instance.
- * @param string $userId The authenticated Nextcloud user ID.
- *
- * @return array> Flat list of dashboard data arrays.
- */
- private function fetchUserDashboards(object $objectService, string $userId): array
- {
- $dashboards = [];
- $seen = [];
-
- try {
- // C5 fix: use the real ObjectService::findAll() API.
- // `findObjects()` does not exist; the old call threw
- // BadMethodCallException that was silently swallowed, causing the
- // manifest to always return empty pages/menu arrays.
- //
- // The owner filter MUST be NESTED under `@self`. OpenRegister splits
- // filters into metadata filters (the magic table's `_`-prefixed
- // columns, addressed as a nested `@self` array) and property filters
- // (matched against the schema's own properties). A bare `owner` is
- // therefore read as a property filter on an `owner` property, which
- // the dashboard schema does not have — so it matched nothing and
- // this endpoint returned an EMPTY MANIFEST to every user, including
- // the owner of the dashboards.
- //
- // Measured on a live instance with admin owning two dashboards: no
- // owner filter returned 2; a bare `owner => admin` returned 0; a
- // DOTTED `@self.owner => admin` also returned 0; the nested
- // `@self => [owner => admin]` returned 2. The control was a nested
- // filter with a nonexistent user, which returned 0 — without it,
- // "nested returns 2" could not be distinguished from "the filter was
- // ignored, here is everything", which is the failure mode that
- // produced the always-empty manifest in the first place.
- $ownedResults = $objectService->findAll(
- config: [
- 'filters' => [
- 'register' => self::REGISTER,
- 'schema' => self::SCHEMA,
- '@self' => ['owner' => $userId],
- ],
- 'limit' => 500,
- ]
- );
-
- if (is_array($ownedResults) === true) {
- $this->foldInto(rows: $ownedResults, dashboards: $dashboards, seen: $seen, extract: true);
- }
- } catch (DoesNotExistException $e) {
- // The 'mydash' register or 'dashboard' schema has not been
- // provisioned in OpenRegister on this instance yet. That simply
- // means the user has no dashboards — degrade to an empty manifest
- // so the frontend renders its "no dashboards yet" CTA instead of a
- // 500 (OpenRegister surfaces this as DoesNotExistException, which
- // extends \Exception and so is not a RuntimeException).
- $this->logger->info(
- 'MyDash: OpenRegister register/schema not provisioned — returning empty manifest. '.$e->getMessage(),
- ['app' => Application::APP_ID, 'userId' => $userId]
- );
- } catch (\RuntimeException | \InvalidArgumentException $e) {
- // Narrow catch: only handle recoverable OR API errors. Let
- // unexpected errors propagate so they are visible in the logs.
- $this->logger->error(
- 'LaunchPad: failed to fetch dashboards from OpenRegister: '.$e->getMessage(),
- ['app' => Application::APP_ID, 'userId' => $userId]
- );
- }//end try
-
- // Second source: dashboards explicitly granted to this user. Additive,
- // and folded through the SAME $seen map so a dashboard the user both
- // owns and was granted appears once.
- $this->foldInto(
- rows: $this->fetchGrantedDashboards(objectService: $objectService, userId: $userId),
- dashboards: $dashboards,
- seen: $seen,
- extract: false
- );
-
- return $dashboards;
-
- }//end fetchUserDashboards()
-
- /**
- * Fold one source's rows into the accumulator, skipping ones already seen.
- *
- * Shared by both sources on purpose: the owned query and the grant query must
- * dedupe by the SAME identity rule, or a dashboard the user both owns and was
- * granted would appear twice in the manifest.
- *
- * @param array $rows Rows from one source.
- * @param array> $dashboards Accumulator, by reference.
- * @param array $seen Identity map, by reference.
- * @param bool $extract Whether the rows still
- * need extractData() —
- * the granted source has
- * already normalised
- * them.
- *
- * @return void
- */
- private function foldInto(array $rows, array &$dashboards, array &$seen, bool $extract): void
- {
- foreach ($rows as $row) {
- $data = $row;
- if ($extract === true) {
- $data = $this->extractData(item: $row);
- }
-
- if (is_array($data) === false || empty($data) === true) {
- continue;
- }
-
- $id = ($data['id'] ?? $data['uuid'] ?? $data['slug'] ?? null);
- if ($id !== null && isset($seen[$id]) === false) {
- $seen[$id] = true;
- $dashboards[] = $data;
- }
- }
-
- }//end foldInto()
-
- /**
- * Dashboard objects explicitly granted to this user, via OpenRegister.
- *
- * WHY THIS IS ADDITIVE, rather than "drop the owner filter and let RBAC
- * decide". Letting RBAC decide is the tidier design and it is what the
- * OpenRegister `private` scope exists for — but it is only safe once the
- * `dashboard` schema actually carries `scope: private`. A register-descriptor
- * change lands through a repair step on upgrade, so there is necessarily a
- * window (and, on any instance where that import did not apply, an
- * indefinite one) in which the schema is still unscoped. An unfiltered
- * findAll() against an unscoped schema returns EVERY user's dashboards. So
- * the owner filter stays, and grants only ever ADD rows. The failure mode of
- * this shape is a missing dashboard; the failure mode of the other is a
- * cross-tenant leak in the manifest.
- *
- * `read` is the verb, because appearing in someone's manifest is exactly a
- * read. The resolver answers only for the five core permission verbs and
- * refuses anything else, so this cannot silently widen.
- *
- * Fails soft and empty: OpenRegister may be present without the sharing
- * primitive (an older release), in which case the class is simply absent and
- * the manifest degrades to owned-only — the behaviour before this change.
- *
- * @param object $objectService The OpenRegister ObjectService instance.
- * @param string $userId The authenticated Nextcloud user ID.
- *
- * @return array> Granted dashboard data arrays.
- */
- private function fetchGrantedDashboards(object $objectService, string $userId): array
- {
- try {
- $grantResolver = $this->container->get('OCA\OpenRegister\Service\Rbac\ObjectGrantResolver');
- } catch (\Throwable $e) {
- // OpenRegister without the per-object sharing primitive. Not an
- // error: degrade to owned-only, which is the pre-existing behaviour.
- $this->logger->debug(
- 'LaunchPad: OpenRegister object-grant resolver unavailable — manifest is owned-only. '.$e->getMessage(),
- ['app' => Application::APP_ID]
- );
-
- return [];
- }
-
- try {
- // Keys, not values: the resolver returns uuid => permission
- // bitmask, so array_values() would yield the bitmasks.
- $grantedUuids = array_keys($grantResolver->grantedObjectUuidsFor($userId, 'read'));
- if (empty($grantedUuids) === true) {
- return [];
- }
-
- if (count($grantedUuids) > self::MAX_GRANTED) {
- // Logged, never silent: a truncated manifest must not be
- // indistinguishable from "nothing is shared with this user".
- $this->logger->warning(
- sprintf(
- 'LaunchPad: %d granted dashboards exceeds the %d cap — manifest truncated.',
- count($grantedUuids),
- self::MAX_GRANTED
- ),
- ['app' => Application::APP_ID, 'userId' => $userId]
- );
- $grantedUuids = array_slice($grantedUuids, 0, self::MAX_GRANTED);
- }
-
- // `ids` is a first-class config key that matches `_uuid` OR `_slug`.
- // A `filters['uuid']` entry would instead be read as a property
- // filter on a `uuid` property — the same trap that made the owner
- // query above return nothing.
- $results = $objectService->findAll(
- config: [
- 'filters' => [
- 'register' => self::REGISTER,
- 'schema' => self::SCHEMA,
- ],
- 'ids' => $grantedUuids,
- 'limit' => self::MAX_GRANTED,
- ]
- );
-
- if (is_array($results) === false) {
- return [];
- }
-
- $granted = [];
- foreach ($results as $item) {
- $data = $this->extractData(item: $item);
- if (empty($data) === false) {
- $granted[] = $data;
- }
- }
-
- return $granted;
- } catch (DoesNotExistException $e) {
- // Register/schema not provisioned — same benign case the owned
- // query already handles.
- return [];
- } catch (\RuntimeException | \InvalidArgumentException $e) {
- $this->logger->error(
- 'LaunchPad: failed to fetch granted dashboards from OpenRegister: '.$e->getMessage(),
- ['app' => Application::APP_ID, 'userId' => $userId]
- );
-
- return [];
- }//end try
-
- }//end fetchGrantedDashboards()
-
- /**
- * Normalise a raw ObjectService result item to a plain data array.
- *
- * OpenRegister items may be returned as objects with a `getObject()`
- * method or as plain associative arrays.
- *
- * @param mixed $item A single result from ObjectService::findAll().
- *
- * @return array The plain data array, or [] on failure.
- */
- private function extractData(mixed $item): array
- {
- if (is_array($item) === true) {
- return $item;
- }
-
- if (is_object($item) === true && method_exists($item, 'getObject') === true) {
- $data = $item->getObject();
- if (is_array($data) === true) {
- return $data;
- }
-
- return [];
- }
-
- if (is_object($item) === true && method_exists($item, 'jsonSerialize') === true) {
- $data = $item->jsonSerialize();
- if (is_array($data) === true) {
- return $data;
- }
-
- return [];
- }
-
- return [];
-
- }//end extractData()
-
- /**
- * Build the v2 manifest array from a list of dashboard data arrays.
- *
- * @param array> $dashboards Flat list of dashboard data.
- * @param string $userId The current user ID.
- *
- * @return array The v2 manifest document.
- */
- private function buildManifest(array $dashboards, string $userId): array
- {
- $pages = [];
- $menu = [];
- $order = 0;
-
- foreach ($dashboards as $data) {
- $slug = $data['slug'] ?? null;
- $title = $data['title'] ?? null;
-
- if (empty($slug) === true || empty($title) === true) {
- continue;
- }
-
- $pageId = 'dashboard-'.$slug;
-
- $pages[] = [
- 'id' => $pageId,
- 'route' => '/'.$slug,
- 'type' => 'dashboard',
- 'title' => $title,
- 'widgets' => $data['widgets'] ?? [],
- ];
-
- $menu[] = [
- 'id' => 'menu-'.$slug,
- 'label' => $title,
- 'route' => $pageId,
- 'order' => $order,
- // ADR-077 Tier A concept `dashboard`. These entries are
- // dashboards, so `icon-home` was both the wrong concept and a
- // legacy `icon-*` CSS class — which renders as an invisible
- // white glyph on NC34+ light themes. The name is registered in
- // src/icons.js; CnIcon has no fallback for one that is not.
- 'icon' => 'ViewDashboardOutline',
- ];
-
- $order++;
- }//end foreach
-
- return [
- '$schema' => self::SCHEMA_URL,
- 'version' => '1.0.0',
- 'dependencies' => ['openregister'],
- 'menu' => $menu,
- 'pages' => $pages,
- 'runtime' => [
- 'user' => [
- 'id' => $userId,
- ],
- ],
- ];
-
- }//end buildManifest()
+class ManifestController extends Controller {
+ /**
+ * OpenRegister register slug for launchpad dashboards.
+ *
+ * @var string
+ */
+ private const REGISTER = 'launchpad';
+
+ /**
+ * OpenRegister schema slug for dashboard objects.
+ *
+ * @var string
+ */
+ private const SCHEMA = 'dashboard';
+
+ /**
+ * V2 manifest schema URL.
+ *
+ * @var string
+ */
+ private const SCHEMA_URL = 'https://raw.githubusercontent.com/ConductionNL/nextcloud-vue/main/src/schemas/app-manifest-v2.schema.json';
+
+ /**
+ * Constructor.
+ *
+ * @param IRequest $request The HTTP request.
+ * @param ContainerInterface $container The Nextcloud DI container; used to
+ * lazy-load ObjectService so that launchpad
+ * degrades gracefully when OpenRegister
+ * is not yet active.
+ * @param ActionAuthService $actionAuth ADR-023 action authorization.
+ * @param IUserSession $userSession User session (IUser resolution).
+ * @param LoggerInterface $logger PSR logger.
+ * @param string|null $userId The authenticated user ID, injected
+ * by the DI container.
+ */
+ public function __construct(
+ IRequest $request,
+ private readonly ContainerInterface $container,
+ private readonly ActionAuthService $actionAuth,
+ private readonly IUserSession $userSession,
+ private readonly LoggerInterface $logger,
+ private readonly ?string $userId,
+ ) {
+ parent::__construct(
+ appName: Application::APP_ID,
+ request: $request
+ );
+ }//end __construct()
+
+ /**
+ * Build and return the v2 app manifest for the authenticated user.
+ *
+ * Reads the user's dashboard objects from OpenRegister. Each object with
+ * a `slug` and `title` property becomes one page entry and one menu entry.
+ * Objects the user owns, plus objects explicitly granted to them via
+ * OpenRegister's per-object sharing primitive, are included.
+ *
+ * Route: GET /apps/launchpad/api/manifest
+ *
+ * @return JSONResponse A JSON document conforming to the v2 manifest
+ * schema. Returns HTTP 401 when no user is
+ * authenticated, HTTP 503 when OpenRegister is
+ * unavailable.
+ *
+ * @spec manifest-v2-runtime:REQ-MVR-001
+ * @spec openspec/specs/runtime-shell/spec.md
+ */
+ #[NoAdminRequired]
+ #[NoCSRFRequired]
+ public function index(): JSONResponse {
+ if ($this->userId === null) {
+ return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED);
+ }
+
+ $user = $this->userSession->getUser();
+ if ($user === null) {
+ return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED);
+ }
+
+ try {
+ $this->actionAuth->requireAction($user, 'manifest.index');
+ } catch (OCSForbiddenException) {
+ return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN);
+ }
+
+ // Retrieve ObjectService lazily — OpenRegister may not be enabled on
+ // every instance. Returning an empty manifest (not an error) lets the
+ // frontend render its "no dashboards yet" CTA without a red alert.
+ try {
+ /*
+ * @var \OCA\OpenRegister\Service\ObjectService $objectService
+ */
+
+ $objectService = $this->container->get('OCA\OpenRegister\Service\ObjectService');
+ } catch (\Throwable $e) {
+ $this->logger->warning(
+ 'LaunchPad: OpenRegister ObjectService unavailable — returning empty manifest. ' . $e->getMessage(),
+ ['app' => Application::APP_ID]
+ );
+
+ return new JSONResponse($this->buildManifest(dashboards: [], userId: $this->userId));
+ }//end try
+
+ // Fetch all dashboard objects owned by or shared with the current user.
+ $dashboards = $this->fetchUserDashboards(objectService: $objectService, userId: $this->userId);
+
+ return new JSONResponse($this->buildManifest(dashboards: $dashboards, userId: $this->userId));
+ }//end index()
+
+ /**
+ * Maximum number of granted dashboards folded into one manifest.
+ *
+ * A bound, not a policy: the grant lookup is cheap but the follow-up load is
+ * one IN(...) query whose parameter list should not grow without limit. If a
+ * user ever exceeds this, the manifest is truncated rather than slow — and
+ * the truncation is logged rather than silent, so it cannot be mistaken for
+ * "the user has no shared dashboards".
+ *
+ * @var int
+ */
+ private const MAX_GRANTED = 200;
+
+ /**
+ * Fetch dashboard objects from OpenRegister for the given user.
+ *
+ * C5 fix (REQ-MVR-001): replaces the non-existent `findObjects()` call
+ * (which caused a BadMethodCallException silently swallowed by Throwable)
+ * with the real `ObjectService::findAll()` API. The `owner` filter
+ * constrains results to the calling user's records — without it every
+ * user would receive the full dataset (latent IDOR on top of the API drift).
+ *
+ * Two sources, deduplicated: dashboards the user OWNS, and dashboards
+ * explicitly GRANTED to them through OpenRegister's per-object sharing
+ * primitive. The owner filter is deliberately kept on the first query rather
+ * than replaced by "let RBAC decide" — see fetchGrantedDashboards() for why
+ * the additive shape is the safe one.
+ *
+ * @param object $objectService The OpenRegister ObjectService instance.
+ * @param string $userId The authenticated Nextcloud user ID.
+ *
+ * @return array> Flat list of dashboard data arrays.
+ */
+ private function fetchUserDashboards(object $objectService, string $userId): array {
+ $dashboards = [];
+ $seen = [];
+
+ try {
+ // C5 fix: use the real ObjectService::findAll() API.
+ // `findObjects()` does not exist; the old call threw
+ // BadMethodCallException that was silently swallowed, causing the
+ // manifest to always return empty pages/menu arrays.
+ //
+ // The owner filter MUST be NESTED under `@self`. OpenRegister splits
+ // filters into metadata filters (the magic table's `_`-prefixed
+ // columns, addressed as a nested `@self` array) and property filters
+ // (matched against the schema's own properties). A bare `owner` is
+ // therefore read as a property filter on an `owner` property, which
+ // the dashboard schema does not have — so it matched nothing and
+ // this endpoint returned an EMPTY MANIFEST to every user, including
+ // the owner of the dashboards.
+ //
+ // Measured on a live instance with admin owning two dashboards: no
+ // owner filter returned 2; a bare `owner => admin` returned 0; a
+ // DOTTED `@self.owner => admin` also returned 0; the nested
+ // `@self => [owner => admin]` returned 2. The control was a nested
+ // filter with a nonexistent user, which returned 0 — without it,
+ // "nested returns 2" could not be distinguished from "the filter was
+ // ignored, here is everything", which is the failure mode that
+ // produced the always-empty manifest in the first place.
+ $ownedResults = $objectService->findAll(
+ config: [
+ 'filters' => [
+ 'register' => self::REGISTER,
+ 'schema' => self::SCHEMA,
+ '@self' => ['owner' => $userId],
+ ],
+ 'limit' => 500,
+ ]
+ );
+
+ if (is_array($ownedResults) === true) {
+ $this->foldInto(rows: $ownedResults, dashboards: $dashboards, seen: $seen, extract: true);
+ }
+ } catch (DoesNotExistException $e) {
+ // The 'mydash' register or 'dashboard' schema has not been
+ // provisioned in OpenRegister on this instance yet. That simply
+ // means the user has no dashboards — degrade to an empty manifest
+ // so the frontend renders its "no dashboards yet" CTA instead of a
+ // 500 (OpenRegister surfaces this as DoesNotExistException, which
+ // extends \Exception and so is not a RuntimeException).
+ $this->logger->info(
+ 'MyDash: OpenRegister register/schema not provisioned — returning empty manifest. ' . $e->getMessage(),
+ ['app' => Application::APP_ID, 'userId' => $userId]
+ );
+ } catch (\RuntimeException|\InvalidArgumentException $e) {
+ // Narrow catch: only handle recoverable OR API errors. Let
+ // unexpected errors propagate so they are visible in the logs.
+ $this->logger->error(
+ 'LaunchPad: failed to fetch dashboards from OpenRegister: ' . $e->getMessage(),
+ ['app' => Application::APP_ID, 'userId' => $userId]
+ );
+ }//end try
+
+ // Second source: dashboards explicitly granted to this user. Additive,
+ // and folded through the SAME $seen map so a dashboard the user both
+ // owns and was granted appears once.
+ $this->foldInto(
+ rows: $this->fetchGrantedDashboards(objectService: $objectService, userId: $userId),
+ dashboards: $dashboards,
+ seen: $seen,
+ extract: false
+ );
+
+ return $dashboards;
+ }//end fetchUserDashboards()
+
+ /**
+ * Fold one source's rows into the accumulator, skipping ones already seen.
+ *
+ * Shared by both sources on purpose: the owned query and the grant query must
+ * dedupe by the SAME identity rule, or a dashboard the user both owns and was
+ * granted would appear twice in the manifest.
+ *
+ * @param array $rows Rows from one source.
+ * @param array> $dashboards Accumulator, by reference.
+ * @param array $seen Identity map, by reference.
+ * @param bool $extract Whether the rows still
+ * need extractData() —
+ * the granted source has
+ * already normalised
+ * them.
+ *
+ * @return void
+ */
+ private function foldInto(array $rows, array &$dashboards, array &$seen, bool $extract): void {
+ foreach ($rows as $row) {
+ $data = $row;
+ if ($extract === true) {
+ $data = $this->extractData(item: $row);
+ }
+
+ if (is_array($data) === false || empty($data) === true) {
+ continue;
+ }
+
+ $id = ($data['id'] ?? $data['uuid'] ?? $data['slug'] ?? null);
+ if ($id !== null && isset($seen[$id]) === false) {
+ $seen[$id] = true;
+ $dashboards[] = $data;
+ }
+ }
+
+ }//end foldInto()
+
+ /**
+ * Dashboard objects explicitly granted to this user, via OpenRegister.
+ *
+ * WHY THIS IS ADDITIVE, rather than "drop the owner filter and let RBAC
+ * decide". Letting RBAC decide is the tidier design and it is what the
+ * OpenRegister `private` scope exists for — but it is only safe once the
+ * `dashboard` schema actually carries `scope: private`. A register-descriptor
+ * change lands through a repair step on upgrade, so there is necessarily a
+ * window (and, on any instance where that import did not apply, an
+ * indefinite one) in which the schema is still unscoped. An unfiltered
+ * findAll() against an unscoped schema returns EVERY user's dashboards. So
+ * the owner filter stays, and grants only ever ADD rows. The failure mode of
+ * this shape is a missing dashboard; the failure mode of the other is a
+ * cross-tenant leak in the manifest.
+ *
+ * `read` is the verb, because appearing in someone's manifest is exactly a
+ * read. The resolver answers only for the five core permission verbs and
+ * refuses anything else, so this cannot silently widen.
+ *
+ * Fails soft and empty: OpenRegister may be present without the sharing
+ * primitive (an older release), in which case the class is simply absent and
+ * the manifest degrades to owned-only — the behaviour before this change.
+ *
+ * @param object $objectService The OpenRegister ObjectService instance.
+ * @param string $userId The authenticated Nextcloud user ID.
+ *
+ * @return array> Granted dashboard data arrays.
+ */
+ private function fetchGrantedDashboards(object $objectService, string $userId): array {
+ try {
+ $grantResolver = $this->container->get('OCA\OpenRegister\Service\Rbac\ObjectGrantResolver');
+ } catch (\Throwable $e) {
+ // OpenRegister without the per-object sharing primitive. Not an
+ // error: degrade to owned-only, which is the pre-existing behaviour.
+ $this->logger->debug(
+ 'LaunchPad: OpenRegister object-grant resolver unavailable — manifest is owned-only. ' . $e->getMessage(),
+ ['app' => Application::APP_ID]
+ );
+
+ return [];
+ }
+
+ try {
+ // Keys, not values: the resolver returns uuid => permission
+ // bitmask, so array_values() would yield the bitmasks.
+ $grantedUuids = array_keys($grantResolver->grantedObjectUuidsFor($userId, 'read'));
+ if (empty($grantedUuids) === true) {
+ return [];
+ }
+
+ if (count($grantedUuids) > self::MAX_GRANTED) {
+ // Logged, never silent: a truncated manifest must not be
+ // indistinguishable from "nothing is shared with this user".
+ $this->logger->warning(
+ sprintf(
+ 'LaunchPad: %d granted dashboards exceeds the %d cap — manifest truncated.',
+ count($grantedUuids),
+ self::MAX_GRANTED
+ ),
+ ['app' => Application::APP_ID, 'userId' => $userId]
+ );
+ $grantedUuids = array_slice($grantedUuids, 0, self::MAX_GRANTED);
+ }
+
+ // `ids` is a first-class config key that matches `_uuid` OR `_slug`.
+ // A `filters['uuid']` entry would instead be read as a property
+ // filter on a `uuid` property — the same trap that made the owner
+ // query above return nothing.
+ $results = $objectService->findAll(
+ config: [
+ 'filters' => [
+ 'register' => self::REGISTER,
+ 'schema' => self::SCHEMA,
+ ],
+ 'ids' => $grantedUuids,
+ 'limit' => self::MAX_GRANTED,
+ ]
+ );
+
+ if (is_array($results) === false) {
+ return [];
+ }
+
+ $granted = [];
+ foreach ($results as $item) {
+ $data = $this->extractData(item: $item);
+ if (empty($data) === false) {
+ $granted[] = $data;
+ }
+ }
+
+ return $granted;
+ } catch (DoesNotExistException $e) {
+ // Register/schema not provisioned — same benign case the owned
+ // query already handles.
+ return [];
+ } catch (\RuntimeException|\InvalidArgumentException $e) {
+ $this->logger->error(
+ 'LaunchPad: failed to fetch granted dashboards from OpenRegister: ' . $e->getMessage(),
+ ['app' => Application::APP_ID, 'userId' => $userId]
+ );
+
+ return [];
+ }//end try
+
+ }//end fetchGrantedDashboards()
+
+ /**
+ * Normalise a raw ObjectService result item to a plain data array.
+ *
+ * OpenRegister items may be returned as objects with a `getObject()`
+ * method or as plain associative arrays.
+ *
+ * @param mixed $item A single result from ObjectService::findAll().
+ *
+ * @return array The plain data array, or [] on failure.
+ */
+ private function extractData(mixed $item): array {
+ if (is_array($item) === true) {
+ return $item;
+ }
+
+ if (is_object($item) === true && method_exists($item, 'getObject') === true) {
+ $data = $item->getObject();
+ if (is_array($data) === true) {
+ return $data;
+ }
+
+ return [];
+ }
+
+ if (is_object($item) === true && method_exists($item, 'jsonSerialize') === true) {
+ $data = $item->jsonSerialize();
+ if (is_array($data) === true) {
+ return $data;
+ }
+
+ return [];
+ }
+
+ return [];
+ }//end extractData()
+
+ /**
+ * Build the v2 manifest array from a list of dashboard data arrays.
+ *
+ * @param array> $dashboards Flat list of dashboard data.
+ * @param string $userId The current user ID.
+ *
+ * @return array The v2 manifest document.
+ */
+ private function buildManifest(array $dashboards, string $userId): array {
+ $pages = [];
+ $menu = [];
+ $order = 0;
+
+ foreach ($dashboards as $data) {
+ $slug = $data['slug'] ?? null;
+ $title = $data['title'] ?? null;
+
+ if (empty($slug) === true || empty($title) === true) {
+ continue;
+ }
+
+ $pageId = 'dashboard-' . $slug;
+
+ $pages[] = [
+ 'id' => $pageId,
+ 'route' => '/' . $slug,
+ 'type' => 'dashboard',
+ 'title' => $title,
+ 'widgets' => $data['widgets'] ?? [],
+ ];
+
+ $menu[] = [
+ 'id' => 'menu-' . $slug,
+ 'label' => $title,
+ 'route' => $pageId,
+ 'order' => $order,
+ // ADR-077 Tier A concept `dashboard`. These entries are
+ // dashboards, so `icon-home` was both the wrong concept and a
+ // legacy `icon-*` CSS class — which renders as an invisible
+ // white glyph on NC34+ light themes. The name is registered in
+ // src/icons.js; CnIcon has no fallback for one that is not.
+ 'icon' => 'ViewDashboardOutline',
+ ];
+
+ $order++;
+ }//end foreach
+
+ return [
+ '$schema' => self::SCHEMA_URL,
+ 'version' => '1.0.0',
+ 'dependencies' => ['openregister'],
+ 'menu' => $menu,
+ 'pages' => $pages,
+ 'runtime' => [
+ 'user' => [
+ 'id' => $userId,
+ ],
+ ],
+ ];
+
+ }//end buildManifest()
}//end class
diff --git a/lib/Controller/MetadataAdminController.php b/lib/Controller/MetadataAdminController.php
index bba8e061..ebc90e34 100644
--- a/lib/Controller/MetadataAdminController.php
+++ b/lib/Controller/MetadataAdminController.php
@@ -51,370 +51,364 @@
*
* @spec openspec/specs/dashboard-metadata-fields/spec.md
*/
-class MetadataAdminController extends Controller
-{
- /**
- * Constructor.
- *
- * @param IRequest $request The HTTP request.
- * @param MetadataService $metadataService The metadata service facade.
- * @param IGroupManager $groupManager Admin checker.
- * @param IUserSession $userSession Current user session.
- * @param ActionAuthService $actionAuth ADR-023 action authorization.
- */
- public function __construct(
- IRequest $request,
- private readonly MetadataService $metadataService,
- private readonly IGroupManager $groupManager,
- private readonly IUserSession $userSession,
- private readonly ActionAuthService $actionAuth,
- ) {
- parent::__construct(
- appName: Application::APP_ID,
- request: $request
- );
- }//end __construct()
-
- /**
- * Inline admin guard.
- *
- * @return JSONResponse|null Non-null = caller must be rejected.
- */
- private function assertAdmin(): ?JSONResponse
- {
- $user = $this->userSession->getUser();
- if ($user === null) {
- return new JSONResponse(
- data: ['error' => 'Not authenticated'],
- statusCode: Http::STATUS_UNAUTHORIZED
- );
- }
-
- if ($this->groupManager->isAdmin(userId: $user->getUID()) === false) {
- return new JSONResponse(
- data: ['error' => 'Admin required'],
- statusCode: Http::STATUS_FORBIDDEN
- );
- }
-
- return null;
- }//end assertAdmin()
-
- /**
- * `GET /api/admin/metadata-fields` — list all field definitions
- * (REQ-MDFL-001).
- *
- * @return JSONResponse The fields array + count, or 403.
- *
- * @spec openspec/specs/dashboard-metadata-fields/spec.md
- */
- #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
- public function listFields(): JSONResponse
- {
- $guard = $this->assertAdmin();
- if ($guard !== null) {
- return $guard;
- }
-
- try {
- $this->actionAuth->requireAction(
- $this->userSession->getUser(),
- 'metadata-admin.list-fields'
- );
- } catch (OCSForbiddenException) {
- return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN);
- }
-
- $fields = $this->metadataService->listFields();
-
- return ResponseHelper::success(
- data: [
- 'fields' => ResponseHelper::serializeList(entities: $fields),
- 'count' => count($fields),
- ]
- );
- }//end listFields()
-
- /**
- * `POST /api/admin/metadata-fields` — create a new field definition
- * (REQ-MDFL-001).
- *
- * @param string $key The slug.
- * @param string $label The display label.
- * @param string $type The field type.
- * @param array|null $options Option set (select types).
- * @param int $required 0 / 1.
- * @param int $sortOrder UI sort order.
- *
- * @return JSONResponse 201 + field, 400 on validation failure,
- * 403 for non-admins.
- *
- * @spec openspec/specs/dashboard-metadata-fields/spec.md
- */
- #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
- public function createField(
- string $key='',
- string $label='',
- string $type='',
- ?array $options=null,
- int $required=0,
- int $sortOrder=0
- ): JSONResponse {
- $guard = $this->assertAdmin();
- if ($guard !== null) {
- return $guard;
- }
-
- try {
- $this->actionAuth->requireAction(
- $this->userSession->getUser(),
- 'metadata-admin.create-field'
- );
- } catch (OCSForbiddenException) {
- return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN);
- }
-
- try {
- $field = $this->metadataService->createFieldDefinition(
- key: $key,
- label: $label,
- type: $type,
- options: $options,
- required: $required,
- sortOrder: $sortOrder
- );
- } catch (InvalidMetadataFieldException $exception) {
- return self::badRequest(message: $exception->getMessage());
- }
-
- return new JSONResponse(
- data: $field->jsonSerialize(),
- statusCode: Http::STATUS_CREATED
- );
- }//end createField()
-
- /**
- * `GET /api/admin/metadata-fields/{id}` — fetch a single field
- * definition (REQ-MDFL-001).
- *
- * @param int $id The field id.
- *
- * @return JSONResponse 200 + field, 404 when missing, 403 for non-admins.
- *
- * @spec openspec/specs/dashboard-metadata-fields/spec.md
- */
- #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
- public function getField(int $id): JSONResponse
- {
- $guard = $this->assertAdmin();
- if ($guard !== null) {
- return $guard;
- }
-
- try {
- $this->actionAuth->requireAction(
- $this->userSession->getUser(),
- 'metadata-admin.get-field'
- );
- } catch (OCSForbiddenException) {
- return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN);
- }
-
- try {
- $field = $this->metadataService->getField(id: $id);
- } catch (DoesNotExistException) {
- return new JSONResponse(
- data: ['error' => 'Field not found'],
- statusCode: Http::STATUS_NOT_FOUND
- );
- }
-
- return ResponseHelper::success(data: $field->jsonSerialize());
- }//end getField()
-
- /**
- * `PUT /api/admin/metadata-fields/{id}` — update label / sortOrder
- * / required / options. Forbids `key` rename (REQ-MDFL-002).
- *
- * @param int $id The field id.
- * @param string|null $label The new label.
- * @param int|null $sortOrder The new sort order.
- * @param int|null $required The new required flag.
- * @param array|null $options The new option set.
- * @param string|null $key Forbidden — triggers 400.
- *
- * @return JSONResponse 200 + field, 400 on validation failure,
- * 404 when missing, 403 for non-admins.
- *
- * @spec openspec/specs/dashboard-metadata-fields/spec.md
- */
- #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
- public function updateField(
- int $id,
- ?string $label=null,
- ?int $sortOrder=null,
- ?int $required=null,
- ?array $options=null,
- ?string $key=null
- ): JSONResponse {
- $guard = $this->assertAdmin();
- if ($guard !== null) {
- return $guard;
- }
-
- try {
- $this->actionAuth->requireAction(
- $this->userSession->getUser(),
- 'metadata-admin.update-field'
- );
- } catch (OCSForbiddenException) {
- return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN);
- }
-
- $patch = self::buildPatch(
- key: $key,
- label: $label,
- sortOrder: $sortOrder,
- required: $required,
- options: $options
- );
-
- try {
- $field = $this->metadataService->updateFieldDefinition(
- id: $id,
- patch: $patch
- );
- } catch (DoesNotExistException) {
- return new JSONResponse(
- data: ['error' => 'Field not found'],
- statusCode: Http::STATUS_NOT_FOUND
- );
- } catch (InvalidMetadataFieldException $exception) {
- return self::badRequest(message: $exception->getMessage());
- }
-
- return ResponseHelper::success(data: $field->jsonSerialize());
- }//end updateField()
-
- /**
- * `DELETE /api/admin/metadata-fields/{id}?cascade=true` —
- * REQ-MDFL-003.
- *
- * @param int $id The field id.
- * @param bool $cascade Whether to cascade-delete dependent values.
- *
- * @return JSONResponse 200 on success, 409 when soft-deletion blocked,
- * 404 when missing, 403 for non-admins.
- *
- * @spec openspec/specs/dashboard-metadata-fields/spec.md
- */
- #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
- public function deleteField(int $id, bool $cascade=false): JSONResponse
- {
- $guard = $this->assertAdmin();
- if ($guard !== null) {
- return $guard;
- }
-
- try {
- $this->actionAuth->requireAction(
- $this->userSession->getUser(),
- 'metadata-admin.delete-field'
- );
- } catch (OCSForbiddenException) {
- return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN);
- }
-
- try {
- $this->metadataService->deleteFieldDefinition(
- id: $id,
- cascade: $cascade
- );
- } catch (DoesNotExistException) {
- return new JSONResponse(
- data: ['error' => 'Field not found'],
- statusCode: Http::STATUS_NOT_FOUND
- );
- } catch (MetadataFieldHasValuesException $exception) {
- return new JSONResponse(
- data: [
- 'error' => MetadataFieldHasValuesException::ERROR_CODE,
- 'message' => $exception->getMessage(),
- 'valueCount' => $exception->getValueCount(),
- ],
- statusCode: Http::STATUS_CONFLICT
- );
- }
-
- return ResponseHelper::success(data: ['status' => 'ok']);
- }//end deleteField()
-
- /**
- * Build the update patch from the individual nullable parameters.
- *
- * `null` means "not supplied" and the key is left out entirely, so
- * the service can distinguish an omitted field from an explicit
- * value. The router delivers `null` when the body omits the key;
- * any explicit array (including an empty one) counts as "set
- * options", so admins can clear an option set on non-select types.
- *
- * The forbidden `key` rename is deliberately forwarded rather than
- * dropped — the service rejects it with the documented 400.
- *
- * @param string|null $key The (forbidden) new slug.
- * @param string|null $label The new label.
- * @param int|null $sortOrder The new sort order.
- * @param int|null $required The new required flag.
- * @param array|null $options The new option set.
- *
- * @return array The patch payload.
- */
- private static function buildPatch(
- ?string $key,
- ?string $label,
- ?int $sortOrder,
- ?int $required,
- ?array $options
- ): array {
- $patch = [];
- if ($key !== null) {
- $patch['key'] = $key;
- }
-
- if ($label !== null) {
- $patch['label'] = $label;
- }
-
- if ($sortOrder !== null) {
- $patch['sortOrder'] = $sortOrder;
- }
-
- if ($required !== null) {
- $patch['required'] = $required;
- }
-
- if ($options !== null) {
- $patch['options'] = $options;
- }
-
- return $patch;
- }//end buildPatch()
-
- /**
- * Build a 400-with-error envelope.
- *
- * @param string $message The validation message.
- *
- * @return JSONResponse The 400 response.
- */
- private static function badRequest(string $message): JSONResponse
- {
- return new JSONResponse(
- data: [
- 'error' => InvalidMetadataFieldException::ERROR_CODE,
- 'message' => $message,
- ],
- statusCode: Http::STATUS_BAD_REQUEST
- );
- }//end badRequest()
+class MetadataAdminController extends Controller {
+ /**
+ * Constructor.
+ *
+ * @param IRequest $request The HTTP request.
+ * @param MetadataService $metadataService The metadata service facade.
+ * @param IGroupManager $groupManager Admin checker.
+ * @param IUserSession $userSession Current user session.
+ * @param ActionAuthService $actionAuth ADR-023 action authorization.
+ */
+ public function __construct(
+ IRequest $request,
+ private readonly MetadataService $metadataService,
+ private readonly IGroupManager $groupManager,
+ private readonly IUserSession $userSession,
+ private readonly ActionAuthService $actionAuth,
+ ) {
+ parent::__construct(
+ appName: Application::APP_ID,
+ request: $request
+ );
+ }//end __construct()
+
+ /**
+ * Inline admin guard.
+ *
+ * @return JSONResponse|null Non-null = caller must be rejected.
+ */
+ private function assertAdmin(): ?JSONResponse {
+ $user = $this->userSession->getUser();
+ if ($user === null) {
+ return new JSONResponse(
+ data: ['error' => 'Not authenticated'],
+ statusCode: Http::STATUS_UNAUTHORIZED
+ );
+ }
+
+ if ($this->groupManager->isAdmin(userId: $user->getUID()) === false) {
+ return new JSONResponse(
+ data: ['error' => 'Admin required'],
+ statusCode: Http::STATUS_FORBIDDEN
+ );
+ }
+
+ return null;
+ }//end assertAdmin()
+
+ /**
+ * `GET /api/admin/metadata-fields` — list all field definitions
+ * (REQ-MDFL-001).
+ *
+ * @return JSONResponse The fields array + count, or 403.
+ *
+ * @spec openspec/specs/dashboard-metadata-fields/spec.md
+ */
+ #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
+ public function listFields(): JSONResponse {
+ $guard = $this->assertAdmin();
+ if ($guard !== null) {
+ return $guard;
+ }
+
+ try {
+ $this->actionAuth->requireAction(
+ $this->userSession->getUser(),
+ 'metadata-admin.list-fields'
+ );
+ } catch (OCSForbiddenException) {
+ return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN);
+ }
+
+ $fields = $this->metadataService->listFields();
+
+ return ResponseHelper::success(
+ data: [
+ 'fields' => ResponseHelper::serializeList(entities: $fields),
+ 'count' => count($fields),
+ ]
+ );
+ }//end listFields()
+
+ /**
+ * `POST /api/admin/metadata-fields` — create a new field definition
+ * (REQ-MDFL-001).
+ *
+ * @param string $key The slug.
+ * @param string $label The display label.
+ * @param string $type The field type.
+ * @param array|null $options Option set (select types).
+ * @param int $required 0 / 1.
+ * @param int $sortOrder UI sort order.
+ *
+ * @return JSONResponse 201 + field, 400 on validation failure,
+ * 403 for non-admins.
+ *
+ * @spec openspec/specs/dashboard-metadata-fields/spec.md
+ */
+ #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
+ public function createField(
+ string $key = '',
+ string $label = '',
+ string $type = '',
+ ?array $options = null,
+ int $required = 0,
+ int $sortOrder = 0,
+ ): JSONResponse {
+ $guard = $this->assertAdmin();
+ if ($guard !== null) {
+ return $guard;
+ }
+
+ try {
+ $this->actionAuth->requireAction(
+ $this->userSession->getUser(),
+ 'metadata-admin.create-field'
+ );
+ } catch (OCSForbiddenException) {
+ return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN);
+ }
+
+ try {
+ $field = $this->metadataService->createFieldDefinition(
+ key: $key,
+ label: $label,
+ type: $type,
+ options: $options,
+ required: $required,
+ sortOrder: $sortOrder
+ );
+ } catch (InvalidMetadataFieldException $exception) {
+ return self::badRequest(message: $exception->getMessage());
+ }
+
+ return new JSONResponse(
+ data: $field->jsonSerialize(),
+ statusCode: Http::STATUS_CREATED
+ );
+ }//end createField()
+
+ /**
+ * `GET /api/admin/metadata-fields/{id}` — fetch a single field
+ * definition (REQ-MDFL-001).
+ *
+ * @param int $id The field id.
+ *
+ * @return JSONResponse 200 + field, 404 when missing, 403 for non-admins.
+ *
+ * @spec openspec/specs/dashboard-metadata-fields/spec.md
+ */
+ #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
+ public function getField(int $id): JSONResponse {
+ $guard = $this->assertAdmin();
+ if ($guard !== null) {
+ return $guard;
+ }
+
+ try {
+ $this->actionAuth->requireAction(
+ $this->userSession->getUser(),
+ 'metadata-admin.get-field'
+ );
+ } catch (OCSForbiddenException) {
+ return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN);
+ }
+
+ try {
+ $field = $this->metadataService->getField(id: $id);
+ } catch (DoesNotExistException) {
+ return new JSONResponse(
+ data: ['error' => 'Field not found'],
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ }
+
+ return ResponseHelper::success(data: $field->jsonSerialize());
+ }//end getField()
+
+ /**
+ * `PUT /api/admin/metadata-fields/{id}` — update label / sortOrder
+ * / required / options. Forbids `key` rename (REQ-MDFL-002).
+ *
+ * @param int $id The field id.
+ * @param string|null $label The new label.
+ * @param int|null $sortOrder The new sort order.
+ * @param int|null $required The new required flag.
+ * @param array|null $options The new option set.
+ * @param string|null $key Forbidden — triggers 400.
+ *
+ * @return JSONResponse 200 + field, 400 on validation failure,
+ * 404 when missing, 403 for non-admins.
+ *
+ * @spec openspec/specs/dashboard-metadata-fields/spec.md
+ */
+ #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
+ public function updateField(
+ int $id,
+ ?string $label = null,
+ ?int $sortOrder = null,
+ ?int $required = null,
+ ?array $options = null,
+ ?string $key = null,
+ ): JSONResponse {
+ $guard = $this->assertAdmin();
+ if ($guard !== null) {
+ return $guard;
+ }
+
+ try {
+ $this->actionAuth->requireAction(
+ $this->userSession->getUser(),
+ 'metadata-admin.update-field'
+ );
+ } catch (OCSForbiddenException) {
+ return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN);
+ }
+
+ $patch = self::buildPatch(
+ key: $key,
+ label: $label,
+ sortOrder: $sortOrder,
+ required: $required,
+ options: $options
+ );
+
+ try {
+ $field = $this->metadataService->updateFieldDefinition(
+ id: $id,
+ patch: $patch
+ );
+ } catch (DoesNotExistException) {
+ return new JSONResponse(
+ data: ['error' => 'Field not found'],
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ } catch (InvalidMetadataFieldException $exception) {
+ return self::badRequest(message: $exception->getMessage());
+ }
+
+ return ResponseHelper::success(data: $field->jsonSerialize());
+ }//end updateField()
+
+ /**
+ * `DELETE /api/admin/metadata-fields/{id}?cascade=true` —
+ * REQ-MDFL-003.
+ *
+ * @param int $id The field id.
+ * @param bool $cascade Whether to cascade-delete dependent values.
+ *
+ * @return JSONResponse 200 on success, 409 when soft-deletion blocked,
+ * 404 when missing, 403 for non-admins.
+ *
+ * @spec openspec/specs/dashboard-metadata-fields/spec.md
+ */
+ #[AuthorizedAdminSetting(LaunchPadAdmin::class)]
+ public function deleteField(int $id, bool $cascade = false): JSONResponse {
+ $guard = $this->assertAdmin();
+ if ($guard !== null) {
+ return $guard;
+ }
+
+ try {
+ $this->actionAuth->requireAction(
+ $this->userSession->getUser(),
+ 'metadata-admin.delete-field'
+ );
+ } catch (OCSForbiddenException) {
+ return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN);
+ }
+
+ try {
+ $this->metadataService->deleteFieldDefinition(
+ id: $id,
+ cascade: $cascade
+ );
+ } catch (DoesNotExistException) {
+ return new JSONResponse(
+ data: ['error' => 'Field not found'],
+ statusCode: Http::STATUS_NOT_FOUND
+ );
+ } catch (MetadataFieldHasValuesException $exception) {
+ return new JSONResponse(
+ data: [
+ 'error' => MetadataFieldHasValuesException::ERROR_CODE,
+ 'message' => $exception->getMessage(),
+ 'valueCount' => $exception->getValueCount(),
+ ],
+ statusCode: Http::STATUS_CONFLICT
+ );
+ }
+
+ return ResponseHelper::success(data: ['status' => 'ok']);
+ }//end deleteField()
+
+ /**
+ * Build the update patch from the individual nullable parameters.
+ *
+ * `null` means "not supplied" and the key is left out entirely, so
+ * the service can distinguish an omitted field from an explicit
+ * value. The router delivers `null` when the body omits the key;
+ * any explicit array (including an empty one) counts as "set
+ * options", so admins can clear an option set on non-select types.
+ *
+ * The forbidden `key` rename is deliberately forwarded rather than
+ * dropped — the service rejects it with the documented 400.
+ *
+ * @param string|null $key The (forbidden) new slug.
+ * @param string|null $label The new label.
+ * @param int|null $sortOrder The new sort order.
+ * @param int|null $required The new required flag.
+ * @param array|null $options The new option set.
+ *
+ * @return array The patch payload.
+ */
+ private static function buildPatch(
+ ?string $key,
+ ?string $label,
+ ?int $sortOrder,
+ ?int $required,
+ ?array $options,
+ ): array {
+ $patch = [];
+ if ($key !== null) {
+ $patch['key'] = $key;
+ }
+
+ if ($label !== null) {
+ $patch['label'] = $label;
+ }
+
+ if ($sortOrder !== null) {
+ $patch['sortOrder'] = $sortOrder;
+ }
+
+ if ($required !== null) {
+ $patch['required'] = $required;
+ }
+
+ if ($options !== null) {
+ $patch['options'] = $options;
+ }
+
+ return $patch;
+ }//end buildPatch()
+
+ /**
+ * Build a 400-with-error envelope.
+ *
+ * @param string $message The validation message.
+ *
+ * @return JSONResponse The 400 response.
+ */
+ private static function badRequest(string $message): JSONResponse {
+ return new JSONResponse(
+ data: [
+ 'error' => InvalidMetadataFieldException::ERROR_CODE,
+ 'message' => $message,
+ ],
+ statusCode: Http::STATUS_BAD_REQUEST
+ );
+ }//end badRequest()
}//end class
diff --git a/lib/Controller/MetricsController.php b/lib/Controller/MetricsController.php
index 61c2c1c0..b0ad0174 100644
--- a/lib/Controller/MetricsController.php
+++ b/lib/Controller/MetricsController.php
@@ -66,71 +66,69 @@
*
* @spec openspec/changes/adopt-apphost/specs/prometheus-metrics/spec.md — Requirement: Metrics Endpoint (REQ-PROM-001)
*/
-class MetricsController extends Controller
-{
- /**
- * Prometheus text exposition content type.
- *
- * Inlined rather than read from `PrometheusRenderer::CONTENT_TYPE`, because
- * referencing that constant is itself a compile-time dependency on a class
- * that may not be installed — the same trap as the old `extends`.
- *
- * @var string
- */
- public const CONTENT_TYPE = 'text/plain; version=0.0.4; charset=utf-8';
+class MetricsController extends Controller {
+ /**
+ * Prometheus text exposition content type.
+ *
+ * Inlined rather than read from `PrometheusRenderer::CONTENT_TYPE`, because
+ * referencing that constant is itself a compile-time dependency on a class
+ * that may not be installed — the same trap as the old `extends`.
+ *
+ * @var string
+ */
+ public const CONTENT_TYPE = 'text/plain; version=0.0.4; charset=utf-8';
- /**
- * Constructor.
- *
- * @param string $appName This leaf's app id (`launchpad`), which the
- * engine uses to locate the manifest and to
- * prefix the emitted metrics.
- * @param IRequest $request The HTTP request.
- * @param object|null $manifestLoader OpenRegister's ManifestLoader, or null when
- * OpenRegister is unavailable. Untyped on
- * purpose — see the class docblock.
- * @param object|null $engine OpenRegister's MetricsEngine, or null.
- */
- public function __construct(
- string $appName,
- IRequest $request,
- private readonly ?object $manifestLoader=null,
- private readonly ?object $engine=null,
- ) {
- parent::__construct(appName: $appName, request: $request);
- }//end __construct()
+ /**
+ * Constructor.
+ *
+ * @param string $appName This leaf's app id (`launchpad`), which the
+ * engine uses to locate the manifest and to
+ * prefix the emitted metrics.
+ * @param IRequest $request The HTTP request.
+ * @param object|null $manifestLoader OpenRegister's ManifestLoader, or null when
+ * OpenRegister is unavailable. Untyped on
+ * purpose — see the class docblock.
+ * @param object|null $engine OpenRegister's MetricsEngine, or null.
+ */
+ public function __construct(
+ string $appName,
+ IRequest $request,
+ private readonly ?object $manifestLoader = null,
+ private readonly ?object $engine = null,
+ ) {
+ parent::__construct(appName: $appName, request: $request);
+ }//end __construct()
- /**
- * GET /api/metrics — declarative Prometheus metrics (admin-only, ADR-006).
- *
- * @return TextPlainResponse Prometheus text exposition 0.0.4, or a plain 503
- * body when the engine is unavailable.
- *
- * @spec openspec/changes/adopt-apphost/specs/prometheus-metrics/spec.md — Requirement: Metrics Endpoint (REQ-PROM-001)
- */
- #[NoCSRFRequired]
- public function index(): TextPlainResponse
- {
- if ($this->manifestLoader === null || $this->engine === null) {
- return new TextPlainResponse(
- '# OpenRegister AppHost observability engine unavailable'."\n",
- Http::STATUS_SERVICE_UNAVAILABLE
- );
- }
+ /**
+ * GET /api/metrics — declarative Prometheus metrics (admin-only, ADR-006).
+ *
+ * @return TextPlainResponse Prometheus text exposition 0.0.4, or a plain 503
+ * body when the engine is unavailable.
+ *
+ * @spec openspec/changes/adopt-apphost/specs/prometheus-metrics/spec.md — Requirement: Metrics Endpoint (REQ-PROM-001)
+ */
+ #[NoCSRFRequired]
+ public function index(): TextPlainResponse {
+ if ($this->manifestLoader === null || $this->engine === null) {
+ return new TextPlainResponse(
+ '# OpenRegister AppHost observability engine unavailable' . "\n",
+ Http::STATUS_SERVICE_UNAVAILABLE
+ );
+ }
- try {
- $manifest = $this->manifestLoader->load(appId: $this->appName);
- $body = $this->engine->render(manifest: $manifest);
- } catch (Throwable $e) {
- return new TextPlainResponse(
- '# metrics unavailable: '.$e->getMessage()."\n",
- Http::STATUS_SERVICE_UNAVAILABLE
- );
- }
+ try {
+ $manifest = $this->manifestLoader->load(appId: $this->appName);
+ $body = $this->engine->render(manifest: $manifest);
+ } catch (Throwable $e) {
+ return new TextPlainResponse(
+ '# metrics unavailable: ' . $e->getMessage() . "\n",
+ Http::STATUS_SERVICE_UNAVAILABLE
+ );
+ }
- $response = new TextPlainResponse($body);
- $response->addHeader('Content-Type', self::CONTENT_TYPE);
+ $response = new TextPlainResponse($body);
+ $response->addHeader('Content-Type', self::CONTENT_TYPE);
- return $response;
- }//end index()
+ return $response;
+ }//end index()
}//end class
diff --git a/lib/Controller/PageController.php b/lib/Controller/PageController.php
index bc78857d..151a69cc 100644
--- a/lib/Controller/PageController.php
+++ b/lib/Controller/PageController.php
@@ -57,527 +57,515 @@
* group services to fill
* the contract.
*/
-class PageController extends Controller
-{
- /**
- * Constructor.
- *
- * @param IRequest $request The request.
- * @param IManager $dashboardManager Nextcloud dashboard widget manager.
- * @param IInitialState $initialState The Nextcloud initial-state service.
- * @param IUserSession $userSession Active user session.
- * @param WidgetService $widgetService Available-widgets descriptor formatter.
- * @param DashboardService $dashboardService Dashboard listing + resolver
- * (also exposes the
- * `allow_user_dashboards` flag
- * — REQ-ASET-003).
- * @param AdminTemplateService $adminTemplateService Primary-group routing
- * resolver (REQ-TMPL-012,
- * REQ-TMPL-013).
- * @param RoleFeaturePermissionService $roleFeaturePerm Per-user widget
- * allow-list source
- * (REQ-RFP-009..010).
- * @param DashboardTreeService $treeService Slug-chain
- * resolver used by the
- * deep-link route.
- * @param LoggerInterface $logger Used to record
- * silent fallback
- * when a deep-link
- * path doesn't
- * resolve to a
- * visible dashboard.
- * @param AdminSettingsService $adminSettingsService Source of the
- * quick-search
- * no-match
- * fallback-target
- * admin setting
- * (tile-quick-search
- * REQ-QSEARCH-004).
- */
- public function __construct(
- IRequest $request,
- private readonly IManager $dashboardManager,
- private readonly IInitialState $initialState,
- private readonly IUserSession $userSession,
- private readonly WidgetService $widgetService,
- private readonly DashboardService $dashboardService,
- private readonly AdminTemplateService $adminTemplateService,
- private readonly RoleFeaturePermissionService $roleFeaturePerm,
- private readonly DashboardTreeService $treeService,
- private readonly LoggerInterface $logger,
- private readonly AdminSettingsService $adminSettingsService,
- ) {
- parent::__construct(appName: Application::APP_ID, request: $request);
- }//end __construct()
-
- /**
- * Deep-link entry point — `/apps/launchpad/{deepLink}`.
- *
- * Symfony binds the captured slug-chain into `$deepLink`. Delegating
- * to {@see self::index()} keeps the workspace render path single-
- * sourced; the optional path argument merely overrides the active
- * dashboard before initial-state assembly.
- *
- * @param string $deepLink Slug-chain captured from the URL (may
- * contain `/` separators).
- *
- * @return TemplateResponse The workspace template response.
- *
- * @spec openspec/specs/runtime-shell/spec.md
- */
- #[NoAdminRequired]
- #[NoCSRFRequired]
- public function deepLink(string $deepLink=''): TemplateResponse
- {
- return $this->index(deepLink: $deepLink);
- }//end deepLink()
-
- /**
- * Render the workspace page.
- *
- * Wires the full workspace initial-state contract into the template via
- * {@see InitialStateBuilder}. Every key declared in REQ-INIT-002 is set
- * before `apply()` runs; missing keys raise
- * {@see \OCA\LaunchPad\Exception\MissingInitialStateException} so the page
- * never renders with a partial payload.
- *
- * Deep-link path: when `$deepLink` resolves through the tree service
- * to a dashboard the user can read, that dashboard is used as the
- * active one (overriding the resolver's seven-step fallback). When
- * the path doesn't resolve (renamed, deleted, never existed, or not
- * visible to the caller), the controller logs a warning and falls
- * back silently — bookmarks of stale slug chains still land on
- * something instead of 404'ing.
- *
- * @param string $deepLink Optional slug-chain selecting the active
- * dashboard. Empty string ⇒ default resolver.
- *
- * @return TemplateResponse The template response.
- *
- * @spec openspec/specs/runtime-shell/spec.md
- */
- #[NoAdminRequired]
- #[NoCSRFRequired]
- public function index(string $deepLink=''): TemplateResponse
- {
- Util::addScript(application: Application::APP_ID, file: 'launchpad-main');
- Util::addStyle(application: Application::APP_ID, file: 'launchpad');
-
- // Load all widget scripts so legacy widgets can register their callbacks.
- $this->loadWidgetScripts();
-
- $userId = $this->resolveUserId();
-
- $routingGroup = $this->resolveRoutingGroup(userId: $userId);
- $primaryGroupId = $routingGroup['id'];
- $primaryGroupName = $routingGroup['name'];
-
- $isAdmin = false;
- $visible = [];
- $active = null;
- if ($userId !== '') {
- $isAdmin = $this->dashboardService->isAdmin(userId: $userId);
- $visible = $this->dashboardService->getVisibleToUser(userId: $userId);
- $active = $this->resolveActive(
- userId: $userId,
- deepLink: $deepLink,
- primaryGroupId: $primaryGroupId
- );
- }
-
- $descriptors = $this->splitDescriptors(visible: $visible);
- $activeState = $this->describeActive(active: $active);
-
- $allowUserDashboards = $this->dashboardService->getAllowUserDashboards();
-
- $builder = new InitialStateBuilder(
- initialState: $this->initialState,
- page: Page::WORKSPACE
- );
-
- $builder
- ->setWidgets($this->widgetService->getAvailableWidgets())
- ->setLayout($activeState['layout'])
- ->setPrimaryGroup($primaryGroupId)
- ->setPrimaryGroupName($primaryGroupName)
- ->setIsAdmin($isAdmin)
- ->setActiveDashboardId($activeState['activeDashboardId'])
- ->setDashboardSource($activeState['dashboardSource'])
- ->setGroupDashboards($descriptors['group'])
- ->setUserDashboards($descriptors['user'])
- ->setAllowUserDashboards($allowUserDashboards);
-
- // PR #95 (role-based-content): per-user widget allow-list.
- // `null` = no admin policy for this user (unlimited).
- $allowedWidgets = null;
- if ($userId !== '') {
- $allowedWidgets = $this->roleFeaturePerm->getAllowedWidgetIds(
- userId: $userId
- );
- }
-
- // Tile-quick-search REQ-QSEARCH-004: read the admin-configured
- // no-match fallback target. `getSettings()` already resolves the
- // safe 'none' default when unset/invalid, so this never throws.
- $quicksearchFallback = (string) (
- $this->adminSettingsService->getSettings()['quicksearchFallbackTarget'] ?? AdminSettingsService::DEFAULT_QUICKSEARCH_FALLBACK_TARGET
- );
-
- $builder
- ->setAllowedWidgets($allowedWidgets)
- ->setDeepLinkPath($activeState['deepLinkPath'])
- ->setQuicksearchFallbackTarget($quicksearchFallback)
- ->apply();
-
- // REQ-SHELL-001: pass the chrome slot ids so Nextcloud treats
- // `#app-workspace` as the main content slot and allocates no left
- // navigation panel (the runtime shell renders its own slide-in
- // sidebar via `dashboard-switcher-sidebar`). Renderer parameter
- // names match the Nextcloud chrome conventions.
- $response = new TemplateResponse(
- appName: Application::APP_ID,
- templateName: 'index',
- params: [
- 'id-app-content' => '#app-workspace',
- 'id-app-navigation' => null,
- ]
- );
-
- $response->setContentSecurityPolicy(csp: $this->buildWorkspaceCsp());
-
- return $response;
- }//end index()
-
- /**
- * Resolve the primary group this request routes through.
- *
- * Routing resolver — REQ-TMPL-012 / REQ-TMPL-013. The
- * `AdminTemplateService` walks the admin-configured `group_order`
- * priority list and returns the first group the user belongs to, OR
- * the literal `'default'` sentinel when nothing matches. The display
- * name comes from the same service so the lookup lives in exactly one
- * place.
- *
- * @param string $userId The caller's uid, or '' when anonymous.
- *
- * @return array{id: string, name: string} The group id and its
- * display name.
- */
- private function resolveRoutingGroup(string $userId): array
- {
- $primaryGroupId = Dashboard::DEFAULT_GROUP_ID;
- $primaryGroupName = $this->adminTemplateService->resolvePrimaryGroupDisplayName(
- groupId: Dashboard::DEFAULT_GROUP_ID
- );
- if ($userId !== '') {
- $primaryGroupId = $this->adminTemplateService->resolvePrimaryGroup(
- userId: $userId
- );
- $primaryGroupName = $this->adminTemplateService->resolvePrimaryGroupDisplayName(
- groupId: $primaryGroupId
- );
- }
-
- return [
- 'id' => $primaryGroupId,
- 'name' => $primaryGroupName,
- ];
- }//end resolveRoutingGroup()
-
- /**
- * Resolve the active session's user id.
- *
- * @return string The uid, or an empty string for an anonymous caller.
- */
- private function resolveUserId(): string
- {
- $user = $this->userSession->getUser();
- if ($user === null) {
- return '';
- }
-
- return $user->getUID();
- }//end resolveUserId()
-
- /**
- * Split the visible dashboards into the group and user descriptor
- * lists the initial-state contract expects (REQ-INIT-002).
- *
- * User-sourced entries drop the `source` key — the frontend infers it
- * from the list the descriptor arrived in.
- *
- * @param array $visible The visible entries.
- *
- * @return array{group: list>, user: list>}
- */
- private function splitDescriptors(array $visible): array
- {
- $groupDashboards = [];
- $userDashboards = [];
- foreach ($visible as $entry) {
- $dashboard = $entry['dashboard'];
- // Dashboard entity has no icon column today — surface an empty
- // string so the frontend descriptor shape matches REQ-INIT-002.
- $descriptor = [
- 'id' => (string) $dashboard->getUuid(),
- 'name' => (string) $dashboard->getName(),
- 'icon' => '',
- 'source' => $entry['source'],
- ];
-
- if ($entry['source'] === Dashboard::SOURCE_USER) {
- unset($descriptor['source']);
- $userDashboards[] = $descriptor;
- continue;
- }
-
- $groupDashboards[] = $descriptor;
- }
-
- return [
- 'group' => $groupDashboards,
- 'user' => $userDashboards,
- ];
- }//end splitDescriptors()
-
- /**
- * Resolve the dashboard that should open for this request.
- *
- * Deep-link override: when the URL carries a slug-chain we try to land
- * the user on that dashboard before consulting the seven-step
- * resolver. Failures (path doesn't resolve, not visible, throws) are
- * swallowed so a stale bookmark still opens *something* instead of
- * breaking.
- *
- * @param string $userId The caller's uid (never empty here).
- * @param string $deepLink The slug-chain from the URL, or ''.
- * @param string $primaryGroupId The resolved primary group id.
- *
- * @return array{dashboard: Dashboard, source: string}|null The active
- * entry, or
- * null.
- */
- private function resolveActive(
- string $userId,
- string $deepLink,
- string $primaryGroupId
- ): ?array {
- $active = null;
- if ($deepLink !== '') {
- $active = $this->resolveDeepLink(userId: $userId, deepLink: $deepLink);
- }
-
- if ($active !== null) {
- return $active;
- }
-
- return $this->dashboardService->resolveActiveDashboard(
- userId: $userId,
- primaryGroupId: $primaryGroupId
- );
- }//end resolveActive()
-
- /**
- * Resolve a slug-chain to a dashboard the caller may read.
- *
- * Every failure mode — unresolvable path, dashboard not visible, or a
- * throwing resolver — returns null after logging, so the caller can
- * fall back to the default resolver.
- *
- * @param string $userId The caller's uid.
- * @param string $deepLink The slug-chain from the URL.
- *
- * @return array{dashboard: Dashboard, source: string}|null The matched
- * entry, or
- * null.
- */
- private function resolveDeepLink(string $userId, string $deepLink): ?array
- {
- $active = null;
- try {
- $resolved = $this->treeService->resolvePath(path: $deepLink);
- if ($resolved !== null) {
- $active = $this->dashboardService->getDashboardForUser(
- dashboardId: $resolved->getId(),
- userId: $userId
- );
- }
- } catch (Throwable $t) {
- $this->logger->warning(
- message: 'launchpad: deep-link resolution failed for path "{path}": {message}',
- context: [
- 'path' => $deepLink,
- 'message' => $t->getMessage(),
- ]
- );
- }
-
- if ($active === null) {
- $this->logger->info(
- message: 'launchpad: deep-link path "{path}" not visible — falling back to default resolver',
- context: ['path' => $deepLink]
- );
- }
-
- return $active;
- }//end resolveDeepLink()
-
- /**
- * Derive the initial-state keys that describe the active dashboard.
- *
- * Returns the documented empty defaults when nothing is active, so
- * the contract is fully populated either way.
- *
- * @param array{dashboard: Dashboard, source: string}|null $active The active entry.
- *
- * @return array{activeDashboardId: string, dashboardSource: string, layout: array, deepLinkPath: string}
- */
- private function describeActive(?array $active): array
- {
- if ($active === null) {
- return [
- 'activeDashboardId' => '',
- 'dashboardSource' => Dashboard::SOURCE_GROUP,
- 'layout' => [],
- 'deepLinkPath' => '',
- ];
- }
-
- $activeDashboard = $active['dashboard'];
- $placements = $this->widgetService->getDashboardPlacements(
- dashboardId: $activeDashboard->getId()
- );
-
- return [
- 'activeDashboardId' => (string) $activeDashboard->getUuid(),
- 'dashboardSource' => (string) $active['source'],
- 'layout' => array_map(
- callback: function ($placement) {
- return $placement->jsonSerialize();
- },
- array: $placements
- ),
- 'deepLinkPath' => $this->computeDeepLinkPath(dashboard: $activeDashboard),
- ];
- }//end describeActive()
-
- /**
- * Compute the canonical slug-chain for the active dashboard.
- *
- * The frontend reads this to keep the URL in sync (e.g. after a
- * parent rename, a stale bookmarked path is normalised in-place via
- * `history.replaceState`). A failure is logged and degrades to an
- * empty path rather than breaking the render.
- *
- * @param Dashboard $dashboard The active dashboard.
- *
- * @return string The slug-chain, or '' when it could not be computed.
- */
- private function computeDeepLinkPath(Dashboard $dashboard): string
- {
- try {
- return $this->treeService->computePath(
- uuid: (string) $dashboard->getUuid()
- );
- } catch (Throwable $t) {
- $this->logger->warning(
- message: 'launchpad: failed to compute path for active dashboard {uuid}: {message}',
- context: [
- 'uuid' => (string) $dashboard->getUuid(),
- 'message' => $t->getMessage(),
- ]
- );
- }
-
- return '';
- }//end computeDeepLinkPath()
-
- /**
- * Build the workspace content-security policy.
- *
- * REQ-VID: the video widget embeds YouTube/Vimeo players in an
- * `