diff --git a/.coverage-baseline b/.coverage-baseline
new file mode 100644
index 00000000..90737ba3
--- /dev/null
+++ b/.coverage-baseline
@@ -0,0 +1 @@
+49.47
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/.forgejo/workflows/pre-merge-check-strict.yaml b/.forgejo/workflows/pre-merge-check-strict.yaml
index 01aad5ad..863f2b03 100644
--- a/.forgejo/workflows/pre-merge-check-strict.yaml
+++ b/.forgejo/workflows/pre-merge-check-strict.yaml
@@ -1,8 +1,14 @@
-# Pre-merge quality gate — runs composer check:strict + all 19 Hydra gates on every PR.
-# Configured as a required status check in branch protection on `development`
-# to keep the merge button disabled until this workflow passes.
+# Pre-merge quality gate — enforced lint + phpcs + all Hydra gates on every PR.
+# Required status check on protected branches.
#
-# Diff-scoped per ADR-020 so legacy debt never blocks a PR — only new failures fail.
+# Runner/container mirror the proven release-semrel workflow: codeberg-medium +
+# official php:8.3-cli + a base-tooling step. The old code.forgejo.org/oci/ci-php:8.3
+# image 404s ("manifest unknown"), which fast-failed every run at container-pull.
+#
+# The gate runs `composer lint` + `composer phpcs` directly: check:strict's
+# psalm/phpstan/phpmd/test:all are wrapped in `|| echo skipping` so they never
+# affect pass/fail (ADR-022 parks static analysis), and running them on the medium
+# runner OOMs it. lint+phpcs is the identical enforced gate, fast and deterministic.
name: pre-merge-check-strict
@@ -15,67 +21,50 @@ on:
jobs:
quality-gates:
- runs-on: codeberg-small
+ runs-on: codeberg-medium
container:
- # Docker Hub php:8.3-cli (Debian, ships bash) is reliably pullable on the
- # Codeberg runner; the previous code.forgejo.org/oci/ci-php:8.3 image could
- # not be pulled, so this job failed at setup on every PR and never ran.
image: php:8.3-cli
+ timeout-minutes: 15
steps:
- - name: Install toolchain (git, unzip, composer, php-ext, node, python3)
+ - name: Install base tooling
run: |
- set -eu
apt-get update
- # nodejs is required by the JS-based actions/checkout that runs next;
- # php:8.3-cli ships no node, so without this the checkout step exits 127.
- apt-get install -y --no-install-recommends git unzip python3 nodejs curl ca-certificates
- # Precompiled PHP extensions (seconds) instead of docker-php-ext-install,
- # which compiles from source (~1m) and overran the tiny runner's time limit.
- # ext-xsl is required by the edgedesign/phpqa dev dependency in composer.lock.
- curl -sSLf -o /usr/local/bin/install-php-extensions \
- https://github.com/mlocati/docker-php-extension-installer/releases/latest/download/install-php-extensions
- chmod +x /usr/local/bin/install-php-extensions
- install-php-extensions zip mbstring xsl
+ apt-get install -y --no-install-recommends \
+ git curl ca-certificates gnupg jq unzip zip \
+ libzip-dev libpng-dev python3
+ # Node is required by actions/checkout@v4 (a JS action) which runs
+ # inside this php:8.3-cli container; the stock image ships no node.
+ curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
+ apt-get install -y --no-install-recommends nodejs
+ docker-php-ext-install -j"$(nproc)" zip gd
curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
- composer --version
- php -v | head -1
- node --version
- name: Checkout PR
- uses: https://code.forgejo.org/actions/checkout@v4
+ uses: https://github.com/actions/checkout@v4
+ with:
+ fetch-depth: 0
- name: Install composer deps
- run: composer install --no-interaction --no-progress --prefer-dist
+ run: composer install --no-interaction --no-progress --prefer-dist --ignore-platform-reqs
- - name: Run composer check:strict
- run: composer check:strict
+ - name: Run lint + phpcs (the enforced gate)
+ run: |
+ composer lint
+ composer phpcs
- name: Clone Hydra (for gate runner)
- uses: https://code.forgejo.org/actions/checkout@v4
+ uses: https://github.com/actions/checkout@v4
with:
repository: Conduction/hydra
ref: development
path: .hydra
- - name: Run all 19 Hydra gates (diff-scoped per ADR-020)
- env:
- BASE_REF: ${{ github.base_ref }}
+ - name: Run all Hydra gates (diff-scoped per ADR-020)
run: |
- git fetch origin "$BASE_REF":"$BASE_REF" || true
- bash .hydra/scripts/run-hydra-gates.sh --scope-to-diff --base "origin/$BASE_REF" .
+ git fetch origin ${{ github.base_ref }}:${{ github.base_ref }} || true
+ bash .hydra/scripts/run-hydra-gates.sh --scope-to-diff --base origin/${{ github.base_ref }} .
- name: Gate-19 e2e coverage report (informational)
if: always()
run: |
python3 .hydra/scripts/lib/check_e2e_coverage.py . --mode report || true
-
- js-lint:
- runs-on: codeberg-small
- container:
- image: node:20-alpine
- steps:
- - name: Checkout PR
- uses: https://code.forgejo.org/actions/checkout@v4
-
- - name: Run initial-state JS lint guard (REQ-INIT-003)
- run: node ./scripts/lint-initial-state.js
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/.githooks/pre-commit b/.githooks/pre-commit
new file mode 100755
index 00000000..798ae383
--- /dev/null
+++ b/.githooks/pre-commit
@@ -0,0 +1,38 @@
+#!/bin/sh
+# Committed pre-commit hook (activated via `git config core.hooksPath .githooks`,
+# which `npm install` / `composer install` set automatically — see package.json
+# "prepare" and composer.json "post-install-cmd").
+#
+# Regenerates docs/features.json whenever staged changes touch openspec/specs/
+# or the features overlay, so the commercial capability list can never go
+# stale. CI (features-check / features-extract) only VERIFIES — generation
+# happens here, before the commit, never in the pipeline.
+#
+# Best-effort by design: any failure only warns and never blocks the commit —
+# the CI gate is the enforcement backstop.
+
+if git diff --cached --name-only | grep -qE "^openspec/(specs/|features\.overlay\.json)"; then
+ CACHE=".git/extract-features.py"
+ # Fetch the canonical script (single source of truth in ConductionNL/.github);
+ # fall back to a previously cached copy when offline.
+ curl -sf --max-time 10 \
+ https://raw.githubusercontent.com/ConductionNL/.github/main/scripts/extract-features.py \
+ -o "$CACHE" 2>/dev/null || true
+
+ if [ -f "$CACHE" ]; then
+ if command -v python3 >/dev/null 2>&1; then PY="python3";
+ elif command -v py >/dev/null 2>&1; then PY="py -3";
+ else PY="python"; fi
+
+ if $PY "$CACHE" --app-root . >/dev/null 2>&1; then
+ git add docs/features.json
+ echo "pre-commit: docs/features.json regenerated from openspec/specs/."
+ else
+ echo "pre-commit: WARNING — could not regenerate docs/features.json (python or pyyaml missing?). CI features-check will verify." >&2
+ fi
+ else
+ echo "pre-commit: WARNING — could not fetch extract-features.py (offline?). CI features-check will verify." >&2
+ fi
+fi
+
+exit 0
diff --git a/.github/workflows/branch-policy.yml b/.github/workflows/branch-policy.yml
index 31701eb6..1dca5b78 100644
--- a/.github/workflows/branch-policy.yml
+++ b/.github/workflows/branch-policy.yml
@@ -8,6 +8,8 @@ jobs:
check-source-branch:
name: Branch Policy Check
runs-on: ubuntu-latest
+ # Observed over 31 runs: max 0.1 min.
+ timeout-minutes: 10
steps:
- name: Verify source branch
run: |
diff --git a/.github/workflows/branch-protection.yml b/.github/workflows/branch-protection.yml
index e2efcf6e..7ef08cea 100644
--- a/.github/workflows/branch-protection.yml
+++ b/.github/workflows/branch-protection.yml
@@ -2,10 +2,10 @@ name: Branch Protection
on:
pull_request:
- branches:
- - main
- - beta
+ branches: [main, beta]
+
+permissions: {}
jobs:
- check:
- uses: Conduction/.github/.github/workflows/branch-protection.yml@main
+ branch-protection:
+ uses: ConductionNL/.github/.github/workflows/branch-protection.yml@main
diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml
index 37a01099..edc81a64 100644
--- a/.github/workflows/code-quality.yml
+++ b/.github/workflows/code-quality.yml
@@ -7,12 +7,83 @@ on:
branches: [main, beta, development]
workflow_dispatch:
+# Deduplicating a `push` run against the `pull_request` run for the SAME head
+# ref is the point of this block, and for a feature branch it is exactly right:
+# two runs of identical jobs, one of them wasted.
+#
+# It is wrong for `main` and `development`, because the push run there is NOT a
+# duplicate — it is the only carrier of the push-only jobs: "Coverage Baseline
+# Check" (`github.event_name == 'push'`), "SBOM" and "Features Extract". And
+# those two branches always have an open PR whose `head_ref` IS the branch
+# name: the standing "Release: merge development into beta" (#22 here).
+# `github.head_ref` on that PR run and `github.ref_name` on the push run both
+# render `development`, so both landed in the identical group
+# `quality-development`, and `cancel-in-progress` killed whichever started
+# first — always the push run, by a few seconds.
+#
+# Measured on this repo: 14 of the last 20 `development` push runs were
+# cancelled within ~70s of starting — e.g. 31047886687 (41s), 31038167672
+# (68s), 31034263500 (54s), 31031059278 (42s). That duration is the
+# discriminator: the shared workflow's `timeout-minutes: 45` cancellation lands
+# at 45m16s–45m28s, so these are concurrency kills. This repo is the worst hit
+# in the fleet.
+#
+# On the surviving PR run "Coverage Baseline Check" reports `skipped`, which is
+# CORRECT for a pull_request event and renders exactly like a pass. So the gate
+# appears on both runs and executes on neither — a dead gate of the
+# permanently-pending shape.
+#
+# Suffixing only the default-branch push keeps feature-branch dedup untouched
+# (`quality-feature/x` for both events, exactly as before) and gives the two
+# default branches' push runs a lane of their own.
+#
+# Proven in openconnector#1158: its first-ever completed `development` push run
+# (31048998594) executed Coverage Baseline Check, SBOM and Features Extract.
+concurrency:
+ group: quality-${{ github.head_ref || github.ref_name }}${{ (github.event_name == 'push' && (github.ref_name == 'main' || github.ref_name == 'development')) && '-push' || '' }}
+ cancel-in-progress: true
+
+# Permission CEILING for the called quality pipeline. GitHub statically
+# validates the called workflow's declared job permissions against this
+# grant — even for jobs that are disabled — so it must cover the maximum
+# any nested job declares: journeydoc-capture (contents+actions write),
+# update-baseline / features-extract (contents write), and the Quality
+# Report PR comment (issues / pull-requests write).
+permissions:
+ contents: write
+ actions: write
+ issues: write
+ pull-requests: write
+
jobs:
quality:
- uses: Conduction/.github/.github/workflows/quality.yml@main
+ uses: ConductionNL/.github/.github/workflows/quality.yml@main
with:
app-name: launchpad
php-version: "8.3"
+ # Pinned, because the shared workflow's DEFAULT is '["stable31", "stable32"]'
+ # and stable31 CANNOT WORK here. `additional-apps` below installs
+ # openregister, which declares min-version="32" (its
+ # lib/ContextChat/ContentProvider.php implements
+ # OCP\ContextChat\IContentProvider, absent from core before NC32). On NC31
+ # `occ app:enable openregister` fails with
+ # App "Open Register" cannot be installed because it is not compatible
+ # with this version of the server.
+ # and the run continues anyway, because that failure is only a ::warning::.
+ # Every /apps/openregister/... call then returns Nextcloud's HTML 404 page.
+ #
+ # Order matters as much as membership: newman, playwright and
+ # journeydoc-capture all check out `fromJSON(nextcloud-test-refs)[0]` as
+ # their single server, so with the inherited default those three jobs ran
+ # entirely on the version openregister cannot load.
+ #
+ # 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
@@ -28,11 +99,145 @@ jobs:
# postman fixture-id wiring repaired and assertion drift
# resolved. 196 assertions / 0 failures locally.
#
- # Playwright remains disabled — the shared workflow's
- # PHP-built-in-server lifetime is the blocker (cross-repo PR
- # `Conduction/.github#37`). The `tests/e2e/global-setup.ts`
- # `ensureBundleBuilt()` helper is in place so once the shared
- # workflow lands, flipping the gate Just Works.
+ # - Playwright — ENABLED, against the root suite minus a measured
+ # exclusion list, NOT against the four-test `tests/e2e/ci/` subset
+ # it used to run.
+ #
+ # The subset was a deliberate "green floor that grows", and as a
+ # floor it worked. What it could not do is tell anyone the truth
+ # about coverage. gate-19 reads the ROOT `playwright.config.ts`;
+ # the workflow resolves `/playwright.config.ts`
+ # FIRST, so it read a different file — and nothing compared them.
+ # Measured 2026-08-10 (launchpad#82): **CI executed 4 tests while
+ # 113 existed**, and of **117 `@e2e` annotations only 9** were in
+ # files CI ran. gate-19 reported 71 scenarios covered; 4 had an
+ # executing test behind them.
+ #
+ # `tests/e2e` deliberately contains NO `playwright.config.ts`, so
+ # the workflow's fallback selects the root one — the same file
+ # gate-19 parses. The two cannot drift without an edit to that file.
+ # Its `testIgnore` names every excluded spec with the run that
+ # measured it (31367057618: 65 of 80 passed).
enable-phpunit: true
enable-newman: true
newman-environment-path: "tests/integration/local.env.json"
+ # Creates the non-admin account the collection's authorization assertions
+ # need. Without it `{{regularUser}}` stays unresolved, the request arrives
+ # with junk credentials, and `POST /api/role-feature-permissions
+ # non-admin → 403` gets a 400 instead — failing while testing nothing about
+ # authorization.
+ #
+ # A SCRIPT, not an inline command: the shared workflow runs this through
+ # `eval ` UNQUOTED, so any shell metacharacter is parsed at the outer
+ # level. Measured on the Playwright equivalent, `( … ) && ( … )` is a syntax
+ # error and `sh -c '… ; …'` splits at the wrong level. `bash ` is one
+ # word and cannot be mis-parsed. Path is relative to `server/`.
+ newman-seed-command: bash apps/launchpad/tests/integration/seed.sh
+ # OpenRegister must be present for the integration suite to mean anything.
+ # The Newman collection asserts OpenRegister-backed behaviour — the
+ # AppHost observability engine behind /api/health and /api/metrics, and the
+ # dashboard objects behind the v2 manifest — so without it the suite was
+ # measuring a degraded instance. The job previously reported success only
+ # because `composer test:all` ended in `|| echo '…skipping'` and always
+ # exited 0; with that removed, 17 of 220 assertions surfaced as failures.
+ #
+ # `ref: development` is REQUIRED, not cosmetic. The default is `main`, and
+ # `lib/Service/Rbac/ObjectGrantResolver.php` — which
+ # ManifestController::fetchGrantedDashboards() resolves for the shared-
+ # dashboard source — exists only on `development`. Pinned to `main` the
+ # grant lookup would fail soft to owned-only and the suite would quietly
+ # test less than it appears to.
+ additional-apps: '[{"repo":"ConductionNL/openregister","app":"openregister","ref":"development"}]'
+ enable-playwright: true
+ # Names the real suite directory. It holds no config of its own, so the
+ # workflow falls back to the root `playwright.config.ts` — which is the
+ # file gate-19 reads. Same file, same testIgnore, no drift.
+ playwright-test-path: tests/e2e
+ # The grant spec needs a second, non-admin account to be the share
+ # recipient; a grant to yourself proves nothing.
+ #
+ # This was an inline `… user:add … || true`. The `|| true` was there for a
+ # real reason — the suite must survive a re-run against a warm instance —
+ # but it tolerated EVERYTHING, not just "already exists": a rejected
+ # password, a missing occ or a broken database all exited 0 and let
+ # Playwright start with no grantee. The script keeps the tolerance and
+ # narrows it to the postcondition that matters (the account exists
+ # afterwards), which it establishes by asking the instance. See
+ # tests/e2e/seed.test.sh, which asserts both arms — including the one
+ # that must fail.
+ playwright-seed-command: bash apps/launchpad/tests/e2e/seed.sh
+
+ # ── Frontend Check legs ──────────────────────────────────────────────
+ # `frontend-checks` defaults to `[]`, and an empty list means the shared
+ # workflow emits NO "Frontend Check" job at all — so `check:manifest` ran
+ # nowhere while the run still looked complete. It is a self-contained
+ # `node scripts/check-manifest.js`, which is what a leg has to be (each
+ # leg is a fresh job with its own checkout + `npm ci`).
+ # Measured on this tree before enabling: PASSES. It is enabled to keep it
+ # passing, not because it is currently broken.
+ # `test` is NOT listed: "Frontend Tests (unit)" already runs it.
+ frontend-checks: '["check:manifest"]'
+
+ # ── Coverage ratchet ─────────────────────────────────────────────────
+ # `enable-coverage-guard` defaults to FALSE, which is why both
+ # "Coverage Baseline Protection" and "Coverage Baseline Check" have only
+ # ever reported `skipped`. It needs two inputs this repo did not have,
+ # both added in this commit: `scripts/coverage-guard.php` (byte-identical
+ # to openregister's) and `.coverage-baseline` = 49.47, this repo's own
+ # measured coverage (11200 of 22642 statements) read from clover.xml in
+ # the `coverage-report` artifact of run 30911179742.
+ enable-coverage-guard: true
+
+ # ── Hydra mechanical gates ───────────────────────────────────────────
+ # `enable-hydra-gates` defaults to FALSE, so this tier has never executed
+ # here — the job reported `skipped`, which the Quality Report renders
+ # identically to a pass. Pinned to v1.0.1 so a change to the gate package
+ # cannot move this repo's verdict without a commit here.
+ # `enable-axe` deliberately NOT set: a vanilla Nextcloud 34 already carries
+ # serious/critical violations from core's own UI.
+ #
+ # v1.0.1 -> v1.3.0 (ConductionNL/.github#159). Two defects, one bump.
+ #
+ # 1. STALE. v1.0.1 is `f4d9756` (2026-08-03) and predates three gate
+ # fixes, so every Hydra Gates run this repo has ever made executed a
+ # script in which 16 gates reported PASS when their helper never ran
+ # (#147), gate-33 had no axe report to read and never said so (#148),
+ # and gates 6 and 7 reported PASS on an EMPTY scope (#149). A gate
+ # that reports PASS without running emits a tick identical to a real
+ # one, which is why nothing in this repo's history shows it.
+ #
+ # 2. RED. quality.yml is referenced `@main` while this package is
+ # PINNED, so the two can desync — and on 2026-08-05 they did. #164
+ # flipped `hydra-gates-require-full-coverage` to default TRUE, but
+ # the accounting that makes that flag survivable (NOT APPLICABLE, as
+ # distinct from a structural or a wiring gap) ships in the PACKAGE.
+ # So every pin older than `f7eaf2a` now fails the coverage assertion
+ # for gates it has no subject matter for. Measured diff-scoped,
+ # exactly as CI scopes it:
+ # v1.0.1 exit 98 FAIL — "GATES THAT DID NOT RUN: 24 33"
+ # v1.3.0 exit 0 PASS — those gates named NOT APPLICABLE
+ # The old pin was not merely stale, it was failing this repo's CI for
+ # a reason that had nothing to do with this repo.
+ #
+ # 3. DEAD AGAIN, same mechanism, third time (2026-08-06). The pin was
+ # the defect, not its value. quality.yml floats `@main` and executes
+ # gate scripts BY PATH inside the pinned package, so every new gate
+ # added at @main is a path that v1.3.0 does not contain:
+ #
+ # hydra-gates-ref 'v1.3.0' does not contain:
+ # scripts/axe-run.cjs
+ # scripts/lib/check_spec_anchors.py
+ # scripts/lib/check_form_labels.py
+ # scripts/lib/check_license_triangle.py
+ #
+ # The job failed for a reason that, again, had nothing to do with
+ # this repo — CI itself says so: "This is NOT a code-quality finding
+ # about your repository." Bumping the pin to today's tag would only
+ # reset the same expiry clock a fourth time.
+ #
+ # So: the pin is REMOVED, not bumped. The shared workflow defaults this
+ # input to `main` and states "CALLERS SHOULD NOT SET THIS AT ALL"; the
+ # rest of the fleet sets nothing (scholiq notes the omission is
+ # deliberate). Floating both halves keeps caller and callee in step,
+ # which is the only configuration in which a pinned path cannot expire.
+ enable-hydra-gates: true
diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml
index 3608a532..18138278 100644
--- a/.github/workflows/documentation.yml
+++ b/.github/workflows/documentation.yml
@@ -8,6 +8,6 @@ on:
jobs:
deploy:
- uses: Conduction/.github/.github/workflows/documentation.yml@main
+ uses: ConductionNL/.github/.github/workflows/documentation.yml@main
with:
cname: launchpad.conduction.nl
diff --git a/.github/workflows/issue-triage.yml b/.github/workflows/issue-triage.yml
index 969bc65b..a122c160 100644
--- a/.github/workflows/issue-triage.yml
+++ b/.github/workflows/issue-triage.yml
@@ -12,7 +12,7 @@ on:
jobs:
triage:
- uses: Conduction/.github/.github/workflows/issue-triage.yml@feature/openspec-project-sync
+ uses: ConductionNL/.github/.github/workflows/issue-triage.yml@main
with:
app-name: launchpad
backlog-existing: ${{ github.event_name == 'workflow_dispatch' && inputs.backlog-existing || false }}
diff --git a/.github/workflows/openspec-sync.yml b/.github/workflows/openspec-sync.yml
deleted file mode 100644
index d90bcb2b..00000000
--- a/.github/workflows/openspec-sync.yml
+++ /dev/null
@@ -1,15 +0,0 @@
-name: OpenSpec Sync
-
-on:
- push:
- branches: [development]
- paths: ['openspec/**']
- workflow_dispatch:
-
-jobs:
- sync:
- uses: Conduction/.github/.github/workflows/openspec-sync.yml@feature/openspec-project-sync
- with:
- app-name: launchpad
- secrets:
- PROJECT_TOKEN: ${{ secrets.PROJECT_TOKEN }}
diff --git a/.github/workflows/release-beta.yml b/.github/workflows/release-beta.yml
index aeb2d684..0886f288 100644
--- a/.github/workflows/release-beta.yml
+++ b/.github/workflows/release-beta.yml
@@ -7,7 +7,7 @@ on:
jobs:
release:
- uses: Conduction/.github/.github/workflows/release-beta.yml@main
+ uses: ConductionNL/.github/.github/workflows/release-beta.yml@main
with:
app-name: launchpad
secrets: inherit
diff --git a/.github/workflows/release-stable.yml b/.github/workflows/release-stable.yml
index 2236b496..c7b8fa01 100644
--- a/.github/workflows/release-stable.yml
+++ b/.github/workflows/release-stable.yml
@@ -7,7 +7,7 @@ on:
jobs:
release:
- uses: Conduction/.github/.github/workflows/release-stable.yml@main
+ uses: ConductionNL/.github/.github/workflows/release-stable.yml@main
with:
app-name: launchpad
secrets: inherit
diff --git a/.github/workflows/sync-to-beta.yml b/.github/workflows/sync-to-beta.yml
index a8c33439..76a44269 100644
--- a/.github/workflows/sync-to-beta.yml
+++ b/.github/workflows/sync-to-beta.yml
@@ -7,4 +7,4 @@ on:
jobs:
sync:
- uses: Conduction/.github/.github/workflows/sync-to-beta.yml@main
+ uses: ConductionNL/.github/.github/workflows/sync-to-beta.yml@main
diff --git a/.gitignore b/.gitignore
index da994211..33e4d687 100644
--- a/.gitignore
+++ b/.gitignore
@@ -71,3 +71,7 @@ bom-npm.cdx.json
/docs/sendent-analysis.md
/sendent-workspace-main/
/2026.*_sendent-workspace-main.zip
+
+# Local MCP server configuration — carries API keys, must never be committed.
+# A live n8n API key reached the tip tree of 37 local branches before this was added.
+.mcp.json
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/.phpunit.result.cache b/.phpunit.result.cache
deleted file mode 100644
index 1bbcd6f3..00000000
--- a/.phpunit.result.cache
+++ /dev/null
@@ -1 +0,0 @@
-{"version":2,"defects":{"OCA\\LaunchPad\\Tests\\Unit\\Service\\AdminSettingsServiceTest::testGetSettingsReturnsAllExpectedKeys":8,"OCA\\LaunchPad\\Tests\\Unit\\Service\\AdminSettingsServiceTest::testGetSettingsUsesDefaultsWhenEmpty":8,"OCA\\LaunchPad\\Tests\\Unit\\Service\\AdminSettingsServiceTest::testGetSettingsUsesStoredValues":8},"times":{"OCA\\LaunchPad\\Tests\\Unit\\Service\\AdminSettingsServiceTest::testGetSettingsReturnsAllExpectedKeys":0.02,"OCA\\LaunchPad\\Tests\\Unit\\Service\\AdminSettingsServiceTest::testGetSettingsUsesDefaultsWhenEmpty":0.002,"OCA\\LaunchPad\\Tests\\Unit\\Service\\AdminSettingsServiceTest::testGetSettingsUsesStoredValues":0.001}}
\ No newline at end of file
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 00000000..3e19f6a1
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,7 @@
+# Launchpad
+
+This is a standalone repository that happens to live under
+`nextcloud-docker-dev/workspace/server/apps-extra/`. The instructions in the
+parent `server/` directory (its `CLAUDE.md` / `AGENTS.md`) describe the
+Nextcloud server core and its bundled apps — they do **not** govern this repo.
+Treat launchpad's own conventions as authoritative here.
diff --git a/README.md b/README.md
index 63bf1629..d4911e6e 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@
-
+
diff --git a/appinfo/info.xml b/appinfo/info.xml
index f48e0f03..ccb1e9a5 100644
--- a/appinfo/info.xml
+++ b/appinfo/info.xml
@@ -1,7 +1,7 @@
@@ -23,6 +23,9 @@
- **Widget styling** — Customize colors, borders, and titles for each individual widget
- **Compulsory widgets** — Admins can pin important widgets that users cannot remove
- **Full Nextcloud compatibility** — Works with every existing Nextcloud dashboard widget out of the box
+- **Role-based widget access** — Restrict which widget types a group of users may add, resolved from Nextcloud group membership
+- **Dashboard sharing** — Share a dashboard with specific users or groups, or publish a brute-force-protected read-only public link
+- **Group dashboards** — One shared dashboard per group, in addition to personal dashboards
Perfect for organizations that want consistent, curated dashboards for their teams while still giving users freedom to personalize.
@@ -41,69 +44,95 @@ Free and open source under the EUPL-1.2 license.
- **Widget-styling** — Pas kleuren, randen en titels aan voor elke individuele widget
- **Verplichte widgets** — Beheerders kunnen belangrijke widgets vastzetten die gebruikers niet kunnen verwijderen
- **Volledige Nextcloud-compatibiliteit** — Werkt direct met elke bestaande Nextcloud dashboard-widget
+- **Rolgebaseerde widget-toegang** — Beperk welke widget-types een gebruikersgroep mag toevoegen, op basis van Nextcloud-groepslidmaatschap
+- **Dashboards delen** — Deel een dashboard met specifieke gebruikers of groepen, of publiceer een tegen brute-force beveiligde alleen-lezen publieke link
+- **Groepsdashboards** — Eén gedeeld dashboard per groep, naast persoonlijke dashboards
Ideaal voor organisaties die consistente, samengestelde dashboards willen voor hun teams, terwijl gebruikers de vrijheid houden om te personaliseren.
Vrij en open source onder de EUPL-1.2-licentie.
]]>
- 1.0.5-unstable.11
- agpl
+ 1.0.5-unstable.15
+ EUPL-1.2
Conduction
LaunchPad
- https://github.com/ConductionNL/launchpad
- https://github.com/ConductionNL/launchpad
- https://github.com/ConductionNL/launchpad
+ https://codeberg.org/Conduction/launchpad
+ https://codeberg.org/Conduction/launchpad
+ https://codeberg.org/Conduction/launchpad
customization
organization
dashboard
- https://github.com/ConductionNL/launchpad
- https://github.com/ConductionNL/launchpad/discussions
- https://github.com/ConductionNL/launchpad/issues
- https://github.com/ConductionNL/launchpad
-
- https://raw.githubusercontent.com/ConductionNL/launchpad/main/img/app-store.svg
- https://raw.githubusercontent.com/ConductionNL/launchpad/main/img/screenshot.png
-
+ https://codeberg.org/Conduction/launchpad
+ https://codeberg.org/Conduction/launchpad/issues
+ https://codeberg.org/Conduction/launchpad
+
+ https://codeberg.org/Conduction/launchpad/raw/branch/main/img/screenshot-dashboard.png
+ https://codeberg.org/Conduction/launchpad/raw/branch/main/img/screenshot-widgets.png
+ https://codeberg.org/Conduction/launchpad/raw/branch/main/img/screenshot-admin.png
+
+
-
- openregister
+
-
-
-
-
+
OCA\LaunchPad\BackgroundJob\OrphanedDataCleanupJob
+
+ OCA\LaunchPad\BackgroundJob\HealthPingRefreshJob
-
- OCA\LaunchPad\Repair\InitializeActions
-
- OCA\LaunchPad\Repair\SeedRolePermissions
-
- OCA\LaunchPad\Repair\RegisterBackgroundJobs
-
OCA\LaunchPad\Repair\InitializeActions
+
+ OCA\LaunchPad\Repair\ApplyActionBaseline
OCA\LaunchPad\Repair\PurgeOrphanedCascadeData
OCA\LaunchPad\Repair\RegisterBackgroundJobs
+
+ 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
@@ -126,12 +155,25 @@ Vrij en open source onder de EUPL-1.2-licentie.
OCA\LaunchPad\Command\DemoShowcasesListCommand
OCA\LaunchPad\Command\SetupCommand
-
- OCA\LaunchPad\Command\MigrateStorageToGroupFolder
-
- OCA\LaunchPad\Command\ToggleStorageSetting
+
+ OCA\LaunchPad\Settings\LaunchPadAdmin
+ OCA\LaunchPad\Settings\LaunchPadAdminSection
+
+
+
+
+
+ OCA\LaunchPad\Activity\Extension
+
+
+
LaunchPad
@@ -140,11 +182,4 @@ Vrij en open source onder de EUPL-1.2-licentie.
-5
-
-
-
- OCA\LaunchPad\Activity\Extension
-
diff --git a/appinfo/routes.php b/appinfo/routes.php
index 7d8f9c2c..75f26c7d 100644
--- a/appinfo/routes.php
+++ b/appinfo/routes.php
@@ -3,8 +3,8 @@
declare(strict_types=1);
/**
- * SPDX-FileCopyrightText: 2024 LaunchPad Contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
+ * SPDX-FileCopyrightText: 2024 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
*/
return [
@@ -86,6 +86,21 @@
// dashboard does not exist.
['name' => 'dashboardApi#viewEvent', 'url' => '/api/dashboards/{uuid}/view-event', 'verb' => 'POST',
'requirements' => ['uuid' => '[A-Za-z0-9\-]+']],
+
+ // REQ-TANLT-002: record a tile click. Authed users only; the
+ // controller short-circuits silently when the user has opted out
+ // or analytics is globally disabled (same reused REQ-ANLT-003/004/005
+ // gates as the dashboard view-event route above). Returns HTTP 204
+ // on success, 404 when the placement does not exist. `placementId`
+ // is constrained to digits so the router never confuses it with a
+ // literal segment.
+ ['name' => 'tileAnalytics#recordClick', 'url' => '/api/tile-click/{placementId}', 'verb' => 'POST',
+ 'requirements' => ['placementId' => '\d+']],
+ // REQ-TANLT-003: lets the frontend hook know whether tracking is
+ // currently active for the calling user, so it can suppress the
+ // record call without re-implementing the gate logic client-side.
+ ['name' => 'tileAnalytics#config', 'url' => '/api/tile-analytics/config', 'verb' => 'GET'],
+
// REQ-DASH-026: nested dashboard tree.
['name' => 'dashboardApi#tree', 'url' => '/api/dashboards/tree', 'verb' => 'GET'],
// REQ-DASH-027: slug-chain path resolution. The {path} placeholder
@@ -150,12 +165,18 @@
'url' => '/api/dashboards/{uuid}/public-shares/{id}', 'verb' => 'DELETE',
'requirements' => ['uuid' => '[A-Za-z0-9\-]+', 'id' => '\d+']],
// Public (anonymous) share render and unlock (REQ-PSHR-004, REQ-PSHR-005).
- // Both are #[PublicPage] + #[NoCSRFRequired] on the controller methods.
- // Registered BEFORE the deep-link catch-all at the bottom.
- ['name' => 'publicShare#show', 'url' => '/s/{token}', 'verb' => 'GET',
+ // All #[PublicPage] + #[NoCSRFRequired] on the controller methods.
+ // Registered BEFORE the deep-link catch-all at the bottom. `/s/{token}`
+ // serves the anonymous read-only HTML page (page#publicShare); the SPA it
+ // boots fetches its data from `/s/{token}/data` (publicShare#show). The
+ // more-specific /data + /unlock segments are declared before the bare
+ // token page route so they win in matching.
+ ['name' => 'publicShare#show', 'url' => '/s/{token}/data', 'verb' => 'GET',
'requirements' => ['token' => '[A-Za-z0-9]+']],
['name' => 'publicShare#unlock', 'url' => '/s/{token}/unlock', 'verb' => 'POST',
'requirements' => ['token' => '[A-Za-z0-9]+']],
+ ['name' => 'page#publicShare', 'url' => '/s/{token}', 'verb' => 'GET',
+ 'requirements' => ['token' => '[A-Za-z0-9]+']],
// Kiosk playlist management endpoints (REQ-KIOSK-002). Owner-or-admin,
// `#[NoAdminRequired]` + service-layer per-dashboard guards. The literal
@@ -204,6 +225,21 @@
'url' => '/api/dashboards/{uuid}/reactions', 'verb' => 'POST',
'requirements' => ['uuid' => '[A-Za-z0-9\-]+']],
+ // Mandatory-read acknowledgement endpoints (REQ-ACK-002..006).
+ // The `/report/{announcementKey}/csv` route is registered BEFORE the
+ // plain report route so the `/csv` suffix is matched first, and both
+ // come before the literal `/pending` and root POST routes.
+ ['name' => 'acknowledgement#reportCsv',
+ 'url' => '/api/acknowledgements/report/{announcementKey}/csv', 'verb' => 'GET',
+ 'requirements' => ['announcementKey' => '[A-Za-z0-9\-]+']],
+ ['name' => 'acknowledgement#report',
+ 'url' => '/api/acknowledgements/report/{announcementKey}', 'verb' => 'GET',
+ 'requirements' => ['announcementKey' => '[A-Za-z0-9\-]+']],
+ ['name' => 'acknowledgement#pending',
+ 'url' => '/api/acknowledgements/pending', 'verb' => 'GET'],
+ ['name' => 'acknowledgement#acknowledge',
+ 'url' => '/api/acknowledgements', 'verb' => 'POST'],
+
// Dashboard versioning endpoints (REQ-VERS-001..009).
// `{uuid}` is the dashboard UUID; `{versionNumber}` is the integer
// version number. Routes are registered BEFORE the personal
@@ -257,6 +293,11 @@
['name' => 'ruleApi#updateRule', 'url' => '/api/rules/{ruleId}', 'verb' => 'PUT'],
['name' => 'ruleApi#deleteRule', 'url' => '/api/rules/{ruleId}', 'verb' => 'DELETE'],
+ // conditional-visibility-editor: read-only, non-persisting
+ // "preview as audience/date" — #[NoAdminRequired] on
+ // VisibilityPreviewController::preview().
+ ['name' => 'visibilityPreview#preview', 'url' => '/api/visibility/preview', 'verb' => 'POST'],
+
// Role-feature permissions (REQ-RFP-001..010). Admin-only — the
// controller calls `requireAdmin()` on every method. Sits with
// the rest of the admin-scoped routes; the duplicate
@@ -298,6 +339,11 @@
// streamer is intentionally NOT under `/api/...` because it
// returns binary bytes, not a JSON envelope.
['name' => 'resource#upload', 'url' => '/api/resources', 'verb' => 'POST'],
+ // Raw multipart upload — REQ-RES-014. Admin-only (same security as the
+ // base64 endpoint above); accepts a single `file` multipart field with
+ // no base64 so large images/GIFs never become a huge in-browser string.
+ // Registered before the wildcard `/resource/{filename}` streamer.
+ ['name' => 'resource#uploadMultipart', 'url' => '/api/resources/upload', 'verb' => 'POST'],
// Resource listing — REQ-RES-007. Logged-in user only (no admin
// gate); the listed names are already referenced from rendered
// dashboards so admin gating would lock dashboards out of their
@@ -326,6 +372,10 @@
// `{uuid}/preview-image` suffix matches first.
['name' => 'admin#uploadTemplatePreviewImage', 'url' => '/api/admin/templates/{uuid}/preview-image', 'verb' => 'POST',
'requirements' => ['uuid' => '[A-Za-z0-9\-]+']],
+ // Admin template re-sync (REQ-RESYNC-001). Registered BEFORE the
+ // `/api/admin/templates/{id}` wildcard routes so the literal
+ // `{id}/resync` suffix matches first, same as preview-image above.
+ ['name' => 'admin#resyncTemplate', 'url' => '/api/admin/templates/{id}/resync', 'verb' => 'POST'],
['name' => 'admin#getTemplate', 'url' => '/api/admin/templates/{id}', 'verb' => 'GET'],
['name' => 'admin#updateTemplate', 'url' => '/api/admin/templates/{id}', 'verb' => 'PUT'],
['name' => 'admin#deleteTemplate', 'url' => '/api/admin/templates/{id}', 'verb' => 'DELETE'],
@@ -427,6 +477,18 @@
['name' => 'analytics#dashboardDetail', 'url' => '/api/admin/analytics/dashboards/{uuid}', 'verb' => 'GET',
'requirements' => ['uuid' => '[A-Za-z0-9\-]+']],
+ // Tile usage-analytics admin endpoints (REQ-TANLT-004..005) — a
+ // strict downward extension of the dashboard view-analytics admin
+ // endpoints above. All admin-only via ADR-023 action authorization
+ // inside the controller. The literal `top` and `export` segments
+ // and the `by-dashboard` prefix precede any wildcard so the router
+ // never confuses them.
+ ['name' => 'tileAnalytics#topTiles', 'url' => '/api/admin/analytics/tiles/top', 'verb' => 'GET'],
+ ['name' => 'tileAnalytics#exportCsv', 'url' => '/api/admin/analytics/tiles/export', 'verb' => 'GET'],
+ ['name' => 'tileAnalytics#dashboardBreakdown',
+ 'url' => '/api/admin/analytics/tiles/by-dashboard/{uuid}', 'verb' => 'GET',
+ 'requirements' => ['uuid' => '[A-Za-z0-9\-]+']],
+
// Background feed-refresh trigger (REQ-FRJ-010). Admin-only via
// runtime `IGroupManager::isAdmin` check inside the controller.
['name' => 'admin#refreshFeedsNow', 'url' => '/api/admin/feeds/refresh-now', 'verb' => 'POST'],
@@ -479,6 +541,41 @@
'url' => '/api/admin/demo-showcases/{id}', 'verb' => 'DELETE',
'requirements' => ['id' => '[a-z0-9\-]+']],
+ // Weather widget — cached reading for one placement (REQ-WEATHER-001).
+ // View-time ACL guarded in the controller; never returns the provider
+ // API key or raw provider URL.
+ ['name' => 'weather#show', 'url' => '/api/weather/{placementId}', 'verb' => 'GET',
+ 'requirements' => ['placementId' => '\d+']],
+
+ // Live-data tile widget — cached, resolved value for one placement
+ // (REQ-LIVETILE-003). View-time ACL guarded in the controller; never
+ // returns the source URL, headers, or credentials. The two
+ // multi-segment routes below are registered BEFORE the single-segment
+ // `{placementId}` route so a literal `connector/status` /
+ // `validate-source` path is never mistaken for a numeric placement id.
+ ['name' => 'liveTile#connectorStatus', 'url' => '/api/livetile/connector/status', 'verb' => 'GET'],
+ ['name' => 'liveTile#validateSource', 'url' => '/api/livetile/validate-source', 'verb' => 'POST'],
+ ['name' => 'liveTile#show', 'url' => '/api/livetile/{placementId}', 'verb' => 'GET',
+ 'requirements' => ['placementId' => '\d+']],
+
+ // Iframe-embed widget — save-time allow-list validation
+ // (REQ-IFRAME-002). No per-placement data endpoint: the browser
+ // embeds the target URL directly, config lives in `widgetContent`.
+ ['name' => 'iframe#validateUrl', 'url' => '/api/iframe/validate-url', 'verb' => 'POST'],
+ // Server-side framing-refusal check (REQ-IFRAME-003) — the browser
+ // cannot detect an X-Frame-Options / frame-ancestors block, so the
+ // widget asks the server before rendering the iframe.
+ ['name' => 'iframe#checkFramable', 'url' => '/api/iframe/framable', 'verb' => 'POST'],
+
+ // Service health ping — cached online/offline/degraded badge for one
+ // placement (REQ-HPING-003). View-time ACL guarded in the controller;
+ // never returns the health URL, headers, or upstream response body.
+ // The literal `validate` route is registered BEFORE the single-segment
+ // `{placementId}` route so it is never mistaken for a numeric placement id.
+ ['name' => 'healthPing#validate', 'url' => '/api/health-ping/validate', 'verb' => 'POST'],
+ ['name' => 'healthPing#show', 'url' => '/api/health-ping/{placementId}', 'verb' => 'GET',
+ 'requirements' => ['placementId' => '\d+']],
+
// Resolve a dashboard's canonical slug-chain path (used by the
// frontend for outbound URL sync after a sidebar switch).
// Registered BEFORE the catch-all deep-link route so the literal
diff --git a/composer.json b/composer.json
index 82cd9233..ad9f87e3 100644
--- a/composer.json
+++ b/composer.json
@@ -1,101 +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": "^31.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": {
- "OCP\\": "vendor/nextcloud/ocp/OCP/",
- "NCU\\": "vendor/nextcloud/ocp/NCU/",
- "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",
- "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": "vendor/bin/phpmd lib text phpmd.xml",
- "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 || echo 'Psalm not installed, skipping...'",
- "phpstan": "./vendor/bin/phpstan analyse --memory-limit=1G",
- "test": "phpunit --configuration phpunit.xml",
- "test:unit": "./vendor/bin/phpunit --configuration phpunit.xml --colors=always || echo 'Tests require Nextcloud environment, skipping...'",
- "test:all": "./vendor/bin/phpunit --configuration phpunit.xml --colors=always || echo 'Tests require Nextcloud environment, skipping...'",
- "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 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"
- ]
- },
- "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 vendor/conduction/hydra-gates/quality-config/phpmd-unusedparams.xml --baseline-file phpmd.baseline.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 a7676d2c..02eb061c 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": "b298f765de93d95487463f84a88603b1",
+ "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.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/ConductionNL/.github.git",
+ "reference": "9b9896abf87167e97b821d8ee86c5422f0b32e80"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/ConductionNL/.github/zipball/9b9896abf87167e97b821d8ee86c5422f0b32e80",
+ "reference": "9b9896abf87167e97b821d8ee86c5422f0b32e80",
+ "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.3"
+ },
+ "time": "2026-08-12T22:32:31+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,35 +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": "v31.0.9",
+ "version": "v34.0.2",
"source": {
"type": "git",
"url": "https://github.com/nextcloud-deps/ocp.git",
- "reference": "abd32429d794ede1d92b7b0a88a1070371c907b5"
+ "reference": "81cbb2c594afe0fa978885bf7accd0440199520a"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/nextcloud-deps/ocp/zipball/abd32429d794ede1d92b7b0a88a1070371c907b5",
- "reference": "abd32429d794ede1d92b7b0a88a1070371c907b5",
+ "url": "https://api.github.com/repos/nextcloud-deps/ocp/zipball/81cbb2c594afe0fa978885bf7accd0440199520a",
+ "reference": "81cbb2c594afe0fa978885bf7accd0440199520a",
"shasum": ""
},
"require": {
- "php": "~8.1 || ~8.2 || ~8.3 || ~8.4",
+ "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"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-stable31": "31.0.0-dev"
+ "dev-stable34": "34.0.0-dev"
}
},
"notification-url": "https://packagist.org/downloads/",
@@ -1863,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/v31.0.9"
+ "source": "https://github.com/nextcloud-deps/ocp/tree/v34.0.2"
},
- "time": "2025-07-31T00:57:37+00:00"
+ "time": "2026-07-16T01:28:13+00:00"
},
{
"name": "nikic/php-parser",
@@ -2461,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": {
@@ -2507,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",
@@ -2595,16 +2700,16 @@
},
{
"name": "phpcsstandards/phpcsutils",
- "version": "1.2.2",
+ "version": "1.2.3",
"source": {
"type": "git",
"url": "https://github.com/PHPCSStandards/PHPCSUtils.git",
- "reference": "c216317e96c8b3f5932808f9b0f1f7a14e3bbf55"
+ "reference": "5f35d9408c54d7b529501f3c688b6eae562aea1f"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/PHPCSStandards/PHPCSUtils/zipball/c216317e96c8b3f5932808f9b0f1f7a14e3bbf55",
- "reference": "c216317e96c8b3f5932808f9b0f1f7a14e3bbf55",
+ "url": "https://api.github.com/repos/PHPCSStandards/PHPCSUtils/zipball/5f35d9408c54d7b529501f3c688b6eae562aea1f",
+ "reference": "5f35d9408c54d7b529501f3c688b6eae562aea1f",
"shasum": ""
},
"require": {
@@ -2684,7 +2789,7 @@
"type": "thanks_dev"
}
],
- "time": "2025-12-08T14:27:58+00:00"
+ "time": "2026-07-27T10:28:41+00:00"
},
{
"name": "phpdocumentor/reflection-common",
@@ -3747,6 +3852,111 @@
},
"time": "2019-01-08T18:20:26+00:00"
},
+ {
+ "name": "psr/http-client",
+ "version": "1.0.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/php-fig/http-client.git",
+ "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90",
+ "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.0 || ^8.0",
+ "psr/http-message": "^1.0 || ^2.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.0.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Psr\\Http\\Client\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "PHP-FIG",
+ "homepage": "https://www.php-fig.org/"
+ }
+ ],
+ "description": "Common interface for HTTP clients",
+ "homepage": "https://github.com/php-fig/http-client",
+ "keywords": [
+ "http",
+ "http-client",
+ "psr",
+ "psr-18"
+ ],
+ "support": {
+ "source": "https://github.com/php-fig/http-client"
+ },
+ "time": "2023-09-23T14:17:50+00:00"
+ },
+ {
+ "name": "psr/http-message",
+ "version": "2.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/php-fig/http-message.git",
+ "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71",
+ "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.2 || ^8.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.0.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Psr\\Http\\Message\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "PHP-FIG",
+ "homepage": "https://www.php-fig.org/"
+ }
+ ],
+ "description": "Common interface for HTTP messages",
+ "homepage": "https://github.com/php-fig/http-message",
+ "keywords": [
+ "http",
+ "http-message",
+ "psr",
+ "psr-7",
+ "request",
+ "response"
+ ],
+ "support": {
+ "source": "https://github.com/php-fig/http-message/tree/2.0"
+ },
+ "time": "2023-04-04T09:54:51+00:00"
+ },
{
"name": "psr/log",
"version": "3.0.2",
@@ -3803,18 +4013,19 @@
"source": {
"type": "git",
"url": "https://github.com/Roave/SecurityAdvisories.git",
- "reference": "87a281378fdad8f5926efe259f6ca72e7a395e68"
+ "reference": "3c9ad688ad8826203588ec49363f73f4deb590c1"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/87a281378fdad8f5926efe259f6ca72e7a395e68",
- "reference": "87a281378fdad8f5926efe259f6ca72e7a395e68",
+ "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/3c9ad688ad8826203588ec49363f73f4deb590c1",
+ "reference": "3c9ad688ad8826203588ec49363f73f4deb590c1",
"shasum": ""
},
"conflict": {
"3f/pygmentize": "<1.2",
"adaptcms/adaptcms": "<=1.3",
- "admidio/admidio": "<5.0.8",
+ "adawolfa/isdoc": "<1.4.3|>=1.5,<1.5.1|>=1.6,<1.6.1",
+ "admidio/admidio": "<=5.0.11",
"adodb/adodb-php": "<=5.22.9",
"aheinze/cockpit": "<2.2",
"aimeos/ai-admin-graphql": ">=2022.04.1,<2022.10.10|>=2023.04.1,<2023.10.6|>=2024.04.1,<2024.07.2",
@@ -3825,6 +4036,7 @@
"aimeos/aimeos-core": ">=2022.04.1,<2022.10.17|>=2023.04.1,<2023.10.17|>=2024.04.1,<2024.04.7",
"aimeos/aimeos-laravel": "==2021.10",
"aimeos/aimeos-typo3": "<19.10.12|>=20,<20.10.5",
+ "aimeos/pagible": "<0.10.4",
"airesvsg/acf-to-rest-api": "<=3.1",
"akaunting/akaunting": "<2.1.13",
"akeneo/pim-community-dev": "<5.0.119|>=6,<6.0.53",
@@ -3847,8 +4059,10 @@
"aoe/restler": "<1.7.1",
"apache-solr-for-typo3/solr": "<2.8.3",
"apereo/phpcas": "<1.6",
- "api-platform/core": "<3.4.17|>=4,<4.0.22|>=4.1,<4.1.5",
+ "api-platform/core": "<4.1.29|>=4.2,<4.2.25|>=4.3,<4.3.8",
"api-platform/graphql": "<3.4.17|>=4,<4.0.22|>=4.1,<4.1.5",
+ "api-platform/hal": ">=4,<4.1.29|>=4.2,<4.2.25|>=4.3,<4.3.8",
+ "api-platform/json-api": ">=4,<4.1.29|>=4.2,<4.2.25|>=4.3,<4.3.8",
"appwrite/server-ce": "<=1.2.1",
"arc/web": "<3",
"area17/twill": "<1.2.5|>=2,<2.5.3",
@@ -3861,21 +4075,21 @@
"austintoddj/canvas": "<=3.4.2",
"auth0/auth0-php": ">=3.3,<=8.18",
"auth0/login": "<=7.20",
- "auth0/symfony": "<=5.7",
+ "auth0/symfony": "<=5.8",
"auth0/wordpress": "<=5.5",
- "automad/automad": "<2.0.0.0-alpha5",
+ "automad/automad": "<=2.0.0.0-beta27",
"automattic/jetpack": "<9.8",
"awesome-support/awesome-support": "<=6.0.7",
"aws/aws-sdk-php": "<=3.371.3",
"ayacoo/redirect-tab": "<2.1.2|>=3,<3.1.7|>=4,<4.0.5",
- "azuracast/azuracast": "<=0.23.3",
+ "azuracast/azuracast": "<=0.23.5",
"b13/seo_basics": "<0.8.2",
"backdrop/backdrop": "<=1.32",
- "backpack/crud": "<3.4.9",
+ "backpack/crud": "<4.0.63|>=4.1,<4.1.69|>=5,<5.0.13",
"backpack/filemanager": "<2.0.2|>=3,<3.0.9",
"bacula-web/bacula-web": "<9.7.1",
"badaso/core": "<=2.9.11",
- "bagisto/bagisto": "<2.3.10",
+ "bagisto/bagisto": "<=2.3.15",
"barrelstrength/sprout-base-email": "<1.2.7",
"barrelstrength/sprout-forms": "<3.9",
"barryvdh/laravel-translation-manager": "<0.6.8",
@@ -3888,6 +4102,7 @@
"bedita/bedita": "<4",
"bednee/cooluri": "<1.0.30",
"bigfork/silverstripe-form-capture": ">=3,<3.1.1",
+ "billabear/billabear": "<=2025.01.03",
"billz/raspap-webgui": "<3.3.6",
"binarytorch/larecipe": "<2.8.1",
"bk2k/bootstrap-package": ">=7.1,<7.1.2|>=8,<8.0.8|>=9,<9.0.4|>=9.1,<9.1.3|>=10,<10.0.10|>=11,<11.0.3",
@@ -3908,13 +4123,14 @@
"bytefury/crater": "<6.0.2",
"cachethq/cachet": "<2.5.1",
"cadmium-org/cadmium-cms": "<=0.4.9",
- "cakephp/cakephp": "<3.10.3|>=4,<4.0.10|>=4.1,<4.1.4|>=4.2,<4.2.12|>=4.3,<4.3.11|>=4.4,<4.4.10|>=5.2.10,<5.2.12|==5.3",
+ "cakephp/authentication": "<3.3.6|>=4,<4.1.1",
+ "cakephp/cakephp": "<4.5.11|>=4.6,<4.6.4|>=5,<5.1.7|>=5.2,<5.2.13|>=5.3,<5.3.6",
"cakephp/database": ">=4.2,<4.2.12|>=4.3,<4.3.11|>=4.4,<4.4.10",
"cardgate/magento2": "<2.0.33",
"cardgate/woocommerce": "<=3.1.15",
- "cart2quote/module-quotation": ">=4.1.6,<=4.4.5|>=5,<5.4.4",
+ "cart2quote/module-quotation": ">=4.1.6,<4.4.6|>=5,<5.4.4",
"cart2quote/module-quotation-encoded": ">=4.1.6,<=4.4.5|>=5,<5.4.4",
- "cartalyst/sentry": "<=2.1.6",
+ "cartalyst/sentry": "<2.1.7",
"catfan/medoo": "<1.7.5",
"causal/oidc": "<4",
"cecil/cecil": "<7.47.1",
@@ -3923,41 +4139,42 @@
"cesnet/simplesamlphp-module-proxystatistics": "<3.1",
"chriskacerguis/codeigniter-restserver": "<=2.7.1",
"chrome-php/chrome": "<1.14",
- "ci4-cms-erp/ci4ms": "<0.31.5",
+ "ci4-cms-erp/ci4ms": "<=0.31.8",
"civicrm/civicrm-core": ">=4.2,<4.2.9|>=4.3,<4.3.3",
"ckeditor/ckeditor": "<4.25",
"clickstorm/cs-seo": ">=6,<6.8|>=7,<7.5|>=8,<8.4|>=9,<9.3",
"co-stack/fal_sftp": "<0.2.6",
- "cockpit-hq/cockpit": "<2.14",
- "code16/sharp": "<9.20",
+ "cockpit-hq/cockpit": "<=2.14",
+ "code16/sharp": "<9.22.3",
"codeception/codeception": "<3.1.3|>=4,<4.1.22",
"codeigniter/framework": "<3.1.10",
- "codeigniter4/framework": "<4.6.2",
+ "codeigniter4/framework": "<4.7.2",
"codeigniter4/shield": "<1.0.0.0-beta8",
"codiad/codiad": "<=2.8.4",
"codingms/additional-tca": ">=1.7,<1.15.17|>=1.16,<1.16.9",
"codingms/modules": "<4.3.11|>=5,<5.7.4|>=6,<6.4.2|>=7,<7.5.5",
"commerceteam/commerce": ">=0.9.6,<0.9.9",
"components/jquery": ">=1.0.3,<3.5",
- "composer/composer": "<2.2.27|>=2.3,<2.9.6",
- "concrete5/concrete5": "<9.4.8",
+ "composer/composer": "<2.2.29|>=2.3,<2.10.2",
+ "concrete5/concrete5": "<9.5.2",
"concrete5/core": "<8.5.8|>=9,<9.1",
"contao-components/mediaelement": ">=2.14.2,<2.21.1",
"contao/comments-bundle": ">=2,<4.13.40|>=5.0.0.0-RC1-dev,<5.3.4",
- "contao/contao": ">=3,<3.5.37|>=4,<4.4.56|>=4.5,<4.13.56|>=5,<5.3.38|>=5.4.0.0-RC1-dev,<5.6.1",
+ "contao/contao": ">=3,<3.5.37|>=4,<4.4.56|>=4.5,<5.3.48|>=5.4,<5.7.9",
"contao/core": "<3.5.39",
- "contao/core-bundle": "<4.13.57|>=5,<5.3.42|>=5.4,<5.6.5",
+ "contao/core-bundle": "<5.3.48|>=5.4,<5.7.9",
"contao/listing-bundle": ">=3,<=3.5.30|>=4,<4.4.8",
"contao/managed-edition": "<=1.5",
- "coreshop/core-shop": "<4.1.9",
+ "coreshop/core-shop": "<4.1.9|==5",
"corveda/phpsandbox": "<1.3.5",
"cosenary/instagram": "<=2.3",
+ "cotonti/cotonti": "<=1",
"couleurcitron/tarteaucitron-wp": "<0.3",
"cpsit/typo3-mailqueue": "<0.4.5|>=0.5,<0.5.2",
"craftcms/aws-s3": ">=2.0.2,<=2.2.4",
"craftcms/azure-blob": ">=2.0.0.0-beta1,<=2.1",
- "craftcms/cms": "<=4.17.8|>=5,<5.9.15",
- "craftcms/commerce": ">=4,<4.11|>=5,<5.6",
+ "craftcms/cms": "<4.18|>=5,<5.10",
+ "craftcms/commerce": ">=4,<=4.11.1|>=5,<=5.6.4",
"craftcms/composer": ">=4.0.0.0-RC1-dev,<=4.10|>=5.0.0.0-RC1-dev,<=5.5.1",
"craftcms/craft": ">=3.5,<=4.16.17|>=5.0.0.0-RC1-dev,<=5.8.21",
"craftcms/google-cloud": ">=2.0.0.0-beta1,<=2.2",
@@ -3975,6 +4192,7 @@
"david-garcia/phpwhois": "<=4.3.1",
"dbrisinajumi/d2files": "<1",
"dcat/laravel-admin": "<=2.1.3|==2.2.0.0-beta|==2.2.2.0-beta",
+ "dedoc/scramble": ">=0.13.2,<0.13.22",
"derhansen/fe_change_pwd": "<2.0.5|>=3,<3.0.3",
"derhansen/sf_event_mgt": "<4.3.1|>=5,<5.1.1|>=7,<7.4",
"desperado/xml-bundle": "<=0.1.7",
@@ -3996,8 +4214,8 @@
"doctrine/mongodb-odm": "<1.0.2",
"doctrine/mongodb-odm-bundle": "<3.0.1",
"doctrine/orm": ">=1,<1.2.4|>=2,<2.4.8|>=2.5,<2.5.1|>=2.8.3,<2.8.4",
- "dolibarr/dolibarr": "<=22.0.4",
- "dompdf/dompdf": "<2.0.4",
+ "dolibarr/dolibarr": "<=23.0.2",
+ "dompdf/dompdf": "<3.1.6",
"doublethreedigital/guest-entries": "<3.1.2",
"dreamfactory/df-core": "<1.0.4",
"drupal-pattern-lab/unified-twig-extensions": "<=0.1",
@@ -4011,7 +4229,7 @@
"drupal/commerce_alphabank_redirect": "<1.0.3",
"drupal/commerce_eurobank_redirect": "<2.1.1",
"drupal/config_split": "<1.10|>=2,<2.0.2",
- "drupal/core": ">=6,<6.38|>=7,<7.103|>=8,<10.4.9|>=10.5,<10.5.6|>=11,<11.1.9|>=11.2,<11.2.8",
+ "drupal/core": ">=6,<6.38|>=7,<7.103|>=8,<10.5.10|>=10.6,<10.6.9|>=11,<11.2.12|>=11.3,<11.3.10",
"drupal/core-recommended": ">=7,<7.102|>=8,<10.2.11|>=10.3,<10.3.9|>=11,<11.0.8",
"drupal/currency": "<3.5",
"drupal/drupal": ">=5,<5.11|>=6,<6.38|>=7,<7.102|>=8,<10.2.11|>=10.3,<10.3.9|>=11,<11.0.8",
@@ -4038,10 +4256,11 @@
"drupal/umami_analytics": "<1.0.1",
"duncanmcclean/guest-entries": "<3.1.2",
"dweeves/magmi": "<=0.7.24",
+ "easycorp/easyadmin-bundle": ">=4,<4.29.10|>=5,<5.0.13",
"ec-cube/ec-cube": "<2.4.4|>=2.11,<=2.17.1|>=3,<=3.0.18.0-patch4|>=4,<=4.3.1",
"ecodev/newsletter": "<=4",
"ectouch/ectouch": "<=2.7.2",
- "egroupware/egroupware": "<23.1.20260113|>=26.0.20251208,<26.0.20260113",
+ "egroupware/egroupware": "<23.1.20260601|>=26.0.20251208,<26.5.20260507",
"elefant/cms": "<2.0.7",
"elgg/elgg": "<3.3.24|>=4,<4.0.5",
"elijaa/phpmemcacheadmin": "<=1.3",
@@ -4053,6 +4272,7 @@
"erusev/parsedown": "<1.7.2",
"ether/logs": "<3.0.4",
"evolutioncms/evolution": "<=3.2.3",
+ "evoweb/sf-register": "<13.2.4|>=14,<14.0.2",
"exceedone/exment": "<4.4.3|>=5,<5.0.3",
"exceedone/laravel-admin": "<2.2.3|==3",
"ezsystems/demobundle": ">=5.4,<5.4.6.1-dev",
@@ -4075,15 +4295,16 @@
"ezsystems/repository-forms": ">=2.3,<2.3.2.1-dev|>=2.5,<2.5.15",
"ezyang/htmlpurifier": "<=4.2",
"facade/ignition": "<1.16.15|>=2,<2.4.2|>=2.5,<2.5.2",
- "facturascripts/facturascripts": "<2025.81",
+ "facturascripts/facturascripts": "<=2026.2",
"fastly/magento2": "<1.2.26",
"feehi/cms": "<=2.1.1",
"feehi/feehicms": "<=2.1.1",
"fenom/fenom": "<=2.12.1",
- "filament/actions": ">=3.2,<3.2.123",
- "filament/filament": ">=4,<4.3.1",
- "filament/infolists": ">=3,<3.2.115",
- "filament/tables": ">=3,<3.2.115|>=4,<4.8.5|>=5,<5.3.5",
+ "filament/actions": ">=3.2,<3.2.123|>=4,<=4.11.3|>=5,<=5.6.3",
+ "filament/filament": ">=3,<=3.3.51|>=4,<4.11.5|>=5,<5.6.5",
+ "filament/forms": ">=3,<=3.3.52",
+ "filament/infolists": ">=3,<3.2.115|>=4,<=4.11.4|>=5,<=5.6.4",
+ "filament/tables": ">=3,<=3.3.50|>=4,<=4.11.4|>=5,<=5.6.4",
"filegator/filegator": "<7.8",
"filp/whoops": "<2.1.13",
"fineuploader/php-traditional-server": "<=1.2.2",
@@ -4098,6 +4319,7 @@
"flarum/nicknames": "<1.8.3",
"flarum/sticky": ">=0.1.0.0-beta14,<=0.1.0.0-beta15",
"flarum/tags": "<=0.1.0.0-beta13",
+ "flightphp/core": "<3.18.1",
"floriangaerber/magnesium": "<0.3.1",
"fluidtypo3/vhs": "<5.1.1",
"fof/byobu": ">=0.3.0.0-beta2,<1.1.7",
@@ -4116,19 +4338,22 @@
"friendsofsymfony1/symfony1": ">=1.1,<1.5.19",
"friendsoftypo3/mediace": ">=7.6.2,<7.6.5",
"friendsoftypo3/openid": ">=4.5,<4.5.31|>=4.7,<4.7.16|>=6,<6.0.11|>=6.1,<6.1.6",
+ "friendsoftypo3/tt-address": "<8.1.2|>=9,<9.1.1|>=10,<10.0.1",
"froala/wysiwyg-editor": "<=4.3",
"frosh/adminer-platform": "<2.2.1",
- "froxlor/froxlor": "<2.3.6",
+ "froxlor/froxlor": "<2.3.7",
"frozennode/administrator": "<=5.0.12",
"fuel/core": "<1.8.1",
- "funadmin/funadmin": "<=7.1.0.0-RC4",
+ "funadmin/funadmin": "<=7.1.0.0-RC6",
"gaoming13/wechat-php-sdk": "<=1.10.2",
"genix/cms": "<=1.1.11",
- "georgringer/news": "<1.3.3",
+ "georgringer/news": "<10.0.4|>=11,<11.4.4|>=12,<12.3.2|>=13,<13.0.2|>=14,<14.0.3",
"geshi/geshi": "<=1.0.9.1",
"getformwork/formwork": "<=2.3.3",
- "getgrav/grav": "<1.11.0.0-beta1",
- "getkirby/cms": "<5.4",
+ "getgrav/grav": "<=2.0.0.0-RC8",
+ "getgrav/grav-plugin-api": "<1.0.0.0-beta15",
+ "getgrav/grav-plugin-form": "<9.1",
+ "getkirby/cms": "<=4.9.3|>=5,<=5.4.3",
"getkirby/kirby": "<3.9.8.3-dev|>=3.10,<3.10.1.2-dev|>=4,<4.7.1",
"getkirby/panel": "<2.5.14",
"getkirby/starterkit": "<=3.7.0.2",
@@ -4143,11 +4368,12 @@
"gp247/core": "<1.1.24",
"gree/jose": "<2.2.1",
"gregwar/rst": "<1.0.3",
- "grumpydictator/firefly-iii": "<6.1.17|>=6.4.23,<=6.5",
+ "grumpydictator/firefly-iii": "<=6.6.2",
"gugoan/economizzer": "<=0.9.0.0-beta1",
- "guzzlehttp/guzzle": "<6.5.8|>=7,<7.4.5",
+ "guzzlehttp/guzzle": "<7.15.1",
+ "guzzlehttp/guzzle-services": "<1.5.4",
"guzzlehttp/oauth-subscriber": "<0.8.1",
- "guzzlehttp/psr7": "<1.9.1|>=2,<2.4.5",
+ "guzzlehttp/psr7": "<2.12.3",
"haffner/jh_captcha": "<=2.1.3|>=3,<=3.0.2",
"handcraftedinthealps/goodby-csv": "<1.4.3",
"harvesthq/chosen": "<1.8.7",
@@ -4176,6 +4402,7 @@
"illuminate/cookie": ">=4,<=4.0.11|>=4.1,<6.18.31|>=7,<7.22.4",
"illuminate/database": "<6.20.26|>=7,<7.30.5|>=8,<8.40",
"illuminate/encryption": ">=4,<=4.0.11|>=4.1,<=4.1.31|>=4.2,<=4.2.22|>=5,<=5.0.35|>=5.1,<=5.1.46|>=5.2,<=5.2.45|>=5.3,<=5.3.31|>=5.4,<=5.4.36|>=5.5,<5.5.40|>=5.6,<5.6.15",
+ "illuminate/mail": ">=9,<12.60|>=13,<13.10",
"illuminate/view": "<6.20.42|>=7,<7.30.6|>=8,<8.75",
"imdbphp/imdbphp": "<=5.1.1",
"impresscms/impresscms": "<=1.4.5",
@@ -4187,8 +4414,9 @@
"innologi/typo3-appointments": "<2.0.6",
"intelliants/subrion": "<4.2.2",
"inter-mediator/inter-mediator": "==5.5",
+ "intercom/intercom-php": "==5.0.2",
"invoiceninja/invoiceninja": "<5.13.4",
- "ipl/web": "<0.10.1",
+ "ipl/web": "<=0.10.2|>=0.11,<=0.13",
"islandora/crayfish": "<4.1",
"islandora/islandora": ">=2,<2.4.1",
"ivankristianto/phpwhois": "<=4.3",
@@ -4200,6 +4428,7 @@
"jasig/phpcas": "<1.3.3",
"jbartels/wec-map": "<3.0.3",
"jcbrand/converse.js": "<3.3.3",
+ "jleehr/canto-saas-api": "<=2",
"joedolson/my-calendar": "<3.7.7",
"joelbutcher/socialstream": "<5.6|>=6,<6.2",
"johnbillion/query-monitor": "<3.20.4",
@@ -4226,23 +4455,24 @@
"kelvinmo/simplexrd": "<3.1.1",
"kevinpapst/kimai2": "<1.16.7",
"khodakhah/nodcms": "<=3.4.1",
- "kimai/kimai": "<2.54",
+ "kimai/kimai": "<2.59",
"kitodo/presentation": "<3.2.3|>=3.3,<3.3.4",
"klaviyo/magento2-extension": ">=1,<3",
- "knplabs/knp-snappy": "<=1.4.2",
+ "knplabs/knp-snappy": "<=1.7",
"kohana/core": "<3.3.3",
"koillection/koillection": "<1.6.12",
"krayin/laravel-crm": "<=2.2",
"kreait/firebase-php": ">=3.2,<3.8.1",
"kumbiaphp/kumbiapp": "<=1.1.1",
"la-haute-societe/tcpdf": "<6.2.22",
+ "laktak/hjson": "<2.3",
"laminas/laminas-diactoros": "<2.18.1|==2.19|==2.20|==2.21|==2.22|==2.23|>=2.24,<2.24.2|>=2.25,<2.25.2",
"laminas/laminas-form": "<2.17.1|>=3,<3.0.2|>=3.1,<3.1.1",
"laminas/laminas-http": "<2.14.2",
"lara-zeus/artemis": ">=1,<=1.0.6",
"lara-zeus/dynamic-dashboard": ">=3,<=3.0.1",
"laravel/fortify": "<1.11.1",
- "laravel/framework": "<10.48.29|>=11,<11.44.1|>=12,<12.1.1",
+ "laravel/framework": "<12.61.1|>=13,<13.12",
"laravel/laravel": ">=5.4,<5.4.22",
"laravel/passport": ">=13,<13.7.1",
"laravel/pulse": "<1.3.1",
@@ -4261,7 +4491,7 @@
"librenms/librenms": "<26.3",
"liftkit/database": "<2.13.2",
"lightsaml/lightsaml": "<1.3.5",
- "limesurvey/limesurvey": "<6.15.4",
+ "limesurvey/limesurvey": "<=7.0.0.0-beta1",
"livehelperchat/livehelperchat": "<=3.91",
"livewire-filemanager/filemanager": "<=1.0.4",
"livewire/livewire": "<2.12.7|>=3.0.0.0-beta1,<3.6.4",
@@ -4284,21 +4514,23 @@
"maikuolan/phpmussel": ">=1,<1.6",
"mainwp/mainwp": "<=4.4.3.3",
"manogi/nova-tiptap": "<=3.2.6",
- "mantisbt/mantisbt": "<2.28.1",
+ "mantisbt/mantisbt": "<=2.28.3",
"marcwillmann/turn": "<0.3.3",
"markhuot/craftql": "<=1.3.7",
"marshmallow/nova-tiptap": "<5.7",
"matomo/matomo": "<1.11",
"matyhtf/framework": "<3.0.6",
- "mautic/core": "<5.2.10|>=6,<6.0.8|>=7.0.0.0-alpha,<7.0.1",
+ "mautic/core": "<5.2.11|>=6,<6.0.9|>=7,<7.1.2",
"mautic/core-lib": ">=1.0.0.0-beta,<4.4.13|>=5.0.0.0-alpha,<5.1.1",
"mautic/grapes-js-builder-bundle": ">=4,<4.4.18|>=5,<5.2.9|>=6,<6.0.7",
"maximebf/debugbar": "<1.19",
+ "mckenziearts/livewire-markdown-editor": "<1.3",
"mdanter/ecc": "<2",
"mediawiki/abuse-filter": "<1.39.9|>=1.40,<1.41.3|>=1.42,<1.42.2",
"mediawiki/cargo": "<3.8.3",
"mediawiki/core": "<1.39.5|==1.40",
"mediawiki/data-transfer": ">=1.39,<1.39.11|>=1.41,<1.41.3|>=1.42,<1.42.2",
+ "mediawiki/maps": "<12.1.3",
"mediawiki/matomo": "<2.4.3",
"mediawiki/semantic-media-wiki": "<4.0.2",
"mehrwert/phpmyadmin": "<3.2",
@@ -4318,6 +4550,8 @@
"miniorange/miniorange-saml": "<1.4.3",
"miraheze/ts-portal": "<=33",
"mittwald/typo3_forum": "<1.2.1",
+ "mix/mix": ">=2,<=2.2.17",
+ "mmc/ceselector": "<3.0.3|>=4,<4.0.2|>=5,<5.0.1|>=6,<6.0.1",
"mobiledetect/mobiledetectlib": "<2.8.32",
"modx/revolution": "<=3.1",
"mojo42/jirafeau": "<4.4",
@@ -4330,6 +4564,7 @@
"movim/moxl": ">=0.8,<=0.10",
"movingbytes/social-network": "<=1.2.1",
"mpdf/mpdf": "<=7.1.7",
+ "mtdowling/jmespath.php": "<2.9.1",
"munkireport/comment": "<4",
"munkireport/managedinstalls": "<2.6",
"munkireport/munki_facts": "<1.5",
@@ -4337,6 +4572,7 @@
"munkireport/softwareupdate": "<1.6",
"mustache/mustache": ">=2,<2.14.1",
"mwdelaney/wp-enable-svg": "<=0.2",
+ "nabeel/phpvms": "<7.0.6",
"namshi/jose": "<2.2",
"nasirkhan/laravel-starter": "<11.11",
"nategood/httpful": "<1",
@@ -4356,11 +4592,11 @@
"nilsteampassnet/teampass": "<3.1.3.1-dev",
"nitsan/ns-backup": "<13.0.1",
"nonfiction/nterchange": "<4.1.1",
- "notrinos/notrinos-erp": "<=0.7",
+ "notrinos/notrinos-erp": "<=1",
"noumo/easyii": "<=0.9",
"novaksolutions/infusionsoft-php-sdk": "<1",
"novosga/novosga": "<=2.2.12",
- "nukeviet/nukeviet": "<4.5.02",
+ "nukeviet/nukeviet": "<4.6.00",
"nyholm/psr7": "<1.6.1",
"nystudio107/craft-seomatic": "<3.4.12",
"nzedb/nzedb": "<0.8",
@@ -4377,7 +4613,7 @@
"open-web-analytics/open-web-analytics": "<1.8.1",
"opencart/opencart": ">=0",
"openid/php-openid": "<2.3",
- "openmage/magento-lts": "<20.17",
+ "openmage/magento-lts": "<=20.17",
"opensolutions/vimbadmin": "<=3.0.15",
"opensource-workshop/connect-cms": "<1.41.1|>=2,<2.41.1",
"orchid/platform": ">=8,<14.43",
@@ -4388,8 +4624,10 @@
"oro/customer-portal": ">=4.1,<=4.1.13|>=4.2,<=4.2.10|>=5,<=5.0.11|>=5.1,<=5.1.3",
"oro/platform": ">=1.7,<1.7.4|>=3.1,<3.1.29|>=4.1,<4.1.17|>=4.2,<=4.2.10|>=5,<=5.0.12|>=5.1,<=5.1.3",
"oveleon/contao-cookiebar": "<1.16.3|>=2,<2.1.3",
- "oxid-esales/oxideshop-ce": "<=7.0.5",
+ "oxid-esales/oxideshop-ce": "<4.5|>=6,<6.14.4",
+ "oxid-esales/oxideshop-metapackage-ce": ">=6,<6.5.5",
"oxid-esales/paymorrow-module": ">=1,<1.0.2|>=2,<2.0.1",
+ "oxid-esales/smarty-component": "<1.0.1",
"packbackbooks/lti-1-3-php-library": "<5",
"padraic/humbug_get_contents": "<1.1.2",
"pagarme/pagarme-php": "<3",
@@ -4398,6 +4636,7 @@
"paragonie/random_compat": "<2",
"paragonie/sodium_compat": "<1.24|>=2,<2.5",
"passbolt/passbolt_api": "<4.6.2",
+ "paymenter/paymenter": "<=1.5.4",
"paypal/adaptivepayments-sdk-php": "<=3.9.2",
"paypal/invoice-sdk-php": "<=3.9",
"paypal/merchant-sdk-php": "<3.12",
@@ -4410,23 +4649,26 @@
"pegasus/google-for-jobs": "<1.5.1|>=2,<2.1.1",
"personnummer/personnummer": "<3.0.2",
"ph7software/ph7builder": "<=17.9.1",
- "phanan/koel": "<5.1.4",
+ "phanan/koel": "<=9.7",
+ "pheditor/pheditor": "<2.0.8",
"phenx/php-svg-lib": "<0.5.2",
"php-censor/php-censor": "<2.0.13|>=2.1,<2.1.5",
"php-mod/curl": "<2.3.2",
- "phpbb/phpbb": "<3.3.11",
+ "php-standard-library/h2": ">=6.1,<6.1.2|>=6.2,<6.2.1",
+ "php-standard-library/php-standard-library": ">=6.1,<6.1.2|>=6.2,<6.2.1",
+ "phpbb/phpbb": "<3.3.16|==4.0.0.0-alpha1",
"phpems/phpems": ">=6,<=6.1.3",
"phpfastcache/phpfastcache": "<6.1.5|>=7,<7.1.2|>=8,<8.0.7",
"phpmailer/phpmailer": "<6.5",
"phpmussel/phpmussel": ">=1,<1.6",
"phpmyadmin/phpmyadmin": "<5.2.2",
- "phpmyfaq/phpmyfaq": "<=4.1",
+ "phpmyfaq/phpmyfaq": "<4.1.4",
"phpoffice/common": "<0.2.9",
"phpoffice/math": "<=0.2",
"phpoffice/phpexcel": "<=1.8.2",
- "phpoffice/phpspreadsheet": "<=1.30.3|>=2,<=2.1.15|>=2.2,<=2.4.4|>=3,<=3.10.4|>=4,<=5.6",
+ "phpoffice/phpspreadsheet": "<=1.30.5|>=2,<=2.1.17|>=2.2,<=2.4.6|>=3,<=3.10.6|>=4,<=5.8",
"phppgadmin/phppgadmin": "<=7.13",
- "phpseclib/phpseclib": "<2.0.53|>=3,<3.0.51",
+ "phpseclib/phpseclib": "<=2.0.54|>=3,<=3.0.53",
"phpservermon/phpservermon": "<3.6",
"phpsysinfo/phpsysinfo": "<3.4.3",
"phpunit/phpunit": "<8.5.52|>=9,<9.6.33|>=10,<10.5.62|>=11,<11.5.50|>=12,<12.5.8|>=12.5.21,<12.5.22|>=13.1.5,<13.1.6",
@@ -4435,14 +4677,14 @@
"phpxmlrpc/phpxmlrpc": "<4.9.2",
"phraseanet/phraseanet": "==4.0.3",
"pi/pi": "<=2.5",
- "pimcore/admin-ui-classic-bundle": "<=1.7.15|>=2.0.0.0-RC1-dev,<=2.2.2",
+ "pimcore/admin-ui-classic-bundle": "<1.7.18|>=2.0.0.0-RC1-dev,<=2.3.5",
"pimcore/customer-management-framework-bundle": "<4.2.1",
"pimcore/data-hub": "<1.2.4",
"pimcore/data-importer": "<1.8.9|>=1.9,<1.9.3",
"pimcore/demo": "<10.3",
"pimcore/ecommerce-framework-bundle": "<1.0.10",
"pimcore/perspective-editor": "<1.5.1",
- "pimcore/pimcore": "<=11.5.14.1|>=12,<12.3.3",
+ "pimcore/pimcore": "<=12.3.8|>=2026.1,<2026.1.3",
"pimcore/web2print-tools-bundle": "<=5.2.1|>=6.0.0.0-RC1-dev,<=6.1",
"piwik/piwik": "<1.11",
"pixelfed/pixelfed": "<0.12.5",
@@ -4450,25 +4692,27 @@
"pocketmine/bedrock-protocol": "<8.0.2",
"pocketmine/pocketmine-mp": "<5.42.1",
"pocketmine/raklib": ">=0.14,<0.14.6|>=0.15,<0.15.1",
+ "pontedilana/php-weasyprint": "<=2.5.1",
+ "poweradmin/poweradmin": "<4.2.5|>=4.3,<4.3.4",
"pressbooks/pressbooks": "<5.18",
"prestashop/autoupgrade": ">=4,<4.10.1",
"prestashop/blockreassurance": "<=5.1.3",
"prestashop/blockwishlist": ">=2,<2.1.1",
"prestashop/contactform": ">=1.0.1,<4.3",
"prestashop/gamification": "<2.3.2",
- "prestashop/prestashop": "<8.2.5|>=9.0.0.0-alpha1,<9.1",
+ "prestashop/prestashop": "<8.2.6|>=9,<9.1.1",
"prestashop/productcomments": "<5.0.2",
- "prestashop/ps_checkout": "<4.4.1|>=5,<5.0.5",
+ "prestashop/ps_checkout": "<5.3",
"prestashop/ps_contactinfo": "<=3.3.2",
"prestashop/ps_emailsubscription": "<2.6.1",
- "prestashop/ps_facetedsearch": "<3.4.1",
+ "prestashop/ps_facetedsearch": "<4.0.4",
"prestashop/ps_linklist": "<3.1",
"privatebin/privatebin": "<1.4|>=1.5,<1.7.4|>=1.7.7,<2.0.3",
"processwire/processwire": "<=3.0.255",
- "propel/propel": ">=2.0.0.0-alpha1,<=2.0.0.0-alpha7",
- "propel/propel1": ">=1,<=1.7.1",
+ "propel/propel": ">=2.0.0.0-alpha1,<2.0.0.0-alpha8",
+ "propel/propel1": ">=1,<1.7.2",
"psy/psysh": "<=0.11.22|>=0.12,<=0.12.18",
- "pterodactyl/panel": "<1.12.1",
+ "pterodactyl/panel": "<=1.12.4",
"ptheofan/yii2-statemachine": ">=2.0.0.0-RC1-dev,<=2",
"ptrofimov/beanstalk_console": "<1.7.14",
"pubnub/pubnub": "<6.1",
@@ -4488,13 +4732,14 @@
"rap2hpoutre/laravel-log-viewer": "<0.13",
"react/http": ">=0.7,<1.9",
"really-simple-plugins/complianz-gdpr": "<6.4.2",
- "redaxo/source": "<5.21",
+ "redaxo/source": "<5.21.1",
"remdex/livehelperchat": "<4.29",
"renolit/reint-downloadmanager": "<4.0.2|>=5,<5.0.1",
"reportico-web/reportico": "<=8.1",
"rhukster/dom-sanitizer": "<1.0.10",
"rmccue/requests": ">=1.6,<1.8",
"roadiz/documents": "<2.3.42|>=2.4,<2.5.44|>=2.6,<2.6.28|>=2.7,<2.7.9",
+ "roadiz/openid": "<2.3.43|>=2.5,<2.5.45|>=2.6,<2.6.31|>=2.7,<2.7.18",
"robrichards/xmlseclibs": "<3.1.5",
"roots/soil": "<4.1",
"roundcube/roundcubemail": "<1.5.10|>=1.6,<1.6.11|>=1.7.0.0-beta,<1.7.0.0-RC5-dev",
@@ -4510,24 +4755,26 @@
"scheb/two-factor-bundle": "<3.26|>=4,<4.11",
"sensiolabs/connect": "<4.2.3",
"serluck/phpwhois": "<=4.2.6",
- "setasign/fpdi": "<2.6.4",
+ "setasign/fpdi": "<2.6.7",
"sfroemken/url_redirect": "<=1.2.1",
"sheng/yiicms": "<1.2.1",
- "shopware/core": "<6.6.10.15-dev|>=6.7,<6.7.8.1-dev",
- "shopware/platform": "<6.6.10.15-dev|>=6.7,<6.7.8.1-dev",
+ "shopper/cart": "<2.8",
+ "shopper/framework": "<2.8",
+ "shopware/core": "<6.6.10.18-dev|>=6.7,<6.7.10.1-dev",
+ "shopware/platform": "<6.6.10.18-dev|>=6.7,<6.7.10.1-dev",
"shopware/production": "<=6.3.5.2",
- "shopware/shopware": "<=5.7.17|>=6.4.6,<6.6.10.10-dev|>=6.7,<6.7.6.1-dev",
+ "shopware/shopware": "<=6.3.5.2|>=6.4.6,<6.6.10.10-dev|>=6.7,<6.7.6.1-dev",
"shopware/storefront": "<6.6.10.10-dev|>=6.7,<6.7.5.1-dev",
"shopxo/shopxo": "<=6.4",
- "showdoc/showdoc": "<2.10.4",
+ "showdoc/showdoc": "<3.8.1",
"shuchkin/simplexlsx": ">=1.0.12,<1.1.13",
"silverstripe-australia/advancedreports": ">=1,<=2",
"silverstripe/admin": "<1.13.19|>=2,<2.1.8",
"silverstripe/assets": "<2.4.5|>=3,<3.1.3",
- "silverstripe/cms": "<4.11.3",
+ "silverstripe/cms": "<6.2.1",
"silverstripe/comments": ">=1.3,<3.1.1",
- "silverstripe/forum": "<=0.6.1|>=0.7,<=0.7.3",
- "silverstripe/framework": "<5.3.23",
+ "silverstripe/forum": "<0.6.2|>=0.7,<0.7.4",
+ "silverstripe/framework": "<6.2.2",
"silverstripe/graphql": ">=2,<2.0.5|>=3,<3.8.2|>=4,<4.3.7|>=5,<5.1.3",
"silverstripe/hybridsessions": ">=1,<2.4.1|>=2.5,<2.5.1",
"silverstripe/recipe-cms": ">=4.5,<4.5.3",
@@ -4537,13 +4784,15 @@
"silverstripe/silverstripe-omnipay": "<2.5.2|>=3,<3.0.2|>=3.1,<3.1.4|>=3.2,<3.2.1",
"silverstripe/subsites": ">=2,<2.6.1",
"silverstripe/taxonomy": ">=1.3,<1.3.1|>=2,<2.0.1",
- "silverstripe/userforms": "<3|>=5,<5.4.2",
+ "silverstripe/userforms": "<6.4.9|>=7,<7.0.7|>=7.1,<7.1.1",
+ "silverstripe/versioned": "<3.2.1",
"silverstripe/versioned-admin": ">=1,<1.11.1",
"simogeo/filemanager": "<=2.5",
"simple-updates/phpwhois": "<=1",
- "simplesamlphp/saml2": "<=4.16.15|>=5.0.0.0-alpha1,<=5.0.0.0-alpha19",
- "simplesamlphp/saml2-legacy": "<=4.16.15",
- "simplesamlphp/simplesamlphp": "<1.18.6",
+ "simplesamlphp/saml2": "<=4.20.2|>=5,<5.0.6|>=6,<6.2.1",
+ "simplesamlphp/saml2-legacy": "<=4.20.2",
+ "simplesamlphp/simplesamlphp": "<=2.4.6|>=2.5,<=2.5.1",
+ "simplesamlphp/simplesamlphp-module-casserver": "<=7.0.2",
"simplesamlphp/simplesamlphp-module-infocard": "<1.0.1",
"simplesamlphp/simplesamlphp-module-openid": "<1",
"simplesamlphp/simplesamlphp-module-openidprovider": "<0.9",
@@ -4555,19 +4804,23 @@
"sjbr/sr-freecap": "<2.4.6|>=2.5,<2.5.3",
"sjbr/static-info-tables": "<2.3.1",
"slim/psr7": "<1.4.1|>=1.5,<1.5.1|>=1.6,<1.6.1",
- "slim/slim": "<2.6",
+ "slim/slim": "<2.6|>=4.4,<=4.15.1",
"slub/slub-events": "<3.0.3",
"smarty/smarty": "<4.5.3|>=5,<5.1.1",
- "snipe/snipe-it": "<8.3.7",
+ "snipe/snipe-it": "<=8.6.1",
"socalnick/scn-social-auth": "<1.15.2",
"socialiteproviders/steam": "<1.1",
+ "solidinvoice/solidinvoice": "<=2.3.15",
"solspace/craft-freeform": "<4.1.29|>=5,<=5.14.6",
"soosyze/soosyze": "<=2",
"spatie/browsershot": "<5.0.5",
"spatie/image-optimizer": "<1.7.3",
+ "spatie/laravel-medialibrary": "<11.23",
+ "spatie/schema-org": ">=3.23.1,<3.23.2|>=4,<4.0.2",
"spencer14420/sp-php-email-handler": "<1",
"spipu/html2pdf": "<5.2.8",
"spiral/roadrunner": "<2025.1",
+ "spomky-labs/otphp": "<11.4.3",
"spoon/library": "<1.4.1",
"spoonity/tcpdf": "<6.2.22",
"squizlabs/php_codesniffer": ">=1,<2.8.1|>=3,<3.0.1",
@@ -4576,14 +4829,14 @@
"starcitizentools/short-description": ">=4,<4.0.1",
"starcitizentools/tabber-neue": ">=1.9.1,<2.7.2|>=3,<3.1.1",
"starcitizenwiki/embedvideo": "<=4",
- "statamic/cms": "<5.73.20|>=6,<6.13",
+ "statamic/cms": "<5.74|>=6,<6.20.3",
"stormpath/sdk": "<9.9.99",
- "studio-42/elfinder": "<2.1.67",
+ "studio-42/elfinder": "<=2.1.67",
"studiomitte/friendlycaptcha": "<0.1.4",
"subhh/libconnect": "<7.0.8|>=8,<8.1",
"sukohi/surpass": "<1",
"sulu/form-bundle": ">=2,<2.5.3",
- "sulu/sulu": "<2.6.22|>=3,<3.0.5",
+ "sulu/sulu": "<=2.6.22|>=3,<=3.0.5",
"sumocoders/framework-user-bundle": "<1.4",
"superbig/craft-audit": "<3.0.2",
"svewap/a21glossary": "<=0.4.10",
@@ -4593,50 +4846,65 @@
"sylius/admin-bundle": ">=1,<1.0.17|>=1.1,<1.1.9|>=1.2,<1.2.2",
"sylius/grid": ">=1,<1.1.19|>=1.2,<1.2.18|>=1.3,<1.3.13|>=1.4,<1.4.5|>=1.5,<1.5.1",
"sylius/grid-bundle": "<1.10.1",
+ "sylius/mollie-plugin": "<2.2.8|>=3,<3.2.4|>=3.3,<3.3.1",
"sylius/paypal-plugin": "<1.6.2|>=1.7,<1.7.2|>=2,<2.0.2",
"sylius/resource-bundle": ">=1,<1.3.14|>=1.4,<1.4.7|>=1.5,<1.5.2|>=1.6,<1.6.4",
- "sylius/sylius": "<1.9.12|>=1.10,<1.10.16|>=1.11,<1.11.17|>=1.12,<=1.12.22|>=1.13,<=1.13.14|>=1.14,<=1.14.17|>=2,<=2.0.15|>=2.1,<=2.1.11|>=2.2,<=2.2.2",
+ "sylius/sylius": "<1.9.12|>=1.10,<1.10.16|>=1.11,<1.11.17|>=1.12,<=1.12.22|>=1.13,<=1.13.14|>=1.14,<=1.14.17|>=2,<2.0.18|>=2.1,<2.1.15|>=2.2,<2.2.6",
+ "symbiote/silverstripe-advancedworkflow": "<6.4.5|>=7,<7.1.3|>=7.2,<7.2.1",
"symbiote/silverstripe-multivaluefield": ">=3,<3.1",
"symbiote/silverstripe-queuedjobs": ">=3,<3.0.2|>=3.1,<3.1.4|>=4,<4.0.7|>=4.1,<4.1.2|>=4.2,<4.2.4|>=4.3,<4.3.3|>=4.4,<4.4.3|>=4.5,<4.5.1|>=4.6,<4.6.4",
"symbiote/silverstripe-seed": "<6.0.3",
"symbiote/silverstripe-versionedfiles": "<=2.0.3",
"symfont/process": ">=0",
- "symfony/cache": ">=3.1,<3.4.35|>=4,<4.2.12|>=4.3,<4.3.8",
+ "symfony/cache": "<5.4.52|>=6,<6.4.40|>=7,<7.4.12|>=8,<8.0.12",
"symfony/dependency-injection": ">=2,<2.0.17|>=2.7,<2.7.51|>=2.8,<2.8.50|>=3,<3.4.26|>=4,<4.1.12|>=4.2,<4.2.7",
+ "symfony/dom-crawler": "<5.4.52|>=6,<6.4.40|>=7,<7.4.12|>=8,<8.0.12",
"symfony/error-handler": ">=4.4,<4.4.4|>=5,<5.0.4",
"symfony/form": ">=2.3,<2.3.35|>=2.4,<2.6.12|>=2.7,<2.7.50|>=2.8,<2.8.49|>=3,<3.4.20|>=4,<4.0.15|>=4.1,<4.1.9|>=4.2,<4.2.1",
"symfony/framework-bundle": ">=2,<2.3.18|>=2.4,<2.4.8|>=2.5,<2.5.2|>=2.7,<2.7.51|>=2.8,<2.8.50|>=3,<3.4.26|>=4,<4.1.12|>=4.2,<4.2.7|>=5.3.14,<5.3.15|>=5.4.3,<5.4.4|>=6.0.3,<6.0.4",
- "symfony/http-client": ">=4.3,<5.4.47|>=6,<6.4.15|>=7,<7.1.8",
- "symfony/http-foundation": "<5.4.50|>=6,<6.4.29|>=7,<7.3.7",
- "symfony/http-kernel": ">=2,<4.4.50|>=5,<5.4.20|>=6,<6.0.20|>=6.1,<6.1.12|>=6.2,<6.2.6",
+ "symfony/html-sanitizer": ">=6.1,<6.4.41|>=7,<7.4.13|>=8,<8.0.13",
+ "symfony/http-client": ">=4.3,<5.4.53|>=6,<6.4.15|>=7,<7.1.8",
+ "symfony/http-foundation": "<5.4.50|>=6,<6.4.41|>=7,<7.4.13|>=8,<8.0.13",
+ "symfony/http-kernel": ">=2,<4.4.50|>=5,<5.4.20|>=6,<6.0.20|>=6.1,<6.1.12|>=6.2,<6.2.6|>=7.4,<7.4.12|>=8,<8.0.12",
"symfony/intl": ">=2.7,<2.7.38|>=2.8,<2.8.31|>=3,<3.2.14|>=3.3,<3.3.13",
+ "symfony/json-path": ">=7.3,<7.4.12|>=8,<8.0.12",
+ "symfony/lox24-notifier": ">=7.1,<7.4.12|>=8,<8.0.12",
+ "symfony/mailer": "<5.4.52|>=6,<6.4.40|>=7,<7.4.12|>=8,<8.0.12",
+ "symfony/mailjet-mailer": ">=6.4,<6.4.40|>=7,<7.4.12|>=8,<8.0.12",
+ "symfony/mailomat-mailer": ">=7.2,<7.4.13|>=8,<8.0.13",
+ "symfony/mailtrap-mailer": ">=7.2,<7.4.12|>=8,<8.0.12",
"symfony/maker-bundle": ">=1.27,<1.29.2|>=1.30,<1.31.1",
- "symfony/mime": ">=4.3,<4.3.8",
+ "symfony/mime": "<5.4.52|>=6,<6.4.40|>=7,<7.4.12|>=8,<8.0.12",
+ "symfony/monolog-bridge": "<5.4.52|>=6,<6.4.40|>=7,<7.4.12|>=8,<8.0.12",
"symfony/phpunit-bridge": ">=2.8,<2.8.50|>=3,<3.4.26|>=4,<4.1.12|>=4.2,<4.2.7",
- "symfony/polyfill": ">=1,<1.10",
+ "symfony/polyfill": ">=1,<1.10|>=1.17.1,<1.38.1",
+ "symfony/polyfill-intl-idn": ">=1.17.1,<1.38.1",
"symfony/polyfill-php55": ">=1,<1.10",
"symfony/process": "<5.4.51|>=6,<6.4.33|>=7,<7.1.7|>=7.3,<7.3.11|>=7.4,<7.4.5|>=8,<8.0.5",
"symfony/proxy-manager-bridge": ">=2.7,<2.7.51|>=2.8,<2.8.50|>=3,<3.4.26|>=4,<4.1.12|>=4.2,<4.2.7",
- "symfony/routing": ">=2,<2.0.19",
- "symfony/runtime": ">=5.3,<5.4.46|>=6,<6.4.14|>=7,<7.1.7",
+ "symfony/routing": "<5.4.53|>=6,<6.4.41|>=7,<7.4.13|>=8,<8.0.13",
+ "symfony/runtime": ">=5.3,<5.4.52|>=6,<6.4.40|>=7,<7.4.12|>=8,<8.0.12",
"symfony/security": ">=2,<2.7.51|>=2.8,<3.4.49|>=4,<4.4.24|>=5,<5.2.8",
"symfony/security-bundle": ">=2,<4.4.50|>=5,<5.4.20|>=6,<6.0.20|>=6.1,<6.1.12|>=6.2,<6.4.10|>=7,<7.0.10|>=7.1,<7.1.3",
"symfony/security-core": ">=2.4,<2.6.13|>=2.7,<2.7.9|>=2.7.30,<2.7.32|>=2.8,<3.4.49|>=4,<4.4.24|>=5,<5.2.9",
"symfony/security-csrf": ">=2.4,<2.7.48|>=2.8,<2.8.41|>=3,<3.3.17|>=3.4,<3.4.11|>=4,<4.0.11",
"symfony/security-guard": ">=2.8,<3.4.48|>=4,<4.4.23|>=5,<5.2.8",
- "symfony/security-http": ">=2.3,<2.3.41|>=2.4,<2.7.51|>=2.8,<2.8.50|>=3,<3.4.26|>=4,<4.2.12|>=4.3,<4.3.8|>=4.4,<4.4.7|>=5,<5.0.7|>=5.1,<5.2.8|>=5.3,<5.4.47|>=6,<6.4.15|>=7,<7.1.8",
+ "symfony/security-http": "<5.4.53|>=6,<6.4.41|>=7,<7.4.13|>=8,<8.0.13",
"symfony/serializer": ">=2,<2.0.11|>=4.1,<4.4.35|>=5,<5.3.12",
- "symfony/symfony": "<5.4.51|>=6,<6.4.33|>=7,<7.3.11|>=7.4,<7.4.5|>=8,<8.0.5",
+ "symfony/symfony": "<5.4.53|>=6,<6.4.41|>=7,<7.4.13|>=8,<8.0.13",
"symfony/translation": ">=2,<2.0.17",
- "symfony/twig-bridge": ">=2,<4.4.51|>=5,<5.4.31|>=6,<6.3.8",
- "symfony/ux-autocomplete": "<2.11.2",
- "symfony/ux-live-component": "<2.25.1",
+ "symfony/twig-bridge": ">=2,<4.4.51|>=5,<5.4.31|>=6,<6.3.8|>=6.4.24,<6.4.40",
+ "symfony/twilio-notifier": ">=6.4,<6.4.40|>=7,<7.4.12|>=8,<8.0.12",
+ "symfony/ux-autocomplete": "<2.36|>=3,<3.1",
+ "symfony/ux-icons": ">=2.17,<2.36.1|>=3,<3.2",
+ "symfony/ux-live-component": "<2.36|>=3,<3.1",
+ "symfony/ux-toolkit": ">=2.32,<2.36.1|>=3,<3.2",
"symfony/ux-twig-component": "<2.25.1",
"symfony/validator": "<5.4.43|>=6,<6.4.11|>=7,<7.1.4",
"symfony/var-exporter": ">=4.2,<4.2.12|>=4.3,<4.3.8",
- "symfony/web-profiler-bundle": ">=2,<2.3.19|>=2.4,<2.4.9|>=2.5,<2.5.4",
+ "symfony/web-profiler-bundle": ">=2,<2.3.19|>=2.4,<2.4.9|>=2.5,<2.5.4|>=7.2.9,<7.4.12|>=8,<8.0.12",
"symfony/webhook": ">=6.3,<6.3.8",
- "symfony/yaml": ">=2,<2.0.22|>=2.1,<2.1.7|>=2.2.0.0-beta1,<2.2.0.0-beta2",
+ "symfony/yaml": "<5.4.52|>=6,<6.4.40|>=7,<7.4.12|>=8,<8.0.12",
"symphonycms/symphony-2": "<2.6.4",
"t3/dce": "<0.11.5|>=2.2,<2.6.2",
"t3g/svg-sanitizer": "<1.0.3",
@@ -4647,45 +4915,50 @@
"tecnickcom/tcpdf": "<6.8",
"terminal42/contao-tablelookupwizard": "<3.3.5",
"thelia/backoffice-default-template": ">=2.1,<2.1.2",
- "thelia/thelia": ">=2.1,<2.1.3",
+ "thelia/thelia": ">=2.0.0.0-beta1,<2.1.3",
"theonedemon/phpwhois": "<=4.2.5",
"thinkcmf/thinkcmf": "<6.0.8",
- "thorsten/phpmyfaq": "<4.1.1",
+ "thorsten/phpmyfaq": "<4.1.4",
"tikiwiki/tiki-manager": "<=17.1",
"timber/timber": ">=0.16.6,<1.23.1|>=1.24,<1.24.1|>=2,<2.1",
- "tinymce/tinymce": "<7.2",
+ "tinymce/tinymce": "<7.9.3|>=8,<8.5.1",
"tinymighty/wiki-seo": "<1.2.2",
"titon/framework": "<9.9.99",
"tltneon/lgsl": "<7",
"tobiasbg/tablepress": "<=2.0.0.0-RC1",
+ "tomasnorre/crawler": "<11.0.13|>=12,<12.0.11",
"topthink/framework": "<6.0.17|>=6.1,<=8.0.4",
"topthink/think": "<=6.1.1",
"topthink/thinkphp": "<=3.2.3|>=6.1.3,<=8.0.4",
"torrentpier/torrentpier": "<=2.8.8",
- "tpwd/ke_search": "<4.0.3|>=4.1,<4.6.6|>=5,<5.0.2",
+ "tpwd/ke_search": "<5.6.2|>=6,<6.6.1|>=7,<7.0.1",
"tribalsystems/zenario": "<=9.7.61188",
"truckersmp/phpwhois": "<=4.3.1",
"ttskch/pagination-service-provider": "<1",
"twbs/bootstrap": "<3.4.1|>=4,<4.3.1",
- "twig/twig": "<3.11.2|>=3.12,<3.14.1|>=3.16,<3.19",
- "typicms/core": "<16.1.7",
+ "twig/cssinliner-extra": "<3.26",
+ "twig/intl-extra": "<3.26",
+ "twig/markdown-extra": "<3.26",
+ "twig/twig": "<3.27",
+ "typicms/core": "<12.0.5|>=13,<13.0.9|>=14,<14.0.27|>=15,<15.0.29|>=16,<16.1.7",
"typo3/cms": "<9.5.29|>=10,<10.4.35|>=11,<11.5.23|>=12,<12.2",
- "typo3/cms-backend": "<4.1.14|>=4.2,<4.2.15|>=4.3,<4.3.7|>=4.4,<4.4.4|>=7,<=7.6.50|>=8,<=8.7.39|>=9,<9.5.55|>=10,<=10.4.54|>=11,<=11.5.48|>=12,<=12.4.40|>=13,<=13.4.22|>=14,<=14.0.1|==14.2",
+ "typo3/cms-backend": "<10.4.57|>=11,<11.5.51|>=12,<12.4.46|>=13,<13.4.31|>=14,<14.3.3",
"typo3/cms-belog": ">=10,<=10.4.47|>=11,<=11.5.41|>=12,<=12.4.24|>=13,<=13.4.2",
"typo3/cms-beuser": ">=9,<9.5.55|>=10,<10.4.54|>=11,<11.5.48|>=12,<12.4.37|>=13,<13.4.18",
- "typo3/cms-core": "<=8.7.56|>=9,<9.5.55|>=10,<=10.4.54|>=11,<=11.5.48|>=12,<=12.4.40|>=13,<=13.4.22|>=14,<=14.0.1",
+ "typo3/cms-core": "<10.4.57|>=11,<11.5.51|>=12,<12.4.46|>=13,<13.4.31|>=14,<14.3.3",
"typo3/cms-dashboard": ">=10,<10.4.54|>=11,<11.5.48|>=12,<12.4.37|>=13,<13.4.18",
"typo3/cms-extbase": "<6.2.24|>=7,<7.6.8|==8.1.1",
"typo3/cms-extensionmanager": ">=10,<=10.4.47|>=11,<=11.5.41|>=12,<=12.4.24|>=13,<=13.4.2",
"typo3/cms-felogin": ">=4.2,<4.2.3",
- "typo3/cms-fluid": "<4.3.4|>=4.4,<4.4.1",
- "typo3/cms-form": ">=8,<=8.7.39|>=9,<=9.5.24|>=10,<=10.4.47|>=11,<=11.5.41|>=12,<=12.4.24|>=13,<=13.4.2",
+ "typo3/cms-filelist": ">=11,<11.5.51|>=12,<12.4.46|>=13,<13.4.31|>=14,<14.3.3",
+ "typo3/cms-fluid": "<4.3.4|>=4.4,<4.4.1|>=8,<8.7.23|>=9,<9.5.4",
+ "typo3/cms-form": "<10.4.57|>=11,<11.5.51|>=12,<12.4.46|>=13,<13.4.31|>=14,<14.3.5",
"typo3/cms-frontend": "<4.3.9|>=4.4,<4.4.5",
- "typo3/cms-indexed-search": ">=10,<=10.4.47|>=11,<=11.5.41|>=12,<=12.4.24|>=13,<=13.4.2",
+ "typo3/cms-indexed-search": ">=10,<=10.4.47|>=11,<=11.5.41|>=12,<=12.4.24|>=13,<13.4.31|>=14,<14.3.3",
"typo3/cms-install": "<4.1.14|>=4.2,<4.2.16|>=4.3,<4.3.9|>=4.4,<4.4.5|>=12.2,<12.4.8|==13.4.2",
"typo3/cms-lowlevel": ">=11,<=11.5.41",
"typo3/cms-recordlist": ">=11,<11.5.48",
- "typo3/cms-recycler": ">=9,<9.5.55|>=10,<=10.4.54|>=11,<=11.5.48|>=12,<=12.4.40|>=13,<=13.4.22|>=14,<=14.0.1",
+ "typo3/cms-recycler": "<10.4.57|>=11,<11.5.51|>=12,<12.4.46|>=13,<13.4.31|>=14,<14.3.3",
"typo3/cms-redirects": ">=10,<=10.4.54|>=11,<=11.5.48|>=12,<=12.4.40|>=13,<=13.4.22|>=14,<=14.0.1",
"typo3/cms-rte-ckeditor": ">=9.5,<9.5.42|>=10,<10.4.39|>=11,<11.5.30",
"typo3/cms-scheduler": ">=11,<=11.5.41",
@@ -4693,7 +4966,7 @@
"typo3/cms-webhooks": ">=12,<=12.4.30|>=13,<=13.4.11",
"typo3/cms-workspaces": ">=9,<9.5.55|>=10,<10.4.54|>=11,<11.5.48|>=12,<12.4.37|>=13,<13.4.18",
"typo3/flow": ">=1,<1.0.4|>=1.1,<1.1.1|>=2,<2.0.1|>=2.3,<2.3.16|>=3,<3.0.12|>=3.1,<3.1.10|>=3.2,<3.2.13|>=3.3,<3.3.13|>=4,<4.0.6",
- "typo3/html-sanitizer": ">=1,<=1.5.2|>=2,<=2.1.3",
+ "typo3/html-sanitizer": "<2.3.2",
"typo3/neos": ">=1.1,<1.1.3|>=1.2,<1.2.13|>=2,<2.0.4|>=2.3,<2.3.99|>=3,<3.0.20|>=3.1,<3.1.18|>=3.2,<3.2.14|>=3.3,<3.3.23|>=4,<4.0.17|>=4.1,<4.1.16|>=4.2,<4.2.12|>=4.3,<4.3.3",
"typo3/phar-stream-wrapper": ">=1,<2.1.1|>=3,<3.1.1",
"typo3/swiftmailer": ">=4.1,<4.1.99|>=5.4,<5.4.5",
@@ -4709,7 +4982,7 @@
"uvdesk/core-framework": "<=1.1.1",
"vanilla/safecurl": "<0.9.2",
"verbb/comments": "<1.5.5",
- "verbb/formie": "<=2.1.43",
+ "verbb/formie": "<3.1.28",
"verbb/image-resizer": "<2.0.9",
"verbb/knock-knock": "<1.2.8",
"verot/class.upload.php": "<=2.1.6",
@@ -4723,16 +4996,20 @@
"wallabag/wallabag": "<2.6.11",
"wanglelecc/laracms": "<=1.0.3",
"wapplersystems/a21glossary": "<=0.4.10",
- "web-auth/webauthn-framework": ">=3.3,<3.3.4|>=4.5,<4.9|>=5.2,<5.2.4",
- "web-auth/webauthn-lib": ">=4.5,<4.9|>=5.2,<5.2.4",
- "web-auth/webauthn-symfony-bundle": ">=5.2,<5.2.4",
+ "web-auth/webauthn-framework": ">=3.3,<3.3.4|>=4.5,<4.9|>=5.2,<5.2.4|>=5.3,<5.3.1",
+ "web-auth/webauthn-lib": ">=4.5,<5.3.5",
+ "web-auth/webauthn-symfony-bundle": "<5.3.4",
"web-feet/coastercms": "==5.5",
+ "web-token/jwt-bundle": "<3.4.10|>=4,<4.0.7|>=4.1,<4.1.7",
+ "web-token/jwt-experimental": "<4.1.7",
+ "web-token/jwt-framework": "<4.1.7",
+ "web-token/jwt-library": "<3.4.10|>=4,<4.0.7|>=4.1,<4.1.7",
"web-tp3/wec_map": "<3.0.3",
"webbuilders-group/silverstripe-kapost-bridge": "<0.4",
"webcoast/deferred-image-processing": "<1.0.2",
"webklex/laravel-imap": "<5.3",
"webklex/php-imap": "<5.3",
- "webonyx/graphql-php": "<=15.31.4",
+ "webonyx/graphql-php": "<=15.32.2",
"webpa/webpa": "<3.1.2",
"webreinvent/vaahcms": "<=2.3.1",
"wikibase/wikibase": "<=1.39.3",
@@ -4744,9 +5021,11 @@
"winter/wn-system-module": "<1.2.4",
"wintercms/winter": "<=1.2.3",
"wireui/wireui": "<1.19.3|>=2,<2.1.3",
+ "wnx/laravel-backup-restore": "<=1.9.3",
"woocommerce/woocommerce": "<6.6|>=8.8,<8.8.5|>=8.9,<8.9.3",
"wp-cli/wp-cli": ">=0.12,<2.5",
- "wp-graphql/wp-graphql": "<=1.14.5",
+ "wp-coding-standards/wpcs": ">=0.14.1,<3.4.1",
+ "wp-graphql/wp-graphql": "<=2.6",
"wp-premium/gravityforms": "<2.4.21",
"wpanel/wpanel4-cms": "<=4.3.1",
"wpcloud/wp-stateless": "<3.2",
@@ -4757,12 +5036,12 @@
"xpressengine/xpressengine": "<3.0.15",
"yab/quarx": "<2.4.5",
"yansongda/pay": "<=3.7.19",
- "yeswiki/yeswiki": "<=4.6",
+ "yeswiki/yeswiki": "<4.6.6",
"yetiforce/yetiforce-crm": "<6.5",
"yidashi/yii2cmf": "<=2",
"yii2mod/yii2-cms": "<1.9.2",
"yiisoft/yii": "<1.1.31",
- "yiisoft/yii2": "<2.0.52",
+ "yiisoft/yii2": "<2.0.55",
"yiisoft/yii2-authclient": "<2.2.15",
"yiisoft/yii2-bootstrap": "<2.0.4",
"yiisoft/yii2-dev": "<=2.0.45",
@@ -4852,7 +5131,7 @@
"type": "tidelift"
}
],
- "time": "2026-04-28T23:21:55+00:00"
+ "time": "2026-08-01T00:01:24+00:00"
},
{
"name": "sebastian/cli-parser",
@@ -5877,16 +6156,16 @@
},
{
"name": "squizlabs/php_codesniffer",
- "version": "3.13.5",
+ "version": "3.13.6",
"source": {
"type": "git",
"url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git",
- "reference": "0ca86845ce43291e8f5692c7356fccf3bcf02bf4"
+ "reference": "4c378e1a528ea066890fc2397cbdd2f94eb2fc91"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/0ca86845ce43291e8f5692c7356fccf3bcf02bf4",
- "reference": "0ca86845ce43291e8f5692c7356fccf3bcf02bf4",
+ "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/4c378e1a528ea066890fc2397cbdd2f94eb2fc91",
+ "reference": "4c378e1a528ea066890fc2397cbdd2f94eb2fc91",
"shasum": ""
},
"require": {
@@ -5952,7 +6231,7 @@
"type": "thanks_dev"
}
],
- "time": "2025-11-04T16:30:35+00:00"
+ "time": "2026-08-06T00:17:32+00:00"
},
{
"name": "symfony/config",
@@ -6035,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": {
@@ -6109,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": [
{
@@ -6129,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",
@@ -6218,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": {
@@ -6265,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": [
{
@@ -6285,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": {
@@ -6349,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": [
{
@@ -6369,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": {
@@ -6396,7 +6675,7 @@
"name": "symfony/contracts"
},
"branch-alias": {
- "dev-main": "3.6-dev"
+ "dev-main": "3.7-dev"
}
},
"autoload": {
@@ -6429,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": [
{
@@ -6440,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": {
@@ -6495,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": [
{
@@ -6515,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": {
@@ -6563,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": [
{
@@ -6583,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",
@@ -6670,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": {
@@ -6728,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": [
{
@@ -6748,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": {
@@ -6813,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": [
{
@@ -6833,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": {
@@ -6898,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": [
{
@@ -6918,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": {
@@ -6978,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": [
{
@@ -6998,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": {
@@ -7043,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": [
{
@@ -7063,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": {
@@ -7094,7 +7377,7 @@
"name": "symfony/contracts"
},
"branch-alias": {
- "dev-main": "3.6-dev"
+ "dev-main": "3.7-dev"
}
},
"autoload": {
@@ -7130,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": [
{
@@ -7150,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"
},
@@ -7177,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": {
@@ -7219,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": [
{
@@ -7239,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",
@@ -7707,9 +7992,9 @@
"platform": {
"php": "^8.3"
},
- "platform-dev": [],
+ "platform-dev": {},
"platform-overrides": {
"php": "8.3"
},
- "plugin-api-version": "2.6.0"
+ "plugin-api-version": "2.9.0"
}
diff --git a/css/header-override.css b/css/header-override.css
index c9828274..5d655840 100644
--- a/css/header-override.css
+++ b/css/header-override.css
@@ -1,6 +1,6 @@
/**
* SPDX-FileCopyrightText: 2024 LaunchPad Contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
+ * SPDX-License-Identifier: EUPL-1.2
*
* NUCLEAR OPTION: Override ALL theme CSS for header styling
* This file is loaded LAST to ensure it overrides nldesign theme
diff --git a/css/launchpad.css b/css/launchpad.css
index 7f37fc88..cd0d6de8 100644
--- a/css/launchpad.css
+++ b/css/launchpad.css
@@ -1,6 +1,6 @@
/**
* SPDX-FileCopyrightText: 2024 LaunchPad Contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
+ * SPDX-License-Identifier: EUPL-1.2
*
* --launchpad-cell-height is set by `useGridManager.syncCellHeightCssVar()` at
* grid-init time from the JS `CELL_HEIGHT` constant (REQ-GRID-012). The
diff --git a/docs/GOVERNMENT-FEATURES.md b/docs/GOVERNMENT-FEATURES.md
index 6c7bd4ef..78b4a512 100644
--- a/docs/GOVERNMENT-FEATURES.md
+++ b/docs/GOVERNMENT-FEATURES.md
@@ -5,7 +5,7 @@
**Product:** LaunchPad
**Categorie:** Dashboard & informatievoorziening
-**Licentie:** AGPL (vrije open source)
+**Licentie:** EUPL-1.2 (vrije open source)
**Leverancier:** Conduction B.V.
**Platform:** Nextcloud (self-hosted / on-premise / cloud)
@@ -57,7 +57,7 @@
| # | Eis | Status | Toelichting |
|---|-----|--------|-------------|
| T-01 | On-premise / self-hosted | Beschikbaar | Nextcloud-app |
-| T-02 | Open source | Beschikbaar | AGPL, GitHub |
+| T-02 | Open source | Beschikbaar | EUPL-1.2, GitHub |
| T-03 | PHP 8.1+ | Beschikbaar | Moderne PHP |
| T-04 | Nextcloud 28-33 compatibel | Beschikbaar | Brede versie-ondersteuning |
| T-05 | Geen externe dependencies | Beschikbaar | Alleen Nextcloud vereist |
diff --git a/docs/architecture.md b/docs/architecture.md
index bc9ba3bd..b08ffab0 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -25,8 +25,9 @@ persists everything in its own tables via Doctrine mappers.
│ components/WidgetRenderer — legacy-widget bridge │
│ components/WidgetPicker — "add widget" modal │
│ components/WidgetWrapper — per-tile chrome │
-│ components/TileCard / TileEditor / WidgetStyleEditor │
+│ components/TileCard / WidgetStyleEditor │
│ components/admin/AdminSettings — admin console │
+│ modals/ + dialogs/ — every NcModal / NcDialog surface │
└──────────────────────────┬──────────────────────────────────┘
│ OCS JSON via @nextcloud/axios
┌──────────────────────────▼──────────────────────────────────┐
diff --git a/docs/features.json b/docs/features.json
index b0449913..5ceee6bf 100644
--- a/docs/features.json
+++ b/docs/features.json
@@ -1,398 +1,248 @@
[
- {
- "slug": "activity-feed-integration",
- "title": "Activity Feed Integration",
- "summary": "Surface LaunchPad events in Nextcloud's standard Activity feed so every action on a dashboard — creation, editing, publication, sharing, commenting, locking, and role changes — is visible to the relevant users in their NC notifications and activity stream. This capability defines the NC Activity extension class, all event-type constants, audience-targeting rules, debounce logic, subject/message templates, icon conventions, the cross-capability emission contract, and the unit-test contract. Actual `publishActivity()` call-sites are delegated to the sibling capability that owns each action.",
- "docsUrl": "openspec/specs/activity-feed-integration/spec.md"
- },
- {
- "slug": "admin-roles",
- "title": "Admin Roles",
- "summary": "Admin Roles provides a built-in role system scoped entirely within LaunchPad. Organization administrators can delegate dashboard management, widget installation, metadata field configuration, and other LaunchPad operations to trusted users without granting full Nextcloud system administration rights. Three roles (Dashboard Admin, Dashboard Editor, Dashboard Viewer) map to real organizational needs, and role assignments persist in a new table with support for both individual user and group-based delegation. Effective role resolution ensures the highest privilege wins when a user has multiple group memberships.",
- "docsUrl": "openspec/specs/admin-roles/spec.md"
- },
- {
- "slug": "admin-settings",
- "title": "Admin Settings",
- "summary": "Admin settings provide Nextcloud administrators with global configuration options for the LaunchPad app. These settings control system-wide behavior such as whether users can create their own dashboards, how many dashboards they can have, default permission levels for new dashboards, and default grid configuration. Settings are stored as key-value pairs in a dedicated database table and are applied as defaults or constraints across the entire LaunchPad installation.",
- "docsUrl": "openspec/specs/admin-settings/spec.md"
- },
{
"slug": "admin-templates",
- "title": "Admin Templates",
- "summary": "Admin templates allow Nextcloud administrators to create pre-configured dashboards that are automatically distributed to users based on group membership. When a user opens LaunchPad for the first time (or when a new template targets their group), the system creates a personal copy of the matching template. This copy is an independent dashboard that the user can modify within the limits of the inherited permission level. Templates enable organizations to provide standardized dashboard layouts with compulsory widgets while still allowing user customization where appropriate.",
- "docsUrl": "openspec/specs/admin-templates/spec.md"
+ "title": "Admin templates",
+ "summary": "You push a curated homepage to a group in minutes.",
+ "status": "stable",
+ "docsUrl": "openspec/specs/admin-templates/spec.md",
+ "title_nl": "Beheersjablonen",
+ "summary_nl": "Je rolt een ingerichte startpagina in minuten uit naar een groep."
},
{
- "slug": "background-job-feed-refresh",
- "title": "background-job-feed-refresh",
- "summary": "Keeps news-widget feeds fresh by running a scheduled background job that fetches, parses, and caches RSS 2.0 and Atom 1.0 feeds referenced by dashboard placements. It deduplicates feed URLs into a shared cache table, uses HTTP conditional requests and per-feed failure isolation to stay efficient and resilient, enforces a host allow-list and concurrency lock, and exposes an admin endpoint to trigger an immediate refresh.",
- "docsUrl": "openspec/specs/background-job-feed-refresh/spec.md"
- },
- {
- "slug": "calendar-widget",
- "title": "Calendar Widget",
- "summary": "The calendar widget is a built-in LaunchPad widget type that renders aggregated events from internal Nextcloud calendars and external ICS feeds in a single dashboard tile. Users can switch between three view modes (month, week, agenda), restrict the look-ahead window for agenda mode, and color events by their source calendar. External ICS feeds are fetched server-side with caching, an HTTPS-only / SSRF-safe guard, and an optional admin allow-list of permitted hostnames, so that adding a public calendar link to a dashboard does not expose the Nextcloud instance to internal-network probing or runaway expansion.",
- "docsUrl": "openspec/specs/calendar-widget/spec.md"
+ "slug": "permissions",
+ "title": "Permission tiers",
+ "summary": "People personalise their homepage without touching the locked widgets.",
+ "status": "stable",
+ "docsUrl": "openspec/specs/permissions/spec.md",
+ "title_nl": "Rechtenniveaus",
+ "summary_nl": "Mensen passen hun startpagina aan zonder de vergrendelde widgets te raken."
},
{
- "slug": "cli-commands",
- "title": "CLI Commands Suite",
- "summary": "The CLI commands suite establishes a coherent, standardized operator interface for LaunchPad management tasks. It defines the `launchpad:` namespace prefix, consistent global flags across all commands, and introduces new operational helpers for dashboard inspection, sharing debugging, feed token management, and internationalization. The capability ensures scriptability, auditability, and discoverability of all CLI operations.",
- "docsUrl": "openspec/specs/cli-commands/spec.md"
+ "slug": "admin-roles",
+ "title": "Role delegation",
+ "summary": "You hand a colleague dashboard admin without full Nextcloud rights.",
+ "status": "stable",
+ "docsUrl": "openspec/specs/admin-roles/spec.md",
+ "title_nl": "Roldelegatie",
+ "summary_nl": "Je geeft een collega dashboardbeheer zonder volledige Nextcloud-rechten."
},
{
"slug": "conditional-visibility",
- "title": "Conditional Visibility",
- "summary": "Conditional visibility allows widget placements to be shown or hidden based on dynamic rules. This enables dashboards that adapt to the user's context -- for example, showing a \"Team Updates\" widget only during business hours, displaying a \"Holiday Schedule\" widget only in December, or restricting certain widgets to specific user groups. Rules are evaluated at render time and can be inclusive (show when matched) or exclusive (hide when matched). Include rules use OR logic (at least one must match); exclude rules use AND logic (any match hides the widget).",
- "docsUrl": "openspec/specs/conditional-visibility/spec.md"
- },
- {
- "slug": "confluence-html-import",
- "title": "Confluence HTML Export Importer",
- "summary": "Organisations migrating from Atlassian Confluence (or supplementing it with LaunchPad) need a one-shot bulk import that converts existing Confluence page hierarchies into LaunchPad dashboards. Manual recreation of hundreds of pages is impractical. This capability lets a Nextcloud admin upload a Confluence \"HTML Export\" archive and automatically generate LaunchPad dashboards with the page content preserved, the page tree mirrored via the `dashboard-tree` capability, and Confluence Storage Format macros expanded into safe HTML.",
- "docsUrl": "openspec/specs/confluence-html-import/spec.md"
- },
- {
- "slug": "container-widget",
- "title": "Container Widget",
- "summary": "The container widget hosts a sub-grid of child widget placements inside a single outer-grid cell. Authors compose dashboards out of logical sections (a heading + four KPI tiles, a \"tabs\" surface, a card with grouped content) without losing the move-as-one-unit drag behaviour of a top-level placement. Children are stored as nested `placements: WidgetPlacement[]` in the container's `content` blob and dispatched through the same widget registry that drives the top-level grid, so any registered widget type — including another container — can live inside one. Server-side validation caps recursion at three nested container levels (REQ-CONT-006) to prevent runaway depth.",
- "docsUrl": "openspec/specs/container-widget/spec.md"
- },
- {
- "slug": "dashboard-bulk-operations",
- "title": "Dashboard Bulk Operations",
- "summary": "Dashboard bulk operations expose four batch admin endpoints for large-scale management of LaunchPad dashboards: bulk delete, bulk re-parent, bulk publication-status update, and bulk re-index. The endpoints provide all-or-nothing permission pre-checks, per-dashboard atomic mutations with continue-on-error semantics, dry-run preview support, and a single audit Activity event per request. The design closes the misclick gap of the source implementation (which defaulted `cascade=true` on bulk delete) by requiring an explicit opt-in for recursive deletion, and pins a 500-dashboard per-request cap that is admin-tunable via the `bulk_operation_max_per_request` app config key.",
- "docsUrl": "openspec/specs/dashboard-bulk-operations/spec.md"
- },
- {
- "slug": "dashboard-cascade-events",
- "title": "Dashboard Cascade Events",
- "summary": "When a LaunchPad dashboard is deleted, all dependent data (widget placements, comments, reactions, locks, versions, public shares, metadata values, translations, view analytics, child-tree dashboards) MUST be automatically removed. When a Nextcloud user or group is deleted, their associated dashboards and downstream records MUST likewise be cleaned up. This capability defines the event, the listener registry, failure isolation, idempotency, and cascade stats reporting.",
- "docsUrl": "openspec/specs/dashboard-cascade-events/spec.md"
- },
- {
- "slug": "dashboard-comments",
- "title": "Dashboard Comments",
- "summary": "Dashboards are shared workspaces within teams and organizations. Dashboard Comments adds a threaded discussion surface so users can ask \"why is this widget red?\" or \"we should change this metric next sprint\" directly on the dashboard they are looking at. Comments are persisted via Nextcloud's native `ICommentsManager` infrastructure (the same backend used by the file comments and Talk integrations) so administrators get unified comment storage, notifications, and audit trails — and so LaunchPad does not introduce a redundant comment table.",
- "docsUrl": "openspec/specs/dashboard-comments/spec.md"
- },
- {
- "slug": "dashboard-export-import",
- "title": "Dashboard Export & Import",
- "summary": "Dashboard export and import allow LaunchPad administrators to create versioned snapshots of dashboard configurations, widgets, metadata fields, and associated assets. Snapshots are portable across Nextcloud instances, enabling backup, disaster recovery, template authoring, and cross-instance sharing. This capability defines a standardised ZIP container format (`launchpad-export-v1.zip`), collision handling semantics, and API/CLI endpoints for end-to-end export-import workflows. Downstream capabilities such as `confluence-html-import` consume the same ZIP shape, so the manifest schema (`schemaVersion: 1`) is treated as a stable contract.",
- "docsUrl": "openspec/specs/dashboard-export-import/spec.md"
- },
- {
- "slug": "dashboard-icons",
- "title": "Dashboard Icons",
- "summary": "LaunchPad dashboards (and the dashboard-list items in the switcher sidebar and admin UI) display an icon next to their name. This capability owns the icon vocabulary: a small curated registry of named built-in icons that live in the frontend bundle, plus the lookup/render functions consumers use, plus the convention for storing per-dashboard icons in a single column that may also hold an uploaded resource URL.",
- "docsUrl": "openspec/specs/dashboard-icons/spec.md"
+ "title": "Conditional visibility",
+ "summary": "You show finance cards to finance and banners only this week.",
+ "status": "stable",
+ "docsUrl": "openspec/specs/conditional-visibility/spec.md",
+ "title_nl": "Voorwaardelijke zichtbaarheid",
+ "summary_nl": "Je toont finance-kaarten aan finance en banners alleen deze week."
},
{
"slug": "dashboard-kiosk-mode",
- "title": "dashboard-kiosk-mode",
- "summary": "Turns LaunchPad dashboards into unattended signage by rendering them chrome-less and full-viewport via a `kiosk=1` flag or a public playlist token. Users can build named playlists that rotate through multiple dashboards with per-entry dwell times, while kiosk surfaces refresh widget data in place and degrade gracefully on failure with skip-and-retry, last-known-content retention, neutral placeholders, and a watchdog to recover from stalls.",
- "docsUrl": "openspec/specs/dashboard-kiosk-mode/spec.md"
- },
- {
- "slug": "dashboard-locking",
- "title": "Dashboard Locking",
- "summary": "Dashboard locking provides a concurrent-edit guard for dashboards. When two users open the same dashboard's edit view, the system MUST prevent the second user from editing until the first releases the lock. The mechanism uses a time-based lease (default 15 minutes) with client-driven heartbeat renewal to tolerate transient network outages and browser crashes without manual intervention.",
- "docsUrl": "openspec/specs/dashboard-locking/spec.md"
- },
- {
- "slug": "dashboard-metadata-fields",
- "title": "Dashboard Metadata Fields",
- "summary": "Dashboard Metadata Fields allow administrators to define custom, queryable attributes that can be attached to every dashboard in a LaunchPad instance. Once an administrator defines a global registry of field definitions (e.g., \"department\", \"project stage\", \"audience\"), end users populate values for those fields on their dashboards. The field values are then queryable for filtering dashboards in search, widget configuration, and API calls. This capability standardizes what would otherwise be ad-hoc naming conventions and enables the discovery and organization of dashboards at scale.",
- "docsUrl": "openspec/specs/dashboard-metadata-fields/spec.md"
- },
- {
- "slug": "dashboard-public-share",
- "title": "dashboard-public-share",
- "summary": "Lets dashboard owners publish read-only public links to their dashboards, optionally protected by a password and an expiry date. Anonymous visitors render the dashboard through a unique share token while the system enforces read-only access, soft-revocation, brute-force throttling, debounced view counting, and a service-account read path for GroupFolder-backed content.",
- "docsUrl": "openspec/specs/dashboard-public-share/spec.md"
+ "title": "Kiosk and signage mode",
+ "summary": "You rotate dashboards on a lobby screen unattended.",
+ "status": "stable",
+ "docsUrl": "openspec/specs/dashboard-kiosk-mode/spec.md",
+ "title_nl": "Kiosk- en narrowcastmodus",
+ "summary_nl": "Je laat dashboards onbemand rouleren op een lobbyscherm."
},
{
"slug": "dashboard-quota-limits",
- "title": "dashboard-quota-limits",
- "summary": "Numeric admin-governance quotas for LaunchPad: maximum personal dashboards per user and maximum widget placements per dashboard. Both default to `0` (unlimited). Enforcement is server-side and fail-closed at a single `QuotaService` choke point on every user-initiated creation path, returning HTTP 409 with a structured body; admin provisioning is exempt; lowering a limit grandfathers existing data (never deletes/hides); and the dashboards list response carries an additive `quota` envelope so the UI can disable creation affordances at the limit while the server check stays authoritative.",
- "docsUrl": "openspec/specs/dashboard-quota-limits/spec.md"
- },
- {
- "slug": "dashboard-reactions",
- "title": "Dashboard Reactions",
- "summary": "Dashboard reactions enable lightweight social feedback via emoji on LaunchPad dashboards. Users can react with a configurable whitelist of emojis to mark dashboards as useful, appreciated, or funny, without requiring full-featured comments. Reactions are aggregated by emoji and visible to all viewers. An administrator can enable/disable reactions globally and per-dashboard, and can curate the allowed emoji list.",
- "docsUrl": "openspec/specs/dashboard-reactions/spec.md"
- },
- {
- "slug": "dashboard-rss-feeds",
- "title": "Dashboard RSS Feeds",
- "summary": "Expose a user's accessible dashboards as an RSS 2.0 / Atom feed accessible without Nextcloud browser authentication via a per-user secret token. Each user may opt-in by requesting their feed token; the feed is filtered by the token-owner's dashboard ACLs (permissions) so private content remains private. The feed enables integration with RSS readers, monitoring tools, and third-party systems without requiring full Nextcloud login.",
- "docsUrl": "openspec/specs/dashboard-rss-feeds/spec.md"
+ "title": "Dashboard quotas",
+ "summary": "You cap dashboards per user and keep the instance tidy.",
+ "status": "stable",
+ "docsUrl": "openspec/specs/dashboard-quota-limits/spec.md",
+ "title_nl": "Dashboardquota",
+ "summary_nl": "Je begrenst dashboards per gebruiker en houdt de omgeving netjes."
},
{
"slug": "dashboard-switcher",
- "title": "Dashboard Switcher",
- "summary": "The dashboard switcher is a left-edge slide-in sidebar that lets a user see every dashboard visible to them and switch between them with a single click. Dashboards are grouped into three labelled sections by source (primary group, default group, personal). The sidebar also surfaces personal-dashboard creation and deletion when allowed.",
- "docsUrl": "openspec/specs/dashboard-switcher/spec.md"
- },
- {
- "slug": "dashboard-versioning",
- "title": "Dashboard Versioning",
- "summary": "Enable version history and one-click restoration for LaunchPad dashboards. The feature delegates storage strategy to the underlying content backend: dashboards stored in Nextcloud Files (via the `groupfolder` backend) use NC's native file versioning; dashboards in the database backend use a dedicated `oc_launchpad_dash_versions` table. All APIs are backend-agnostic.",
- "docsUrl": "openspec/specs/dashboard-versioning/spec.md"
- },
- {
- "slug": "dashboard-view-analytics",
- "title": "Dashboard View Analytics",
- "summary": "Aggregate, privacy-preserving view counts per dashboard so LaunchPad administrators can understand which dashboards are actually being used. Counts are bucketed by UTC day and stored in a single aggregate table (`oc_launchpad_dashboard_views`). Unique-viewer deduplication uses a daily-rotating salted SHA-256 hash kept exclusively in the Nextcloud cache layer; no per-user-per-event rows are persisted, and cross-day re-identification from the analytics database alone is computationally infeasible. Admins query top dashboards, per-dashboard daily breakdowns, instance-wide totals, and CSV exports through admin-only endpoints. A daily background job purges rows older than the configured retention window (default 365 days, clamped to `[30, 3650]`).",
- "docsUrl": "openspec/specs/dashboard-view-analytics/spec.md"
+ "title": "Multiple dashboards",
+ "summary": "You build a homepage per role and switch in one click.",
+ "status": "stable",
+ "docsUrl": "openspec/specs/dashboard-switcher/spec.md",
+ "title_nl": "Meerdere dashboards",
+ "summary_nl": "Je bouwt een startpagina per rol en wisselt met een klik."
},
{
- "slug": "dashboards",
- "title": "Dashboards",
- "summary": "Dashboards are the core organizational unit in LaunchPad. Each user can create and manage multiple personal dashboards, each acting as a container for widget placements, tiles, and layout configuration. Dashboards define the grid structure, permission level, and active state. Only one dashboard can be active per user at a time, serving as their landing page when they open Nextcloud. Dashboards can also be of type `admin_template`, managed by administrators for distribution to users.",
- "docsUrl": "openspec/specs/dashboards/spec.md"
+ "slug": "grid-layout",
+ "title": "Drag-and-drop grid",
+ "summary": "You drag, drop, and resize cards until the layout fits.",
+ "status": "stable",
+ "docsUrl": "openspec/specs/grid-layout/spec.md",
+ "title_nl": "Sleep-en-neerzet raster",
+ "summary_nl": "Je sleept, plaatst en schaalt kaarten tot de indeling klopt."
},
{
- "slug": "demo-data-showcases",
- "title": "Demo Data Showcases",
- "summary": "The `demo-data-showcases` capability provides administrators with one-click installation of pre-built, fully populated example dashboards that illustrate different organizational use cases. Showcases are bundled as ZIP archives containing a machine-readable `export.json` manifest plus per-locale page JSON files and media assets, loaded from disk on demand, and installed as `group_shared` dashboards visible to all users (via REQ-DASH-012 default-group sentinel). The capability includes widget type validation, graceful skip-on-missing for unknown widgets, NL-only localization in v1, and idempotent installation via API and CLI commands.",
- "docsUrl": "openspec/specs/demo-data-showcases/spec.md"
+ "slug": "nc-dashboard-widget-proxy",
+ "title": "Nextcloud widgets",
+ "summary": "You drop in any Files, Calendar, or Talk widget you already use.",
+ "status": "stable",
+ "docsUrl": "openspec/specs/nc-dashboard-widget-proxy/spec.md",
+ "title_nl": "Nextcloud-widgets",
+ "summary_nl": "Je plaatst elke Bestanden-, Agenda- of Talk-widget die je al gebruikt."
},
{
- "slug": "divider-widget",
- "title": "Divider Widget",
- "summary": "The divider widget is a lightweight, configurable visual separator for LaunchPad dashboards. It enables dashboard creators to break up widget sections into logical groups using minimal UI — a horizontal line, whitespace spacer, or centered heading with dividing lines — all rendered client-side with full theme awareness and print support. This capability adds no backend endpoints or data storage; all configuration is stored in the placement's `widgetContent JSON` blob and rendered in-browser.",
- "docsUrl": "openspec/specs/divider-widget/spec.md"
+ "slug": "tiles",
+ "title": "Shortcut tiles",
+ "summary": "You pin your key tools with an icon, colour, and link.",
+ "status": "stable",
+ "docsUrl": "openspec/specs/tiles/spec.md",
+ "title_nl": "Snelkoppelingstegels",
+ "summary_nl": "Je pint je belangrijkste tools met een icoon, kleur en link."
},
{
- "slug": "files-widget",
- "title": "Files Widget",
- "summary": "The files widget is a built-in LaunchPad widget type that lets dashboard authors embed an inline Nextcloud Files browser directly on a dashboard. The widget reads the configured folder live at render time, applies view-time ACL so each viewer sees only files they may read, supports folder navigation via a breadcrumb, deep-links file clicks into the standard Files application, and exposes optional upload and delete actions gated by both placement-level toggles and per-viewer permission. The capability is one widget type, one renderer, one sub-form, one registry entry, and three HTTP endpoints (contents listing, multi-file upload, single-file delete) — small enough to ship and evolve independently while anchoring the future \"shared workspace folder\" experience that other widgets will build on top of.",
- "docsUrl": "openspec/specs/files-widget/spec.md"
+ "slug": "dashboard-public-share",
+ "title": "Public share links",
+ "summary": "You share a read-only dashboard with a single link.",
+ "status": "stable",
+ "docsUrl": "openspec/specs/dashboard-public-share/spec.md",
+ "title_nl": "Openbare deellinks",
+ "summary_nl": "Je deelt een alleen-lezen dashboard met een enkele link."
},
{
- "slug": "footer-customization",
- "title": "Footer Customization",
- "summary": "Footer Customization provides per-instance branding, legal disclaimers, and contact information rendered below the dashboard surface. Administrators configure global footer content (HTML or structured form), with optional per-dashboard overrides. The footer respects theme colors, supports multi-language variants, and prints correctly in PDF exports.",
- "docsUrl": "openspec/specs/footer-customization/spec.md"
+ "slug": "dashboard-versioning",
+ "title": "Versioning and rollback",
+ "summary": "You roll a dashboard back to last week in one step.",
+ "status": "stable",
+ "docsUrl": "openspec/specs/dashboard-versioning/spec.md",
+ "title_nl": "Versiebeheer en terugdraaien",
+ "summary_nl": "Je draait een dashboard in een stap terug naar vorige week."
},
{
- "slug": "grid-layout",
- "title": "Grid Layout",
- "summary": "The grid layout system powers the drag-and-drop dashboard experience in LaunchPad. Built on GridStack 12.x, it provides a 12-column responsive grid that reflows at four explicit viewport breakpoints (1400/1100/768/480 px → 12/8/4/1 cols) where users can position, resize, and rearrange widget placements and tiles. The grid operates in two modes: view mode (static, no interaction) and edit mode (drag-and-drop enabled). Position changes are emitted via Vue events and persisted via the API by the parent component.",
- "docsUrl": "openspec/specs/grid-layout/spec.md"
+ "slug": "dashboard-export-import",
+ "title": "Export and import",
+ "summary": "You move a dashboard between environments from the UI or the command line.",
+ "status": "stable",
+ "docsUrl": "openspec/specs/dashboard-export-import/spec.md",
+ "title_nl": "Exporteren en importeren",
+ "summary_nl": "Je verplaatst een dashboard tussen omgevingen via de interface of de opdrachtregel."
+ },
+ {
+ "slug": "runtime-or-consumption",
+ "title": "Live business data",
+ "summary": "You pull live figures over GraphQL when OpenRegister is present.",
+ "status": "stable",
+ "docsUrl": "openspec/specs/runtime-or-consumption/spec.md",
+ "providedBy": "openregister",
+ "title_nl": "Live bedrijfsdata",
+ "summary_nl": "Je haalt live cijfers op via GraphQL wanneer OpenRegister aanwezig is."
+ },
+ {
+ "slug": "kpi-cards",
+ "title": "KPI cards",
+ "summary": "You show counts and charts straight on the dashboard.",
+ "status": "stable",
+ "docsUrl": "openspec/specs/default-widget-bundle/spec.md",
+ "providedBy": "openregister",
+ "title_nl": "KPI-kaarten",
+ "summary_nl": "Je toont aantallen en grafieken direct op het dashboard."
},
{
- "slug": "groupfolder-storage-backend",
- "title": "groupfolder-storage-backend",
- "summary": "Abstracts dashboard content storage behind a unified read/write/delete interface so operators can choose between the default database backend and an optional Nextcloud GroupFolder backend. The GroupFolder backend stores dashboards as human-readable JSON files in an auto-created, admin-restricted folder, fails closed without silent fallback, and ships a one-time migration command — all transparently to existing API clients.",
- "docsUrl": "openspec/specs/groupfolder-storage-backend/spec.md"
+ "slug": "confluence-html-import",
+ "title": "Confluence import",
+ "summary": "You bring a Confluence page in as dashboard content.",
+ "status": "stable",
+ "docsUrl": "openspec/specs/confluence-html-import/spec.md",
+ "title_nl": "Confluence-import",
+ "summary_nl": "Je haalt een Confluence-pagina binnen als dashboardinhoud."
},
{
- "slug": "header-widget",
- "title": "Header Widget",
- "summary": "The header widget is a built-in LaunchPad widget type that drops a full-width banner onto a dashboard with a configurable title, optional subtitle, optional background image (URL or NC file), an optional color overlay, and an optional call-to-action button. It replaces the legacy \"header row\" prototype with a first-class, typed widget that participates in the same registry, modal, and grid pipeline as every other built-in widget — no special-casing in the dashboard renderer.",
- "docsUrl": "openspec/specs/header-widget/spec.md"
+ "slug": "prometheus-metrics",
+ "title": "Prometheus metrics",
+ "summary": "You scrape health and usage from a standard metrics endpoint.",
+ "status": "stable",
+ "docsUrl": "openspec/specs/prometheus-metrics/spec.md",
+ "title_nl": "Prometheus-metrieken",
+ "summary_nl": "Je leest gezondheid en gebruik uit via een standaard metrics-endpoint."
},
{
- "slug": "image-widget",
- "title": "Image Widget",
- "summary": "The image widget is a built-in LaunchPad widget type that lets dashboard authors place a single image — logo, screenshot, branding, or decorative imagery — onto a dashboard cell with proper `object-fit` control, broken-image fallback, optional click-through, and a first-class file-upload UX. It replaces the prior workarounds where users jammed ` ` tags into the markdown widget or pointed an iframe widget at an image URL.",
- "docsUrl": "openspec/specs/image-widget/spec.md"
+ "slug": "orphaned-data-cleanup",
+ "title": "Data cleanup",
+ "summary": "A background job clears orphaned data on its own.",
+ "status": "stable",
+ "docsUrl": "openspec/specs/orphaned-data-cleanup/spec.md",
+ "title_nl": "Data-opschoning",
+ "summary_nl": "Een achtergrondtaak ruimt verweesde data vanzelf op."
},
{
- "slug": "infrastructure-helpers",
- "title": "Infrastructure Helpers",
- "summary": "The `infrastructure-helpers` capability collects small, pure (or nearly-pure) utility classes that are reused across multiple capability boundaries. These are not domain logic — they are primitives: string transformation, lookup, parameter extraction. Each helper has a single narrow contract, no persistence, and is invoked from multiple capability code paths. Grouping them here keeps each individual capability spec focused on domain behaviour rather than utility internals.",
- "docsUrl": "openspec/specs/infrastructure-helpers/spec.md"
+ "slug": "activity-feed-integration",
+ "title": "Activity feed",
+ "summary": "Core dashboard actions land in the Nextcloud activity stream.",
+ "status": "beta",
+ "docsUrl": "openspec/specs/activity-feed-integration/spec.md",
+ "title_nl": "Activiteitenoverzicht",
+ "summary_nl": "Kerndashboardacties komen in de Nextcloud-activiteitenstroom terecht."
},
{
- "slug": "initial-state-contract",
- "title": "Initial State Contract",
- "summary": "The `initial-state-contract` capability formalises the precise set of keys that PHP pushes via Nextcloud's `IInitialState::provideInitialState` for each Vue mount in LaunchPad, and the matching `provide()` calls each entry point emits to expose those keys to the rest of the component tree. Without this contract the keys drift silently — frontend reads a key the backend stopped sending, or vice versa, and the breakage only surfaces at runtime.",
- "docsUrl": "openspec/specs/initial-state-contract/spec.md"
+ "slug": "nc-unified-search-integration",
+ "title": "Unified search",
+ "summary": "You find dashboards from the Nextcloud search bar.",
+ "status": "beta",
+ "docsUrl": "openspec/specs/nc-unified-search-integration/spec.md",
+ "title_nl": "Geintegreerd zoeken",
+ "summary_nl": "Je vindt dashboards via de Nextcloud-zoekbalk."
},
{
- "slug": "label-widget",
- "title": "Label Widget",
- "summary": "The label widget is a built-in LaunchPad widget type that lets dashboard authors drop a short, single-line, plain-text heading onto a dashboard to title a row of widgets or mark a zone. It is intentionally narrower than the `text` widget (which carries multi-line HTML content via `v-html`): the label widget renders content with Vue interpolation only, eliminating the XSS surface entirely, and ships heading-style defaults (`16px` bold centred) so a freshly added label looks correct without any styling input.",
- "docsUrl": "openspec/specs/label-widget/spec.md"
+ "slug": "groupfolder-storage-backend",
+ "title": "GroupFolder storage (optional)",
+ "summary": "You optionally store dashboard content in a shared GroupFolder.",
+ "status": "beta",
+ "docsUrl": "openspec/specs/groupfolder-storage-backend/spec.md",
+ "title_nl": "GroupFolder-opslag (optioneel)",
+ "summary_nl": "Je bewaart dashboardinhoud optioneel in een gedeelde GroupFolder."
},
{
- "slug": "launchpad-adopt-or-abstractions",
- "title": "launchpad-adopt-or-abstractions",
- "summary": "Keeps LaunchPad installable and runnable without OpenRegister or OpenConnector while letting its widgets consume OR data when present. It mandates an architectural manifest, forbids install-time OR/OC dependencies, requires runtime feature-detection with documented empty states, locale and tenant-context stamping on OR fetches, a local-first dashboard permission model, and several code-hygiene rules (typed admin-setting keys, a named filename-pattern constant, and documented column-type constants).",
- "docsUrl": "openspec/specs/launchpad-adopt-or-abstractions/spec.md"
+ "slug": "launchpad-spend-analytics-widget",
+ "title": "Spend analytics card",
+ "summary": "You preview spend from financeq and procest on a card.",
+ "status": "beta",
+ "docsUrl": "openspec/specs/launchpad-spend-analytics-widget/spec.md",
+ "providedBy": "openregister",
+ "title_nl": "Uitgavenanalyse-kaart",
+ "summary_nl": "Je bekijkt uitgaven uit financeq en procest op een kaart."
},
{
- "slug": "launchpad-ai-dashboard-assistant",
- "title": "Spec: launchpad-ai-dashboard-assistant",
- "summary": "Add an embedded AI assistant widget (`launchpad_ai_assistant`) that lets the dashboard viewer ask natural-language questions about their dashboard's data and receive a streamed reply — summarising open cases, surfacing consultation responses, explaining an aggregate trend. The widget is a **thin chat surface** that delegates inference to the openconnector-registered LLM source (Ollama + Qwen via `local-llm` per `reference_llphant-ollama-think-false`). launchpad MUST NOT carry its own LLM client SDK or call any inference endpoint directly.",
- "docsUrl": "openspec/specs/launchpad-ai-dashboard-assistant/spec.md"
+ "slug": "launchpad-mobile-remote-access",
+ "title": "Mobile access",
+ "summary": "A responsive homepage that survives on a phone.",
+ "status": "soon",
+ "docsUrl": "openspec/specs/launchpad-mobile-remote-access/spec.md",
+ "title_nl": "Mobiele toegang",
+ "summary_nl": "Een responsieve startpagina die het op een telefoon volhoudt."
},
{
"slug": "launchpad-compliance-audit-panel",
- "title": "Spec: launchpad-compliance-audit-panel",
- "summary": "Surface the organisation's compliance posture on a launchpad dashboard through one widget (`launchpad_compliance_audit`). The widget reads audit-trail, retention, and compliance-evidence data at runtime via GraphQL — consuming OR's `audit-trail-immutable` and `archival-destruction-workflow` abstractions (ADR-022 table rows \"Audit trail\" + \"Archival + destruction workflow\") plus shillinq's Archiefwet retention rules and docudesk's compliance documents.",
- "docsUrl": "openspec/specs/launchpad-compliance-audit-panel/spec.md"
+ "title": "Audit panel",
+ "summary": "A dedicated compliance and audit-trail view.",
+ "status": "soon",
+ "docsUrl": "openspec/specs/launchpad-compliance-audit-panel/spec.md",
+ "title_nl": "Auditpaneel",
+ "summary_nl": "Een eigen weergave voor compliance en het auditspoor."
},
{
"slug": "launchpad-enterprise-security-access",
- "title": "Spec: launchpad-enterprise-security-access",
- "summary": "Surface enterprise security and access posture on a launchpad dashboard through a read-only widget (`launchpad_security_access`). Three card surfaces compose the widget:",
- "docsUrl": "openspec/specs/launchpad-enterprise-security-access/spec.md"
+ "title": "SSO and access posture",
+ "summary": "You surface SAML, TOTP, and WebAuthn status on the dashboard.",
+ "status": "soon",
+ "docsUrl": "openspec/specs/launchpad-enterprise-security-access/spec.md",
+ "title_nl": "SSO en toegangsstatus",
+ "summary_nl": "Je toont de status van SAML, TOTP en WebAuthn op het dashboard."
},
{
- "slug": "launchpad-file-access-widget",
- "title": "Spec: launchpad-file-access-widget",
- "summary": "Surface dossier documents (and arbitrary Nextcloud Files objects) on a launchpad dashboard via a single widget (`launchpad_file_access`). The widget is a **quick-access surface** distinct from the existing `files-widget` (which embeds a folder browser): this widget renders a curated short-list of files the viewer needs from the dashboard — typically the documents attached to a dossier object via OR's `object-interactions` integration (ADR-019 / ADR-022).",
- "docsUrl": "openspec/specs/launchpad-file-access-widget/spec.md"
+ "slug": "launchpad-ai-dashboard-assistant",
+ "title": "AI assistant",
+ "summary": "You ask for the widget you need and have it placed for you.",
+ "status": "soon",
+ "docsUrl": "openspec/specs/launchpad-ai-dashboard-assistant/spec.md",
+ "title_nl": "AI-assistent",
+ "summary_nl": "Je vraagt om de widget die je nodig hebt en die wordt voor je geplaatst."
},
{
"slug": "launchpad-meeting-calendar-actions",
- "title": "Spec: launchpad-meeting-calendar-actions",
- "summary": "Surface meeting and agenda actions on a launchpad dashboard via the widget `launchpad_meeting_actions`. The widget composes data from two sources, presented as one timeline:",
- "docsUrl": "openspec/specs/launchpad-meeting-calendar-actions/spec.md"
- },
- {
- "slug": "launchpad-mobile-remote-access",
- "title": "Spec: launchpad-mobile-remote-access",
- "summary": "Define the **manifest contract** for declaring widget mobile-readiness so the launchpad workspace can render a coherent mobile + remote experience. The existing `responsive-grid-breakpoints` spec already handles the grid-side breakpoint engine; this spec adds the **per-widget declaration surface** in the app manifest (per ADR-024) and the fallback behaviour when no widget on a dashboard declares itself mobile-ready.",
- "docsUrl": "openspec/specs/launchpad-mobile-remote-access/spec.md"
- },
- {
- "slug": "launchpad-spend-analytics-widget",
- "title": "Spec: launchpad-spend-analytics-widget",
- "summary": "Surface procurement + financial spend analytics on a launchpad dashboard as a single widget (`launchpad_spend_analytics`). The widget consumes data live at render time via runtime GraphQL queries against financeq + procest (and, for evidence attachment, docudesk through OR's `object-interactions` integration registry per ADR-022). The widget MUST NOT add an install-time dependency on any sibling app — per `feedback_launchpad-no-or-dependency.md`, launchpad stays the always-available shell.",
- "docsUrl": "openspec/specs/launchpad-spend-analytics-widget/spec.md"
- },
- {
- "slug": "legacy-widget-bridge",
- "title": "Legacy Widget Bridge",
- "summary": "LaunchPad's grid can render widgets from two eras of the Nextcloud widget API: modern widgets that implement `IAPIWidget` / `IAPIWidgetV2` (covered by the [widgets](../widgets/spec.md) capability), and legacy widgets that use the older callback-registration pattern by calling `window.OCA.Dashboard.register(appId, callback)` at bootstrap. This capability covers the client-side bridge that captures those legacy registrations so LaunchPad can mount them into the grid on demand.",
- "docsUrl": "openspec/specs/legacy-widget-bridge/spec.md"
- },
- {
- "slug": "link-button-widget",
- "title": "Link-Button Widget",
- "summary": "The link-button widget is a built-in LaunchPad widget type that lets dashboard authors drop a styled, clickable tile onto a dashboard. The tile dispatches one of three explicit action types — open an external URL in a new tab, invoke a registered in-app workflow, or create a fresh document in the user's Files area. The capability formalises a typed `actionType` enum so the action set can grow safely (no fragile auto-detect-from-extension semantics like the earlier prototype), pairs the renderer with a singleton frontend registry of named internal actions, and pairs the createFile flow with a strictly-validated server endpoint that gates new files behind an admin-configurable extension allow-list.",
- "docsUrl": "openspec/specs/link-button-widget/spec.md"
- },
- {
- "slug": "links-widget",
- "title": "Links Widget",
- "summary": "Provide a multi-column dashboard widget that renders a curated grid of link cards organised into named sections. Distinct from the single-button `link-button-widget` and the high-density `quicklinks-widget`, this widget is optimised for \"link directory\" layouts: each section carries a heading and an arbitrary number of links, each link carries a label, URL, optional icon, and optional description, and the renderer offers three layout modes (`card`, `inline`, `icon-only`). All configuration lives in the placement `widgetContent` JSON — there is no backend data endpoint and no migration. URL sanitisation runs at save time to defend against `javascript:`, `data:`, and `file://` XSS vectors.",
- "docsUrl": "openspec/specs/links-widget/spec.md"
- },
- {
- "slug": "menu-widget",
- "title": "Menu Widget",
- "summary": "The menu widget is a built-in LaunchPad widget type that renders a hierarchical, in-page navigation tree distinct from the application sidebar. It supports up to three levels of nesting and three visual styles — `dropdown`, `megamenu`, and `tree` — so dashboard authors can publish curated link sets that fit the surrounding layout without writing custom Vue code.",
- "docsUrl": "openspec/specs/menu-widget/spec.md"
- },
- {
- "slug": "navigation-editor-org",
- "title": "Organization-wide Navigation Editor",
- "summary": "The `navigation-editor-org` capability provides a robust, admin-curated, group-aware org-wide navigation tree distinct from the personal dashboard list. Where the existing `dashboard-switcher-sidebar` shows the dashboards a user owns or can access, this capability adds a second navigation surface — an admin-controlled tree of links and sections shared across the whole organisation. Useful for company resources, policy hubs, and tools panels.",
- "docsUrl": "openspec/specs/navigation-editor-org/spec.md"
- },
- {
- "slug": "nc-dashboard-widget-proxy",
- "title": "nc-dashboard-widget-proxy",
- "summary": "Defines the user-facing surface of the Nextcloud Dashboard widget proxy (`nc-widget` placement type) — primarily the picker UX inside the unified Add Custom Widget modal. The renderer/contract for `nc-widget` placements themselves is owned by the `widgets` capability (REQ-WDG-018 onwards) and the bridge polling behaviour by `legacy-widget-bridge`. This spec narrows in on how end users discover and pick a Nextcloud-discovered widget when configuring an `nc-widget` placement.",
- "docsUrl": "openspec/specs/nc-dashboard-widget-proxy/spec.md"
- },
- {
- "slug": "nc-unified-search-integration",
- "title": "Nextcloud Unified Search Integration",
- "summary": "Nextcloud's unified search (Ctrl+K / Cmd+K) provides a global discovery mechanism for content across all installed apps. LaunchPad dashboards, widgets, and metadata are exposed to this search via a registered `OCP\\Search\\IProvider` so users can discover and navigate to dashboards by name, description, widget content, or metadata field values from the global search bar without entering the app first.",
- "docsUrl": "openspec/specs/nc-unified-search-integration/spec.md"
- },
- {
- "slug": "news-widget",
- "title": "News Widget",
- "summary": "The news widget aggregates RSS and Atom feed items from one or more configured sources and renders them on a LaunchPad dashboard. Per-placement configuration controls which feeds are included, the layout mode (list, grid, carousel), an optional item cap, presentation switches (thumbnails, summary, date format), and an optional metadata-based filter that suppresses the widget on dashboards whose metadata does not match. HTML sanitisation, host allow-listing, failure tolerance, and link-security defaults are all enforced server-side; the Vue renderer never re-sanitises and never performs upstream fetches.",
- "docsUrl": "openspec/specs/news-widget/spec.md"
- },
- {
- "slug": "orphaned-data-cleanup",
- "title": "Orphaned Data Cleanup",
- "summary": "Provide administrators with a comprehensive, safe, and auditable mechanism to scan for and remove orphaned LaunchPad data: expired locks and tokens, widget assets from deleted dashboards, metadata-value rows with missing field definitions, placements with no dashboard, tokens for deleted users, role assignments for deleted users/groups, and translations for deleted dashboards. The capability MUST support dry-run (safe preview), per-category selectivity (scan vs. auto-purge), background automation (daily safe-categories job), and audit trails (activity events). A registry pattern enables adding new cleanup categories without editing central code.",
- "docsUrl": "openspec/specs/orphaned-data-cleanup/spec.md"
- },
- {
- "slug": "people-widget",
- "title": "People Widget",
- "summary": "The `people-widget` capability registers a dashboard widget that displays a discoverable directory of Nextcloud users with customizable layout (card/grid/list), profile field visibility control, group filtering, and birthday tracking. The widget integrates with Nextcloud's Dashboard Widget API via `OCP\\Dashboard\\IManager`, stores configuration in the widget placement's JSON config, and provides a paginated API endpoint for user lookup. Results expose each user's profile fields as returned by `OCP\\Accounts\\IAccountManager`; scope-based visibility filtering is a planned follow-up (see REQ-PPL-004).",
- "docsUrl": "openspec/specs/people-widget/spec.md"
- },
- {
- "slug": "permissions",
- "title": "Permission Levels",
- "summary": "Permission levels control what users can do with their dashboards. When an admin template is distributed to users, the template's permission level is inherited by the user's personal copy, restricting their editing capabilities. This system allows administrators to create locked-down dashboards (e.g., a company-mandated layout with compulsory widgets) while still giving users varying degrees of customization freedom. The three levels -- `view_only`, `add_only`, and `full` -- form a hierarchy of increasing user control.",
- "docsUrl": "openspec/specs/permissions/spec.md"
- },
- {
- "slug": "prometheus-metrics",
- "title": "Prometheus Metrics",
- "summary": "Expose application metrics in Prometheus text exposition format at `GET /api/metrics` for monitoring, alerting, and operational dashboards. Additionally, provide a health check endpoint at `GET /api/health` for container orchestration and load balancer readiness probes.",
- "docsUrl": "openspec/specs/prometheus-metrics/spec.md"
- },
- {
- "slug": "quicklinks-widget",
- "title": "Quicklinks Widget",
- "summary": "The quicklinks widget is a built-in LaunchPad widget type that renders a flat, dense grid of icon-and-label shortcuts inside a single placement. Where the link-button widget owns one shortcut per placement and the links widget spreads grouped sections across multiple columns, the quicklinks widget targets the \"app launcher\" use case: 8–40 frequently used URLs in one widget, with admin-configurable icon size, shape, label position, columns, tile background, and hover effect. Bulk-add via CSV paste is first-class so admins can move dozens of shortcuts off a spreadsheet without typing each row.",
- "docsUrl": "openspec/specs/quicklinks-widget/spec.md"
- },
- {
- "slug": "resource-uploads",
- "title": "Resource Uploads",
- "summary": "The `resource-uploads` capability owns a small mini file API for binary assets that LaunchPad widgets reference directly: dashboard icons, image-widget images, link-button icons, etc. Resources are stored in LaunchPad's app-data folder (NOT the user's Files), addressed by a stable URL, uploaded admin-only via a base64-data-URL JSON request, and served back to any logged-in user via a non-OCS streaming endpoint plus an OCS listing endpoint. SVG sanitisation is specified in the sibling `svg-sanitisation` capability.",
- "docsUrl": "openspec/specs/resource-uploads/spec.md"
- },
- {
- "slug": "role-feature-permissions",
- "title": "Role Feature Permissions",
- "summary": "This capability governs which dashboard widgets and features are visible, accessible, and default-seeded for users based on their Nextcloud group (role). It ensures that staff see only the tools relevant to their job, that new users receive a role-appropriate starting layout seeded from evidence rather than a generic blank dashboard, and that attempts to access restricted features via direct URL are rejected at the API layer with a 403 response and an audit log entry.",
- "docsUrl": "openspec/specs/role-feature-permissions/spec.md"
- },
- {
- "slug": "runtime-shell",
- "title": "Runtime Shell",
- "summary": "The `runtime-shell` capability owns the user-facing workspace page chrome — the mount point, the sidebar toggle, the active-dashboard label strip, the empty-state branch, and the lifecycle hooks that bind it all together. It is the page-level orchestrator that coordinates four sibling capabilities (`dashboard-switcher`, `widget-add-edit-modal`, `widget-context-menu`, `grid-layout`) and gates editing affordances based on user role and active dashboard scope.",
- "docsUrl": "openspec/specs/runtime-shell/spec.md"
- },
- {
- "slug": "setup-wizard",
- "title": "Setup Wizard",
- "summary": "The Setup Wizard is a multi-step first-run configuration flow for freshly installed LaunchPad instances. It guides administrators through selecting a storage backend, setting group priority order, installing optional demo data, assigning admin roles, and configuring footer content. The wizard detects first-run state via an admin-setting flag, supports both interactive and non-interactive (CLI) flows, and ensures all choices are persisted immediately so progress is not lost.",
- "docsUrl": "openspec/specs/setup-wizard/spec.md"
- },
- {
- "slug": "text-display-widget",
- "title": "Text-Display Widget",
- "summary": "The text-display widget renders user-authored text content inside a dashboard cell, with limited HTML support for inline formatting (bold, italics, links, line breaks). It is the primary \"annotation\" widget — useful for section captions, instructions, contact details, callouts.",
- "docsUrl": "openspec/specs/text-display-widget/spec.md"
- },
- {
- "slug": "tiles",
- "title": "Custom Tiles",
- "summary": "Custom tiles are user-created shortcut cards that provide quick access to Nextcloud apps or external URLs. Unlike widgets (which render dynamic content from Nextcloud apps), tiles are simple, static cards with an icon, label, and link. Tiles are first created as reusable entities in the `oc_launchpad_tiles` table, then placed onto dashboards via a special tile placement mechanism that stores tile data inline on the placement. This inline-copy model means tile placements are independent snapshots -- changes to the tile definition do NOT propagate to existing placements.",
- "docsUrl": "openspec/specs/tiles/spec.md"
- },
- {
- "slug": "video-widget",
- "title": "Video Widget",
- "summary": "Embed video content directly on a LaunchPad dashboard from four source types: YouTube, Vimeo, self-hosted PeerTube instances, and Nextcloud Files. Hosted-platform embeds use a sandboxed iframe with the canonical embed URL (extracted server-side at save time); internal-file embeds use a native HTML5 `` element backed by an ACL-checked streaming endpoint. An admin-controlled domain allow-list governs which hosted origins may be embedded; an empty list is interpreted as \"deny all\" to fail safe.",
- "docsUrl": "openspec/specs/video-widget/spec.md"
- },
- {
- "slug": "widgets",
- "title": "Widgets",
- "summary": "Widgets are the primary content blocks on LaunchPad dashboards. LaunchPad integrates with the Nextcloud Dashboard Widget API (v1 and v2) via `OCP\\Dashboard\\IManager::getWidgets()` to discover all registered dashboard widgets across installed Nextcloud apps. Users can add these discovered widgets to their dashboards as \"placements\" -- records that track the widget's position on the grid, display configuration, and custom styling. Widget placements bridge the Nextcloud widget ecosystem with the LaunchPad grid layout system.",
- "docsUrl": "openspec/specs/widgets/spec.md"
+ "title": "Meeting actions",
+ "summary": "You act on the day's meetings straight from a card.",
+ "status": "soon",
+ "docsUrl": "openspec/specs/launchpad-meeting-calendar-actions/spec.md",
+ "title_nl": "Vergaderacties",
+ "summary_nl": "Je handelt de vergaderingen van de dag direct vanaf een kaart af."
}
]
diff --git a/docs/features/README.md b/docs/features/README.md
index 0abe675a..930ad06d 100644
--- a/docs/features/README.md
+++ b/docs/features/README.md
@@ -25,6 +25,7 @@ LaunchPad maps to the **BI-component** within the GEMMA reference architecture.
| [Admin Templates](./admin-templates.md) | Pre-configured dashboards distributed to users by Nextcloud group membership | [admin-templates.md](./admin-templates.md) |
| [Admin Settings](./admin-settings.md) | Global configuration: allow user dashboards, max dashboards per user, default grid columns | [admin-settings.md](./admin-settings.md) |
| [Conditional Visibility](./conditional-visibility.md) | Show or hide widget placements based on time, date, group membership, or user attributes | [conditional-visibility.md](./conditional-visibility.md) |
+| [Clock & Weather](./clock-weather-widgets.md) | Ambient tiles: a client-side clock (analog/digital, timezone) and a server-fetched, cached weather reading with locale-driven units | [clock-weather-widgets.md](./clock-weather-widgets.md) |
| [Prometheus Metrics](./prometheus-metrics.md) | Monitoring endpoint: dashboard count, widget usage, tile counts, health check | [prometheus-metrics.md](./prometheus-metrics.md) |
## Architecture
diff --git a/docs/features/admin-template-resync.md b/docs/features/admin-template-resync.md
new file mode 100644
index 00000000..73841ddc
--- /dev/null
+++ b/docs/features/admin-template-resync.md
@@ -0,0 +1,62 @@
+# Re-syncing an admin template
+
+When an admin template is distributed, each targeted user receives an
+**independent personal copy**. That independence is what lets people
+personalise their dashboard — but it also means that, without this feature,
+correcting a template only ever reached *future* first-logins. A functioneel
+beheerder who fixed a wrong link in the Burgerzaken template still had 40
+colleagues looking at the old one.
+
+Re-sync closes that gap: it pushes an updated template out to copies that
+already exist.
+
+## The two strategies
+
+| Strategy | What happens to the template's widgets | What happens to the user's own widgets |
+|----------|----------------------------------------|----------------------------------------|
+| **Merge** (default) | Updated to match the template | **Kept** |
+| **Overwrite** | Replaced wholesale with the template layout | **Removed** |
+
+Use **merge** for routine corrections — a changed link, a new compulsory
+announcement — so nobody loses the shortcuts they added. Use **overwrite**
+only when you genuinely intend to reset a department to the standard layout,
+and tell people first.
+
+Compulsory widgets are reconciled under **both** strategies: a widget the
+template pins cannot be missing from a copy after a re-sync.
+
+## Always dry-run first
+
+The action supports `dryRun`, which reports exactly which copies would change
+and what would happen to each — **without mutating anything**. Run it, read
+it, then run for real. This is the difference between "I think this is safe"
+and "I know what this will do to 40 people's screens."
+
+```http
+POST /apps/launchpad/api/admin/templates/{id}/resync
+{ "strategy": "merge", "dryRun": true }
+```
+
+## What else happens
+
+- The operation is **idempotent** — running it twice produces no further change.
+- Each run writes an **audit record** (who, what, when).
+- Affected users are **notified**.
+- For large target groups the work is handed to a background job rather than
+ blocking the request.
+
+## Permissions
+
+Admin-only, guarded both by the `AuthorizedAdminSetting` attribute and an
+explicit in-body admin assertion.
+
+## Known limitation
+
+Notifications are delivered via Nextcloud's `INotification` — the app's
+existing (and only) notification pattern. The `x-openregister-notifications`
+dialect branch is not wired in; see the archived change's `tasks.md`.
+
+## Related
+
+- [Admin Templates](admin-templates.md) — authoring and distributing templates.
+- [Permission Levels](permissions.md) — what a copy's permission level allows.
diff --git a/docs/features/clock-weather-widgets.md b/docs/features/clock-weather-widgets.md
new file mode 100644
index 00000000..08747f7c
--- /dev/null
+++ b/docs/features/clock-weather-widgets.md
@@ -0,0 +1,87 @@
+# Clock & Weather widgets
+
+Two lightweight "ambient tile" widgets that give a dashboard a sense of time
+and place. Both are placed like any other widget and configured from the
+widget settings panel; their configuration lives in the placement's
+`widgetContent` JSON.
+
+## Clock
+
+A fully **client-side** widget — it reads the device clock and makes no
+network request and no backend call at all.
+
+| Setting | Values | Notes |
+|---------|--------|-------|
+| Style | `digital`, `analog` | Digital shows a formatted time string; analog draws a clock face. |
+| Hour format | 12-hour, 24-hour | Applies to the digital style and the accessible label. |
+| Timezone | any IANA zone (e.g. `Europe/Amsterdam`) | Converted with `Intl`; defaults to the browser's zone. |
+| Show date | on / off | Date is rendered in the viewer's locale. |
+
+**Accessibility.** The rendered time is always available to screen readers as
+a text string, including for the analog style, so the widget is never a
+purely visual element.
+
+**Typical use.** A kiosk or narrowcasting screen in a public hall, or a
+service-desk dashboard where a shared, unambiguous clock (and, for
+distributed teams, a second tile pinned to another timezone) matters.
+
+## Weather
+
+Shows current conditions for a location. Unlike the clock, this widget needs
+data, so the fetch happens **server-side** and the result is cached — the
+browser never sees a provider URL or API key.
+
+| Setting | Values | Notes |
+|---------|--------|-------|
+| Location | free text | Leave empty to use the viewer's own Nextcloud `weather_status` location. |
+| Units | follow locale (default), metric, imperial | An explicit choice overrides the locale default. |
+
+### How the reading is resolved
+
+1. The widget calls LaunchPad's own endpoint, `GET /api/weather/{placementId}`.
+2. The endpoint checks that the caller may view that placement — an
+ unauthorised caller gets `403` and **no fetch is performed**.
+3. `WeatherService` returns a cached reading when one exists inside the TTL
+ (default 900 s, configurable).
+4. Otherwise it fetches: the viewer's `weather_status` provider when no
+ location is configured, else the configured provider URL.
+5. If the upstream fails but an older reading exists, that reading is served
+ with `stale: true` rather than an error. With no cached reading at all the
+ endpoint returns `502` and the widget renders an error state.
+
+The response contains exactly `location`, `tempValue`, `units`, `condition`,
+`conditionText`, `language`, `fetchedAt`, `stale` — never a credential.
+
+### Locale-driven units and language
+
+Units and language follow the **viewer's** Nextcloud locale by default, so a
+`nl_NL` colleague sees °C and Dutch condition text while an `en_US` colleague
+on the same shared dashboard sees °F. An author-set units override wins over
+the locale default. This is deliberate: hardcoding units or English-only
+condition strings is a long-standing source of complaints about weather
+widgets.
+
+**Accessibility.** The condition is conveyed by an icon **and** a text label,
+never by icon or colour alone.
+
+### Admin setup
+
+Only needed when you are not relying on the viewer's `weather_status`
+location. Both values are stored server-side and never sent to the browser:
+
+| App config key | Meaning |
+|----------------|---------|
+| `weather_provider_url` | Provider endpoint template; supports the placeholders `{location}`, `{apiKey}`, `{units}`, `{lang}`. |
+| `weather_provider_api_key` | Provider API key, substituted into `{apiKey}`. |
+| `weather_cache_ttl_seconds` | Cache TTL; defaults to 900. |
+
+```bash
+occ config:app:set launchpad weather_provider_url --value='https://api.example/weather?q={location}&units={units}&lang={lang}&appid={apiKey}'
+occ config:app:set launchpad weather_provider_api_key --value='…'
+```
+
+## Related
+
+- [Widgets](widgets.md) — how widgets are discovered and placed.
+- [Conditional visibility](conditional-visibility.md) — show an ambient tile
+ only during opening hours, or only to one group.
diff --git a/docs/features/conditional-visibility.md b/docs/features/conditional-visibility.md
index 95bb49c0..063c1f92 100644
--- a/docs/features/conditional-visibility.md
+++ b/docs/features/conditional-visibility.md
@@ -26,6 +26,43 @@ Conditional visibility allows widget placements to be shown or hidden based on d
| POST | `/api/widgets/{id}/rules` | Add rule to placement |
| PUT | `/api/rules/{id}` | Update rule |
| DELETE | `/api/rules/{id}` | Delete rule |
+| POST | `/api/visibility/preview` | Preview a rule set (see below) — read-only, persists nothing |
+
+## Visibility rules & preview
+
+The rules above are edited from the widget's right-click context menu →
+**Visibility rules…**, which opens the `ConditionalVisibilityEditor`. Each
+rule is a row (`VisibilityRuleRow`) where you pick a type (group / time /
+date / attribute), fill in the type-specific fields, and choose whether the
+rule **includes** or **excludes**:
+
+- Rules are grouped under two headings that spell out the engine's logic
+ directly: **"Show when ANY of these match"** (include rules, OR — at
+ least one must match) and **"Hide when ANY of these match"** (exclude
+ rules, AND — any single match hides the widget, overriding the include
+ rules).
+- With no rules at all, the widget is always shown — the editor states this
+ explicitly rather than leaving an empty list ambiguous.
+
+### Preview as audience / date
+
+Before saving, use **Preview as audience / date** to pick a set of groups
+and a moment in time and see the effective visibility for that context —
+"Visible" or "Hidden", plus which rule(s) matched. This includes rows you
+have added or edited but not yet saved, so a mis-scoped rule (e.g. an
+exclude rule that would hide the widget from everyone) can be caught before
+it goes live.
+
+The preview endpoint (`POST /api/visibility/preview`) evaluates the
+supplied rule set through the exact same evaluation pipeline used when the
+dashboard is actually rendered — it cannot diverge from real visibility,
+and it never writes to the database.
+
+See the [`conditional-visibility` engine spec](../../openspec/specs/conditional-visibility/spec.md)
+for the full rule-evaluation semantics (including known limitations such as
+midnight-spanning time windows) and the
+[`conditional-visibility-editor` spec](../../openspec/changes/conditional-visibility-editor/specs/conditional-visibility-editor/spec.md)
+for the editor/preview requirements.
## Screenshot
diff --git a/docs/features/iframe-embed.md b/docs/features/iframe-embed.md
new file mode 100644
index 00000000..71d89f94
--- /dev/null
+++ b/docs/features/iframe-embed.md
@@ -0,0 +1,67 @@
+# Iframe-embed widget
+
+Embed an external page — a status board, a Grafana panel, an internal
+tool — directly on a dashboard, instead of only linking out to it.
+
+## The host allow-list
+
+Embeddable targets are governed by the `iframe_allowed_hosts` app config
+and are **fail-closed**: enforced both when the widget is saved and again
+whenever the dashboard is rendered. An empty (or unset) allow-list denies
+**every** host — it is never interpreted as "allow all".
+
+```bash
+occ config:app:set launchpad iframe_allowed_hosts --value='["status.example.com","intranet.example.nl"]'
+```
+
+Removing a host from the list immediately stops any existing placement
+pointing at it — the widget switches to the "no longer permitted" state
+rather than continuing to render a stale live frame.
+
+## What LaunchPad's CSP contributes — and what it doesn't
+
+Every allow-listed host is added to LaunchPad's own `frame-src`
+Content-Security-Policy directive (via an `AddContentSecurityPolicyEvent`
+listener), so Nextcloud's own CSP never blocks an otherwise-permitted
+embed. This is the **only** side of the framing relationship LaunchPad
+controls.
+
+The **target** site's own `X-Frame-Options: DENY` or
+`Content-Security-Policy: frame-ancestors 'none'` header is a decision made
+by that site's owner and **cannot be overridden** by the embedder — no CSP
+change on LaunchPad's side can force such a target to render in a frame.
+When the widget detects this (no `load` event within a timeout, or a
+`load` event that resolves to an empty same-origin placeholder document),
+it renders a fallback card instead of a silent blank frame: the
+configured title, a plain-language explanation, and an "Open in new tab"
+link. This is a client-side detection, not a proxy or CSP bypass — nothing
+strips or spoofs the target's own headers.
+
+## Sandbox
+
+The iframe always carries a `sandbox` attribute. Authors may toggle
+`allow-scripts`, `allow-same-origin`, `allow-forms`, and `allow-popups`;
+`allow-top-navigation` (and its `-by-user-activation` variant) is never
+offered and is stripped even if present in a saved config, so an embedded
+frame can never navigate the host dashboard page away.
+
+## Configuration
+
+| Setting | Notes |
+|---------|-------|
+| URL | Validated against the admin allow-list, both client-side (fast feedback) and server-side (authoritative) |
+| Title | Required — exposed as the iframe's accessible `title` for screen readers |
+| Height / aspect ratio | Fixed pixel height, or one of `16:9` / `4:3` / `1:1` / `9:16` |
+| Sandbox tokens | `allow-scripts`, `allow-same-origin`, `allow-forms`, `allow-popups` |
+
+## Accessibility
+
+The blocked/failed state is conveyed by an icon **and** a text label,
+never by colour alone, and the "Open in new tab" link is keyboard-focusable
+and announces that it opens in a new tab.
+
+## Related
+
+- [Widgets](widgets.md) — how widgets are discovered and placed.
+- [Live-data tile](live-data-tile.md) — the sibling capability this widget's
+ allow-list/CSP approach is modelled on.
diff --git a/docs/features/live-data-tile.md b/docs/features/live-data-tile.md
new file mode 100644
index 00000000..f4251e57
--- /dev/null
+++ b/docs/features/live-data-tile.md
@@ -0,0 +1,82 @@
+# Live-data tile
+
+A tile that shows a **live value** — an open-case count, a queue length, a
+budget figure — instead of being a static shortcut. It polls a source on a
+schedule, formats the value, and can badge it against thresholds.
+
+This closes LaunchPad's biggest functional gap against the wider dashboard
+market: every serious competitor renders live data on tiles.
+
+## Two ways to get the value
+
+### 1. Via OpenConnector (preferred)
+
+When the [OpenConnector](https://github.com/ConductionNL/openconnector) app is
+installed and advertises the `dashboard-http-datasource` capability, pick a
+pre-configured **source** and give a value expression. OpenConnector owns the
+credentials, host allow-listing, rate-limiting and caching; LaunchPad only asks
+for a value.
+
+Use this whenever the upstream needs authentication.
+
+### 2. Direct URL (fallback)
+
+When OpenConnector is not installed, a tile can poll a URL directly — but only
+if its host appears in the administrator's allow-list. This mode is intended
+for unauthenticated internal endpoints.
+
+If OpenConnector is absent the connector mode is hidden in the tile form, and
+any tile already configured for it renders a clear "data source unavailable"
+state rather than failing silently.
+
+## Configuration
+
+| Setting | Notes |
+|---------|-------|
+| Source mode | `connector` (OpenConnector source) or `url` (direct, allow-listed) |
+| Value expression | JSONPath-lite: `$.data.open_count`, `$.items[0].total` |
+| Refresh interval | Seconds; clamped to a 30 s minimum, defaults to 300 s |
+| Formatting | Prefix, suffix, thousands separator |
+| Badge thresholds | Value ranges mapped to ok / warn / alert |
+| Link target | Where the tile navigates when activated |
+
+## What the browser never sees
+
+The widget calls LaunchPad's own endpoint, `GET /api/livetile/{placementId}`,
+and receives only `{value, formatted, badge, fetchedAt, stale}`. The source
+URL, request headers and any credential stay on the server. A caller who may
+not view the placement gets `403` and **no fetch is performed**.
+
+## The host allow-list
+
+Direct-URL mode is governed by the `livetile_allowed_hosts` app config and is
+**fail-closed** — enforced both when the tile is saved and again at every
+fetch. Removing a host from the allow-list therefore immediately stops
+existing tiles pointing at it, rather than leaving them running until someone
+notices.
+
+```bash
+occ config:app:set launchpad livetile_allowed_hosts --value='intranet.example.nl,api.example.nl'
+```
+
+An empty allow-list denies everything.
+
+## Stale values
+
+If an upstream refresh fails, the last known value is served with `stale:
+true` and the tile marks it as possibly out of date. On a service-desk or wall
+display a slightly old number is more useful than an empty tile — but it must
+be visibly flagged, so the staleness is never silent.
+
+## Accessibility
+
+The badge state is conveyed by an icon **and** a text label, never by colour
+alone, and the value carries an accessible label. This matters here because a
+threshold badge is exactly the kind of red/green signal that becomes invisible
+to a colour-blind colleague.
+
+## Related
+
+- [Widgets](widgets.md) — how widgets are discovered and placed.
+- OpenConnector `dashboard-http-datasource` — the governed resolve façade this
+ tile consumes as a leaf.
diff --git a/docs/features/service-health-ping.md b/docs/features/service-health-ping.md
new file mode 100644
index 00000000..a0e6835d
--- /dev/null
+++ b/docs/features/service-health-ping.md
@@ -0,0 +1,78 @@
+# Service health ping
+
+An optional **online / offline / degraded** status badge on a tile, so a
+municipal IT landing page can answer *"is de zaakapplicatie bereikbaar?"* at a
+glance instead of a static link that gives no signal about whether the
+service behind it is actually up.
+
+A background job periodically pings the tile's configured health URL
+server-side, the result is cached with a short TTL, and the tile renders the
+badge from that cache — viewers never pay the upstream ping latency on page
+load.
+
+## Configuration
+
+Health ping is configured per tile, in the same editor used for the tile's
+title, icon and colours:
+
+| Setting | Notes |
+|---------|-------|
+| Enable health ping | Off by default — no badge, no request, until turned on |
+| Health check URL | Must resolve to a host on the administrator's allow-list |
+| Expected HTTP status | Defaults to any 2xx/3xx when left unset |
+| Check interval | Seconds; clamped to a 15 s minimum, defaults to 60 s |
+
+The config is stored in the placement's existing content JSON — no database
+schema change.
+
+## Classification
+
+- **Online** — the response status matches the expected status within the
+ latency threshold.
+- **Degraded** — the status matches, but the response was slow.
+- **Offline** — the request timed out, the connection failed, or the status
+ did not match. This is a *completed* reading, not a missed one: it is
+ cached and served immediately, exactly like online/degraded.
+
+## What the browser never sees
+
+The badge calls LaunchPad's own endpoint, `GET
+/api/health-ping/{placementId}`, and receives only `{state, checkedAt,
+latencyMs, stale}`. The health URL, request headers and any upstream response
+body stay on the server. A caller who may not view the placement gets `403`
+and **no ping is performed**.
+
+## The host allow-list
+
+Health ping is governed by the `healthping_allowed_hosts` app config and is
+**fail-closed** — enforced both when the tile is saved and again at every
+ping. When a host is refused, no request is ever attempted: the badge falls
+back to the last-known reading (marked stale) rather than showing a false
+"up" state.
+
+```bash
+occ config:app:set launchpad healthping_allowed_hosts --value='intranet.example.nl,api.example.nl'
+```
+
+An empty allow-list denies everything.
+
+## Background refresh
+
+`HealthPingRefreshJob` runs every 15 seconds and refreshes any ping-enabled
+tile whose cached badge is older than its own configured interval, so the
+badge a viewer sees on page load is almost always already warm.
+
+## Accessibility
+
+The badge state is conveyed by an icon **and** a text label — "Online",
+"Degraded", "Offline" — never by colour alone, and the checked-at time plus
+latency are exposed via a keyboard-reachable, screen-reader announced
+tooltip.
+
+## Related
+
+- [Custom Tiles](tiles.md) — where the health-ping toggle lives in the tile
+ editor.
+- [Live-data tile](live-data-tile.md) — the sibling capability this ping
+ reuses the shape of (allow-listed server-side fetch, `ICache`, stale
+ fallback).
diff --git a/docs/features/tiles.md b/docs/features/tiles.md
index c226e065..46341559 100644
--- a/docs/features/tiles.md
+++ b/docs/features/tiles.md
@@ -20,6 +20,47 @@ Custom tiles are user-created shortcut cards that provide quick access to Nextcl
| DELETE | `/api/tiles/{id}` | Delete tile |
| POST | `/api/dashboard/{id}/tile` | Place tile on dashboard |
+## Usage analytics
+
+Tile usage analytics is a strict, downward **extension** of the
+[dashboard view-analytics](../../openspec/specs/dashboard-view-analytics/spec.md)
+capability at the tile/widget-placement grain — it does not introduce
+any new privacy machinery, only a finer-grained aggregate table.
+
+- Aggregate-only counts stored in `oc_launchpad_tile_clicks`, one row
+ per `(placementUuid, clickBucket)` per UTC day. No per-event rows
+ are ever persisted.
+- Unique-actor dedup reuses the SAME salted-daily-hash mechanism
+ (`sha256(userId || dailySalt)`, cached in `ICache` only) and the
+ SAME `SaltRotationJob` as dashboard views — no second salt or
+ rotation job.
+- Reuses the SAME `launchpad.analytics_enabled` (global) and
+ `launchpad.analytics_optout` (per-user) settings. There is no
+ separate tile-analytics opt-out.
+- The existing analytics retention-purge job is extended to also
+ purge `oc_launchpad_tile_clicks` rows older than
+ `launchpad.analytics_retention_days` in the same run — no second
+ purge job.
+- The frontend fires a fire-and-forget `POST /api/tile-click/{id}` on
+ tile activation (click or keyboard Enter), gated by
+ `GET /api/tile-analytics/config` so tracking is suppressed
+ client-side when analytics is disabled or the user opted out.
+
+### API Endpoints
+
+| Method | Endpoint | Auth | Description |
+|--------|----------|------|-------------|
+| POST | `/api/tile-click/{placementId}` | Any authed user | Record a click (always 204; no-op when disabled/opted out) |
+| GET | `/api/tile-analytics/config` | Any authed user | Whether tracking is active for the caller |
+| GET | `/api/admin/analytics/tiles/top` | Admin | Top-N tiles by click count for a period |
+| GET | `/api/admin/analytics/tiles/by-dashboard/{uuid}` | Admin | Per-dashboard tile breakdown |
+| GET | `/api/admin/analytics/tiles/export` | Admin | CSV export |
+
## Screenshot

+
+## Related
+
+- [Service health ping](service-health-ping.md) — optional online / offline /
+ degraded status badge for a tile's linked service.
diff --git a/docs/intro.md b/docs/intro.md
index 795e521e..41cecf75 100644
--- a/docs/intro.md
+++ b/docs/intro.md
@@ -1,6 +1,6 @@
---
sidebar_position: 1
-description: Get started with LaunchPad, customizable dashboards for Nextcloud. Compose KPI widgets and live charts on top of your OpenRegister data.
+description: Get started with LaunchPad, drag-and-drop dashboards for Nextcloud with templates, widgets, role-based access, and dashboard sharing.
---
# LaunchPad
@@ -9,10 +9,14 @@ LaunchPad provides an enhanced, customizable dashboard experience for Nextcloud.
## Features
-- Configurable dashboard widgets
-- Personal and shared dashboard layouts
-- Integration with Nextcloud apps
-- KPI cards, charts, and activity feeds
+- Drag-and-drop grid dashboards, personal or shared per group
+- A wide widget library (text, image, link, files, people, news, calendar,
+ video, container, native Nextcloud dashboard widgets, and more)
+- Admin templates with permission levels and compulsory widgets
+- Conditional widget visibility (group, time of day, date)
+- Role-based widget access from Nextcloud group membership
+- Dashboard sharing — per user/group, or a public read-only link
+- Activity feed integration and full-text search
## Getting Started
diff --git a/docs/market/market-position-2026-07-23.md b/docs/market/market-position-2026-07-23.md
new file mode 100644
index 00000000..b286961b
--- /dev/null
+++ b/docs/market/market-position-2026-07-23.md
@@ -0,0 +1,78 @@
+
+
+# LaunchPad — market position & gap analysis (2026-07-23)
+
+Deep-research snapshot backing the `openspec/changes/*` market-gap wave. Full
+evidence (33 competitors, 11 stakeholders, 17 market insights, 23 external
+sources, 13 journeys, 17 gap features, 1 ecosystem gap) is logged in the
+Spectr intelligence register (`spectr` register, `source_ref =
+lp-research-2026-07-23`).
+
+## Positioning in one line
+
+**LaunchPad is the only governed multi-dashboard builder inside the Nextcloud
+ecosystem** — and the only dashboard product anywhere that pairs
+admin-distributed templates + conditional visibility + kiosk/public-share
+with NL Design System theming, EUPL licensing and on-prem hosting. That is an
+ownable, sovereignty-first position for Dutch gemeenten and MKB.
+
+## The competitive field
+
+| Segment | Players | Threat to LaunchPad |
+|---|---|---|
+| Dutch adaptive workspace | **Workspace 365** (£6.80–£10.20/user/mo) | High — same story, same buyers, but M365-tied |
+| Microsoft incumbent | **Viva Connections** (free with M365) | High — free where the buyer is already on M365 |
+| Intranet SaaS | Happeo, LumApps, Staffbase, Simpplr, Unily, Basaas, Omnia, Powell | Medium — upmarket, quote-based, US/DACH data-residency problems |
+| Self-hosted OSS dashboards | Homarr, gethomepage, Glance, Dashy, Heimdall, Organizr, Flame | Sets the UX bar (live tiles, status pings, search) but none target organisations |
+| BI dashboards | Grafana, Metabase, Superset, Redash | UX benchmark for composition/provisioning, not portals |
+| Nextcloud-native | built-in Dashboard, Analytics (Rello), External Sites, AppOrder, Custom Menu, iFrame Widget | The DIY status quo LaunchPad replaces |
+
+Commercial price corridor is **€6–12/user/month**; LaunchPad (EUPL, free)
+undercuts all of it — a per-org support/hosting proposition differentiates
+against every commercial player while OSS rivals ignore organisations
+entirely.
+
+## Why the moat holds
+
+- The Nextcloud app-store Dashboard category is only micro-widgets + LaunchPad; no competing builder exists.
+- Native Dashboard is single-page, fixed-layout, per-user; admin defaults are `occ`-only, instance-wide, and don't touch existing users. The highest-voted dashboard wishes (admin default per group #25553, resizable/pinned widgets #39562, iframe widget, per-group landing) sit **closed-unimplemented**, and Hub 25/26 shipped **no** dashboard investment — low risk of Nextcloud building this natively near-term.
+- ~40 Dutch gemeenten are moving onto a sovereign Nextcloud cloud — direct pull for a gemeente-ready, NLDS-themed, WCAG-AA portal.
+
+## The gaps we are closing (this change wave)
+
+Ranked by researched demand. Each row is an `openspec/changes/*` change on
+`development`.
+
+| Change | Gap | Priority | Route |
+|---|---|---|---|
+| `live-data-tile-widget` | Static tiles → live data tiles (the #1 functional gap; 12/12 competitors) | must | LaunchPad widget **+ OpenConnector `dashboard-http-datasource` leaf** |
+| `conditional-visibility-editor` | Rules engine has no UI; add editor + preview-as-audience/date | must | LaunchPad (UI over existing engine) |
+| `admin-template-resync` | Template edits never reach already-provisioned copies | must | LaunchPad (extends admin-templates) |
+| `tile-quick-search` | No on-dashboard launcher/search bar (9/9 competitors) | should | LaunchPad (runtime-shell) |
+| `service-health-ping` | No tile up/down status ("is de zaakapplicatie bereikbaar?") | should | LaunchPad widget |
+| `iframe-embed-widget` | CSP-aware external-URL embed (a whole micro-app niche) | should | LaunchPad widget |
+| `tile-usage-analytics` | Per-tile click analytics for the KPI-review flow | should | LaunchPad (extends dashboard-view-analytics) |
+| `clock-weather-widgets` | Ambient clock/weather widgets (startpage staples) | could | LaunchPad widgets |
+
+### Leaf reintegration (cross-app boundary)
+
+`live-data-tile-widget` deliberately does **not** put third-party HTTP,
+credentials or egress control in LaunchPad. That capability lives in
+**OpenConnector** as `dashboard-http-datasource` (a governed, read-only
+"resolve one value from a configured source" façade over the existing
+source/HTTP/auth engines). LaunchPad consumes it as a **leaf** through a
+runtime capability probe — no static OpenConnector imports — and degrades to
+a minimal allow-listed direct GET when OpenConnector is absent, per the
+`runtime-or-consumption` policy.
+
+## Already-specced-but-unbuilt (verify before duplicating)
+
+Several proposed changes already cover adjacent gaps: `public-dashboard-publication`
+and the public-share API (public-share UI), `scheduled-exports`,
+`drill-down-cross-widget-filter` (dashboard variables), `embedded-analytics`
+(iframe/JS-SDK embed + tokens), `keyboard-accessible-widget-repositioning`,
+`map-support`, `launchpad-ai-dashboard-assistant`. The gap wave above is the
+set with **no** existing change.
diff --git a/docs/migration/widget-library-to-ncvue.md b/docs/migration/widget-library-to-ncvue.md
index 85879b05..04758f62 100644
--- a/docs/migration/widget-library-to-ncvue.md
+++ b/docs/migration/widget-library-to-ncvue.md
@@ -31,8 +31,8 @@ The nc-vue widget library is **not finished as a public API**, and **not publish
So "port to nc-vue" means: finish, export, document, test, parity-audit, and
**publish** a ~36-component library in the shared fleet lib (consumed by
-OpenRegister / OpenCatalogi / Procest / Pipelinq / LaunchPad), then migrate launchpad
-onto it. That is multi-day and has fleet-wide blast radius — it cannot be done in
+OpenRegister / OpenCatalogi / Procest / Pipelinq / LaunchPad), then migrate
+LaunchPad onto it. That is multi-day and has fleet-wide blast radius — it cannot be done in
one pass, and a half-done state breaks both repos.
## Parity audit — current nc-vue readiness
diff --git a/docs/package-lock.json b/docs/package-lock.json
index a9dccbd2..52539f92 100644
--- a/docs/package-lock.json
+++ b/docs/package-lock.json
@@ -8,7 +8,7 @@
"name": "launchpad-docs",
"version": "0.0.0",
"dependencies": {
- "@conduction/docusaurus-preset": "^3.24.0",
+ "@conduction/docusaurus-preset": "^3.26.0",
"@docusaurus/core": "^3.10.0",
"@docusaurus/preset-classic": "^3.10.0",
"@docusaurus/theme-mermaid": "^3.10.0",
@@ -2043,9 +2043,9 @@
}
},
"node_modules/@conduction/docusaurus-preset": {
- "version": "3.24.0",
- "resolved": "https://registry.npmjs.org/@conduction/docusaurus-preset/-/docusaurus-preset-3.24.0.tgz",
- "integrity": "sha512-T6LwvArwaF6QZfnc36zSTiOGEKmCIXaIBjiLAEFR7mcC1EfCUrZI6Dsf1z9BtC971jruTX3cE3+l4NxMo3LgzQ==",
+ "version": "3.26.0",
+ "resolved": "https://registry.npmjs.org/@conduction/docusaurus-preset/-/docusaurus-preset-3.26.0.tgz",
+ "integrity": "sha512-Nh7Ekl0dwKWxrb4y3aRtEl98blkNsr8LOa/ixrrVUGrvL/l7YOaGnlfNWt2pQZvBd7u4jt4E4qHfu4DJDrnUJA==",
"license": "EUPL-1.2",
"bin": {
"validate-ai-baseline": "bin/validate-ai-baseline.mjs"
diff --git a/docs/package.json b/docs/package.json
index dd471977..740cc640 100644
--- a/docs/package.json
+++ b/docs/package.json
@@ -17,7 +17,7 @@
"ci": "npm ci --legacy-peer-deps && npm run build"
},
"dependencies": {
- "@conduction/docusaurus-preset": "^3.24.0",
+ "@conduction/docusaurus-preset": "^3.26.0",
"@docusaurus/core": "^3.10.0",
"@docusaurus/preset-classic": "^3.10.0",
"@docusaurus/theme-mermaid": "^3.10.0",
diff --git a/docs/tutorials/user/02-create-dashboard.md b/docs/tutorials/user/02-create-dashboard.md
index 6c55e63f..60d8dc87 100644
--- a/docs/tutorials/user/02-create-dashboard.md
+++ b/docs/tutorials/user/02-create-dashboard.md
@@ -10,7 +10,11 @@ Each personal dashboard is an independent canvas — its own layout, its own wid
## Goal
-Create a new personal dashboard, give it a name, optionally add a description and an icon, and land on it ready to add widgets.
+Create a new personal dashboard by forking the one you're on, then rename it and land on it ready to customise.
+
+:::info How "Add dashboard" works
+The **+ Add dashboard** button **forks the dashboard you're currently viewing** into a fresh personal copy — it does *not* open a blank-name modal. The new dashboard is created immediately, named **"My copy of <current name>"**, seeded with a copy of the current dashboard's widgets, and activated. Rename it afterwards via [Dashboard configuration…](10-rename-or-delete.md). This means a new dashboard always starts from a working layout rather than an empty grid.
+:::
## Prerequisites
@@ -18,39 +22,37 @@ Create a new personal dashboard, give it a name, optionally add a description an
## Steps
-### 1. Open the sidebar and click **+ Add dashboard**
+### 1. Open the dashboard you want to base the new one on
-
+The fork copies *this* dashboard's widgets, so start from whichever layout is the best starting point.
-### 2. Fill in the create modal
+### 2. Open the sidebar and click **+ Add dashboard**
-A configuration modal opens with these fields:
+
-- **Name** — required. Used for the sidebar label and the URL slug.
-- **Description** — optional. Shown in admin tooling and inside the configuration modal.
-- **Icon** — optional. Pick from the registered icon set, or paste a URL for a custom icon (see [Dashboard icons capability](../../features/dashboards.md)).
+A new dashboard named **"My copy of <current name>"** is created and activated immediately — no modal, no Save step. You land on it at its own URL.
-
+### 3. Rename it (and set an icon)
-### 3. Click **Save**
+Open the active dashboard's cog menu → **Dashboard configuration…** and edit the **Name**, optional **Description**, and **Icon** (a searchable Material Design Icons picker plus a Custom tab for a URL/upload). See [Rename or delete a dashboard](10-rename-or-delete.md).
-The new dashboard is auto-activated, appears at the top of **MY DASHBOARDS** in the sidebar, and is bootstrapped with the default widget bundle (three tiles + a Files widget). You can now [add more widgets](03-add-widget.md), [reposition them](04-reposition-resize.md), or [pin this as your default](07-set-default.md).
+
-
+You can now [add more widgets](03-add-widget.md), [reposition them](04-reposition-resize.md), or [pin this as your default](07-set-default.md).
## Verification
-- The sidebar shows your new dashboard's name, highlighted as active.
-- The URL bar reads `/apps/launchpad/` — the slug is auto-derived from the name.
-- The grid contains the four default placements (Conduction tile, Sendent tile, Nextcloud tile, Files widget).
+- The sidebar shows the new **"My copy of …"** dashboard, highlighted as active.
+- The URL bar reads `/apps/launchpad/` — the slug is auto-derived from the name.
+- The grid contains a copy of the widgets from the dashboard you forked.
## Common issues
| Symptom | Fix |
|---|---|
| **+ Add dashboard** button is missing | Personal dashboards are disabled by your admin. |
-| Save button is disabled | The Name field is empty — required. |
-| "Slug must be unique among siblings" error | A dashboard with the same auto-derived slug already exists. Pick a different name or set an explicit slug via [Dashboard configuration](10-rename-or-delete.md). |
+| The new dashboard has the wrong widgets | It copied the dashboard you were viewing — fork from a different one, or remove the unwanted widgets. |
+| Two dashboards share a slug | Rename via [Dashboard configuration](10-rename-or-delete.md); the slug re-derives from the new name. |
## Reference
diff --git a/docs/tutorials/user/05-edit-content.md b/docs/tutorials/user/05-edit-content.md
index 04fe8170..4e09f78d 100644
--- a/docs/tutorials/user/05-edit-content.md
+++ b/docs/tutorials/user/05-edit-content.md
@@ -6,16 +6,16 @@ description: Change a widget's content, colours, custom title, or border without
# Edit widget content & style
-Each placement carries two layers of configuration:
+Each placement carries two layers of configuration, both edited from the **same** modal:
- **Content** — type-specific fields (text body, link URL, folder path, …). Changes the widget's payload.
-- **Style** — borders, background colour, custom title override, custom icon override. Cosmetic.
+- **Style / appearance** — show-title toggle, custom title override, background, and custom icon. Cosmetic.
-Both are editable post-add without removing the widget.
+Both are editable post-add without removing the widget. Content and style used to be separate menu entries; they are now one unified **Edit widget** modal with a **Content** area and an **Appearance** section.
## Goal
-Edit a widget you already added — both its content and its visual style.
+Edit a widget you already added — both its content and its appearance.
## Prerequisites
@@ -23,54 +23,51 @@ Edit a widget you already added — both its content and its visual style.
## Steps
-### 1. Right-click the widget
+### 1. Enter edit mode and open the widget's menu
-In edit mode, right-clicking a widget opens a context menu anchored at the cursor:
+Cog menu → **Edit dashboard**. Each placement then shows a **Widget menu** (⋯/cog) button in its top-right corner. Click it:
-
+
Options:
-- **Edit** — opens the per-type configuration form (same as during add).
-- **Style** — opens the cosmetic style editor.
-- **Remove** — see [Remove a widget](06-remove-widget.md).
-- **Cancel** — close the menu.
+- **Edit widget** — opens the unified configuration + appearance form (same modal as during add).
+- **Delete widget** — see [Remove a widget](06-remove-widget.md).
-### 2. Edit content
+### 2. Edit the content
-Pick **Edit**. The same `AddWidgetModal` you used to add the widget reopens, this time pre-filled with the current placement's content. Change fields and **Save**.
+Pick **Edit widget**. The same **Add Widget** modal you used to add it reopens, pre-filled with the current placement's content. Change the type-specific fields at the top (label, URL, folder, colours, …).

-### 3. Edit style
+### 3. Edit the appearance
-Pick **Style** instead. The dedicated `WidgetStyleEditor` opens with these controls:
+Scroll to the **Appearance** section of the same modal:
-- **Custom title** — overrides the widget's default title (leave blank for default).
-- **Custom icon** — registry key, URL, or empty for default.
- **Show title** — toggle the title bar on/off.
-- **Border** — colour and thickness.
-- **Background colour** — solid, transparent, or theme-bound.
+- **Custom title** — overrides the widget's default title (leave blank for default).
+- **Background** — Default, or a custom colour.
+- **Icon** — pick from the Material Design Icons catalogue, the NL Design set (when the `nldesign` app is enabled), or **Upload** your own; leave empty for the default.
-
+
-The style is persisted as a JSON blob in `placement.styleConfig`; it doesn't touch the widget's content.
+The appearance settings persist as a JSON blob in `placement.styleConfig`; they don't touch the widget's content.
### 4. Save
-Both modals close on **Save** and the change is reflected immediately.
+The modal closes on **Save** and the change is reflected immediately.
## Verification
-- Reload the page. Content / style changes are still applied.
-- The widget header reflects any custom title; widget background reflects any custom colour.
+- Reload the page. Content and appearance changes are still applied.
+- The widget header reflects any custom title; the widget background reflects any custom colour.
## Common issues
| Symptom | Fix |
|---|---|
-| **Edit** is disabled | The widget type has no configuration form (renderer-only widgets). Use **Style** for cosmetics, or remove and re-add. |
-| Custom icon doesn't render | The icon string isn't a registered registry key and not a valid URL. See [Dashboard icons capability](../../features/dashboards.md). |
-| Title row gone after toggling **Show title** | Re-open the style editor and toggle it back on, OR set a custom title. |
+| The type-specific fields are absent | The widget type is renderer-only (no configuration form). You can still change the Appearance section, or remove and re-add. |
+| The NL Design icon set is missing from the Icon picker | The `nldesign` app is not enabled on this instance — the pack is hidden by design (MDI + Upload still work). |
+| Title row gone after toggling **Show title** | Re-open Edit and toggle it back on, OR set a custom title. |
## Reference
diff --git a/docs/tutorials/user/06-remove-widget.md b/docs/tutorials/user/06-remove-widget.md
index 1a0fa10d..987a3908 100644
--- a/docs/tutorials/user/06-remove-widget.md
+++ b/docs/tutorials/user/06-remove-widget.md
@@ -19,20 +19,20 @@ Remove one widget from a dashboard.
## Steps
-### 1. Right-click the widget
+### 1. Open the widget's menu
-In edit mode, right-click anywhere on the widget. The context menu opens at the cursor.
+In edit mode, click the placement's **Widget menu** (⋯/cog) button in its top-right corner.
-
+
-### 2. Click **Remove**
+### 2. Click **Delete widget**
The menu auto-closes and the placement disappears from the grid. The DELETE call fires immediately; there is no undo.

:::caution
-The remove is destructive. If you're unsure, [edit the style](05-edit-content.md) and toggle **Show title** off — it hides the placement without deleting it.
+The delete is destructive. If you're unsure, [edit the appearance](05-edit-content.md) and toggle **Show title** off — it de-emphasises the placement without deleting it.
:::
## Verification
@@ -44,8 +44,8 @@ The remove is destructive. If you're unsure, [edit the style](05-edit-content.md
| Symptom | Fix |
|---|---|
-| **Remove** is greyed out | The widget is `isCompulsory=1` on this dashboard (admin-pinned). Ask your admin to lift it. |
-| Removing throws "permission denied" | Your permission level on the dashboard is `view_only`. |
+| **Delete widget** is greyed out | The widget is `isCompulsory=1` on this dashboard (admin-pinned). Ask your admin to lift it. |
+| Deleting throws "permission denied" | Your permission level on the dashboard is `view_only`. |
## Reference
diff --git a/docs/tutorials/user/11-sharing-dashboards-publicly.md b/docs/tutorials/user/11-sharing-dashboards-publicly.md
index 89ea5971..a635f1af 100644
--- a/docs/tutorials/user/11-sharing-dashboards-publicly.md
+++ b/docs/tutorials/user/11-sharing-dashboards-publicly.md
@@ -5,15 +5,23 @@ title: Sharing dashboards publicly
# Sharing dashboards publicly
-LaunchPad lets you share a read-only view of any dashboard you own via a
-URL-safe token — no Nextcloud login required.
-
-## Creating a public share
-
-1. Open the dashboard you want to share.
-2. Click **Share** → **Public share** in the dashboard menu.
-3. (Optional) Enter a password and/or an expiry date.
-4. Click **Create share**. A shareable URL is displayed.
+LaunchPad can mint a read-only, URL-safe token for any dashboard you own —
+no Nextcloud login required to view it.
+
+:::warning Feature status — API only for now
+The public-share **HTTP API described below is live and stable**, but the
+in-app UI for creating and managing public links is **not yet shipped**. The
+dashboard **Share** button currently opens the *user & group* sharing tab
+only (see [Bookmark or share a dashboard URL](08-deep-link.md) for logged-in
+sharing). Public links are therefore created via the API (or automation)
+today; the point-and-click **Create public link** control, and the anonymous
+rendered view at `/s/{token}`, are on the roadmap. Until then, treat this page
+as the integrator's reference for the endpoints.
+:::
+
+## Creating a public share (API)
+
+Call the create endpoint on the dashboard's UUID:
### API
diff --git a/eslint.config.js b/eslint.config.js
index b3480510..ce31b017 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -28,6 +28,13 @@ module.exports = defineConfig([{
rules: {
'jsdoc/require-jsdoc': 'off',
+ // `@spec openspec/...` is this repo's ADR-003 / ADR-020 traceability
+ // tag linking a method to the capability Requirement it implements.
+ // It is a real, enforced convention (`composer lint:spec-annotations`
+ // checks it against tools/spec-annotations-allowlist.txt), so the
+ // jsdoc plugin needs to be told the tag exists rather than the
+ // convention being bent to satisfy the linter.
+ 'jsdoc/check-tag-names': ['warn', { definedTags: ['spec'] }],
'vue/first-attribute-linebreak': 'off',
'vue/no-unused-components': 'warn',
'@typescript-eslint/no-explicit-any': 'off',
@@ -39,6 +46,72 @@ module.exports = defineConfig([{
'import/no-unresolved': ['error', { ignore: ['^@conduction/nextcloud-vue'] }],
'no-console': 'off',
'no-debugger': 'off',
+
+ // ---------------------------------------------------------------
+ // Vue 3 rule corrections.
+ //
+ // `@nextcloud/eslint-config@8` still resolves eslint-plugin-vue's
+ // **Vue 2** preset (observable via `eslint --print-config`:
+ // `vue/no-reserved-props` arrives as `{ vueVersion: 2 }`). Several
+ // of those rules are not merely irrelevant under Vue 3 — they are
+ // INVERTED, and forbid the exact syntax Vue 3 requires. Switching
+ // the whole preset to `@nextcloud/eslint-config/vue3` crashes here
+ // because that config references `@typescript-eslint/*` rules whose
+ // plugin this project does not register, so the affected rules are
+ // corrected individually instead.
+ // ---------------------------------------------------------------
+
+ // Vue 2 forbade a key on ``; Vue 3 REQUIRES it there
+ // (the fragment is the keyed unit). `no-v-for-template-key-on-child`
+ // is the Vue-3 counterpart — key on the child instead of the
+ // template is the error now.
+ 'vue/no-v-for-template-key': 'off',
+ 'vue/no-v-for-template-key-on-child': 'error',
+
+ // `v-model:arg` is the Vue 3 replacement for Vue 2's `.sync`.
+ 'vue/no-v-model-argument': 'off',
+
+ // Vue 3 templates may have multiple roots (fragments).
+ //
+ // NOTE: `@conduction/nextcloud-vue@2.1.0-vue3.16` switched this rule
+ // off in its own shared preset, so apps that extend
+ // `@conduction/nextcloud-vue/eslint` no longer need a local disable.
+ // This app does NOT extend that preset — it corrects the Vue-2
+ // rules individually on top of `@nextcloud` (see the block comment
+ // above). Verified with `eslint --print-config`: removing this line
+ // takes the rule from `[0]` to `[2]`. It stays until launchpad
+ // adopts the shared preset.
+ 'vue/no-multiple-template-root': 'off',
+
+ // `.sync` was removed in Vue 3 — `valid-v-bind-sync` validates a
+ // modifier that no longer exists, while `no-deprecated-v-bind-sync`
+ // is what actually flags leftovers.
+ 'vue/valid-v-bind-sync': 'off',
+
+ 'vue/no-reserved-props': ['error', { vueVersion: 3 }],
+
+ // Vue-2 idioms the Vue 3 compiler silently ignores rather than
+ // erroring on — the failure mode is a dead listener or an unrendered
+ // slot at runtime, so these are promoted to errors.
+ 'vue/no-deprecated-v-bind-sync': 'error',
+ 'vue/no-deprecated-dollar-listeners-api': 'error',
+ 'vue/no-deprecated-dollar-scopedslots-api': 'error',
+ 'vue/no-deprecated-destroyed-lifecycle': 'error',
+ 'vue/no-deprecated-events-api': 'error',
+ 'vue/no-deprecated-filter': 'error',
+ 'vue/no-deprecated-functional-template': 'error',
+ 'vue/no-deprecated-html-element-is': 'error',
+ 'vue/no-deprecated-inline-template': 'error',
+ 'vue/no-deprecated-props-default-this': 'error',
+ 'vue/no-deprecated-router-link-tag-prop': 'error',
+ 'vue/no-deprecated-scope-attribute': 'error',
+ 'vue/no-deprecated-slot-attribute': 'error',
+ 'vue/no-deprecated-slot-scope-attribute': 'error',
+ 'vue/no-deprecated-v-is': 'error',
+ 'vue/no-deprecated-v-on-native-modifier': 'error',
+ 'vue/no-deprecated-v-on-number-modifiers': 'error',
+ 'vue/no-deprecated-data-object-declaration': 'error',
+ 'vue/require-slots-as-functions': 'error',
},
}, {
// Test files may import devDependencies (vitest, @vue/test-utils, etc.)
diff --git a/img/activity/dashboard_acknowledged.svg b/img/activity/dashboard_acknowledged.svg
new file mode 100644
index 00000000..70dee1e7
--- /dev/null
+++ b/img/activity/dashboard_acknowledged.svg
@@ -0,0 +1 @@
+
diff --git a/img/app.svg b/img/app.svg
index 28ab86e4..2ec4f313 100644
--- a/img/app.svg
+++ b/img/app.svg
@@ -1,16 +1,9 @@
-
-
-
-
+
-
-
-
-
+
+
+
+
diff --git a/l10n/nl.js b/l10n/nl.js
index a46dc36d..7176c9e5 100644
--- a/l10n/nl.js
+++ b/l10n/nl.js
@@ -1099,7 +1099,24 @@ OC.L10N.register(
"AI insight" : "AI-inzicht",
"AI insight unavailable — the local LLM source is not configured" : "AI-inzicht niet beschikbaar — de lokale LLM-bron is niet geconfigureerd",
"Generate insight" : "Inzicht genereren",
- "Total spend: {amount}" : "Totale uitgaven: {amount}"
+ "Total spend: {amount}" : "Totale uitgaven: {amount}",
+ "Acknowledgement required" : "Bevestiging vereist",
+ "Please confirm you have read this item." : "Bevestig dat je dit item hebt gelezen.",
+ "I have read and understood" : "Ik heb dit gelezen en begrepen",
+ "Deadline: {date}" : "Deadline: {date}",
+ "Could not record your acknowledgement. Please try again." : "Je bevestiging kon niet worden vastgelegd. Probeer het opnieuw.",
+ "You have items awaiting acknowledgement" : "Je hebt items die op bevestiging wachten",
+ "_%n item to acknowledge_::_%n items to acknowledge_" : ["%n item te bevestigen", "%n items te bevestigen"],
+ "Read receipts" : "Leesbevestigingen",
+ "Read-receipt report" : "Leesbevestigingsrapport",
+ "Acknowledged" : "Bevestigd",
+ "Pending" : "In afwachting",
+ "Overdue" : "Te laat",
+ "User" : "Gebruiker",
+ "Status" : "Status",
+ "Acknowledged at" : "Bevestigd op",
+ "Export CSV" : "CSV exporteren",
+ "Could not load the read-receipt report." : "Het leesbevestigingsrapport kon niet worden geladen."
},
"nplurals=2; plural=(n != 1);"
);
diff --git a/l10n/nl.json b/l10n/nl.json
index 1f13e78d..fc252058 100644
--- a/l10n/nl.json
+++ b/l10n/nl.json
@@ -1063,6 +1063,23 @@
"AI insight": "AI-inzicht",
"AI insight unavailable — the local LLM source is not configured": "AI-inzicht niet beschikbaar — de lokale LLM-bron is niet geconfigureerd",
"Generate insight": "Inzicht genereren",
- "Total spend: {amount}": "Totale uitgaven: {amount}"
+ "Total spend: {amount}": "Totale uitgaven: {amount}",
+ "Acknowledgement required": "Bevestiging vereist",
+ "Please confirm you have read this item.": "Bevestig dat je dit item hebt gelezen.",
+ "I have read and understood": "Ik heb dit gelezen en begrepen",
+ "Deadline: {date}": "Deadline: {date}",
+ "Could not record your acknowledgement. Please try again.": "Je bevestiging kon niet worden vastgelegd. Probeer het opnieuw.",
+ "You have items awaiting acknowledgement": "Je hebt items die op bevestiging wachten",
+ "_%n item to acknowledge_::_%n items to acknowledge_": ["%n item te bevestigen", "%n items te bevestigen"],
+ "Read receipts": "Leesbevestigingen",
+ "Read-receipt report": "Leesbevestigingsrapport",
+ "Acknowledged": "Bevestigd",
+ "Pending": "In afwachting",
+ "Overdue": "Te laat",
+ "User": "Gebruiker",
+ "Status": "Status",
+ "Acknowledged at": "Bevestigd op",
+ "Export CSV": "CSV exporteren",
+ "Could not load the read-receipt report.": "Het leesbevestigingsrapport kon niet worden geladen."
}
}
diff --git a/lib/Activity/ActivityPublisher.php b/lib/Activity/ActivityPublisher.php
index cd702d4c..fb39b0ff 100644
--- a/lib/Activity/ActivityPublisher.php
+++ b/lib/Activity/ActivityPublisher.php
@@ -21,8 +21,8 @@
* @version GIT:auto
* @link https://conduction.nl
*
- * SPDX-FileCopyrightText: 2026 LaunchPad Contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
+ * SPDX-FileCopyrightText: 2024 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
*/
declare(strict_types=1);
@@ -39,332 +39,329 @@
/**
* Thin Activity emission service for LaunchPad.
- *
- * @SuppressWarnings(PHPMD.CouplingBetweenObjects) Mirrors NC Activity surface.
*/
-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 9c0d4a95..5a7e8d41 100644
--- a/lib/Activity/DebounceHelper.php
+++ b/lib/Activity/DebounceHelper.php
@@ -11,10 +11,24 @@
* - `allowGlobalFanout(dashboard, eventType)` — at most one default-group
* fan-out per dashboard per event type per 900-second window.
*
- * The class falls back to a per-process in-memory store when APCu is not
- * available (e.g. CLI or test environments) so call sites do not need to
- * branch on runtime availability. The in-memory store still honours the
- * 900-second TTL for deterministic unit tests.
+ * The claim is resolved through a three-tier fallback so the debounce
+ * guarantee holds across requests and PHP-FPM workers on every
+ * deployment topology (not only ones where APCu happens to be installed
+ * and enabled):
+ *
+ * 1. APCu (`apcu_add`) — the race-free fast path when APCu is usable.
+ * 2. `OCP\ICache` (distributed) — the cross-request fallback when APCu
+ * is unusable. Nextcloud's distributed cache abstraction selects an
+ * appropriate backend (Redis/Memcached/APCu/file) per instance
+ * config, so the debounce survives the per-request rebuild of the DI
+ * container that PHP-FPM performs. `ICache` has no atomic `add()`, so
+ * the claim is a `hasKey()`-then-`set()` — a small race window that is
+ * acceptable for a 15-minute UX debounce guard (not a security
+ * control).
+ * 3. In-memory array — the final fallback used ONLY when no cache is
+ * injected (defensive) or when a test clock is active (the
+ * deterministic unit-test path, where a real cache backend's
+ * wall-clock TTL would not move with the fake clock).
*
* @category Activity
* @package OCA\LaunchPad\Activity
@@ -24,182 +38,215 @@
* @version GIT:auto
* @link https://conduction.nl
*
- * SPDX-FileCopyrightText: 2026 LaunchPad Contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
+ * SPDX-FileCopyrightText: 2024 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
*/
declare(strict_types=1);
namespace OCA\LaunchPad\Activity;
+use OCP\ICache;
+
/**
* 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;
-
- /**
- * Constructor.
- *
- * @param (callable():int)|null $clock Optional clock callable; defaults to `time()`.
- */
- public function __construct(?callable $clock=null)
- {
- $this->realClock = ($clock === null);
- $this->clock = ($clock ?? static fn(): int => time());
- }//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()
-
- /**
- * Atomically claim the key for the configured TTL.
- *
- * Uses APCu when available (with `apcu_add` for race-free claim)
- * and falls back to the in-memory map otherwise.
- *
- * @param string $key The full APCu 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);
- }
-
- // 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 7bc0d6e0..996b8212 100644
--- a/lib/Activity/Extension.php
+++ b/lib/Activity/Extension.php
@@ -25,6 +25,7 @@
* | dashboard_restored | dashboard-versioning |
* | dashboard_lock_overridden | dashboard-locking |
* | dashboard_role_changed | admin-roles |
+ * | dashboard_acknowledged | dashboard-acknowledgements |
*
* Sibling capabilities MUST emit through `ActivityPublisher::publish()`
* after the primary domain action has been persisted. They MUST NOT
@@ -39,8 +40,8 @@
* @version GIT:auto
* @link https://conduction.nl
*
- * SPDX-FileCopyrightText: 2026 LaunchPad Contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
+ * SPDX-FileCopyrightText: 2024 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
*/
declare(strict_types=1);
@@ -57,261 +58,262 @@
/**
* LaunchPad Activity provider.
*
- * @SuppressWarnings(PHPMD.CouplingBetweenObjects) Mirrors event catalogue size.
- * @SuppressWarnings(PHPMD.UnusedFormalParameter) — $previousEvent required by IProvider interface.
- * @spec openspec/specs/activity-feed-integration/spec.md
+ * @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_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,
- ];
+ /**
+ * 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}",
- ],
- ];
- }//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 e72d3781..758f7886 100644
--- a/lib/AppInfo/Application.php
+++ b/lib/AppInfo/Application.php
@@ -19,7 +19,10 @@
namespace OCA\LaunchPad\AppInfo;
use OCA\LaunchPad\Activity\DebounceHelper;
+use OCA\LaunchPad\Controller\HealthController;
+use OCA\LaunchPad\Controller\MetricsController;
use OCA\LaunchPad\Event\DashboardDeletedEvent;
+use OCA\LaunchPad\Listener\CspListener;
use OCA\LaunchPad\Listener\GroupDeletedListener;
use OCA\LaunchPad\Listener\LocksListener;
use OCA\LaunchPad\Listener\MetadataValuesListener;
@@ -39,6 +42,7 @@
use OCP\AppFramework\Bootstrap\IBootstrap;
use OCP\AppFramework\Bootstrap\IRegistrationContext;
use OCP\Group\Events\GroupDeletedEvent;
+use OCP\Security\CSP\AddContentSecurityPolicyEvent;
use OCP\User\Events\UserDeletedEvent;
/**
@@ -53,217 +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
- {
- // Render `dashboard_shared` and `dashboard_ownership_transferred`
- // notifications via our INotifier. REQ-SHARE-011.
- $context->registerNotifierService(notifierClass: Notifier::class);
+ /**
+ * 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);
- // 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
- );
+ // 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()
- // REQ-ACT-007: register DebounceHelper as a shared singleton
- // so the in-memory fallback store (used when APCu is absent in
- // CLI / test runs) survives across all callers within a single
- // request. 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(): DebounceHelper => new DebounceHelper(),
- shared: true
- );
+ /**
+ * 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);
- // 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
- );
+ // 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()
- // 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 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
+ );
- // 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
- );
+ // 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()
- // Surface dashboards, widget content, and metadata values in
- // Nextcloud's unified search (Ctrl+K). REQ-SRCH-001.
- $context->registerSearchProvider(class: LaunchPadSearchProvider::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
+ );
- // 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()
+ // 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()
- /**
- * 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(
- \OCA\LaunchPad\Controller\HealthController::class,
- static function (\Psr\Container\ContainerInterface $c): \OCA\LaunchPad\Controller\HealthController {
- return new \OCA\LaunchPad\Controller\HealthController(
- appName: self::APP_ID,
- request: $c->get(\OCP\IRequest::class),
- manifestLoader: $c->get('OCA\\OpenRegister\\AppHost\\Observability\\ManifestLoader'),
- executor: $c->get('OCA\\OpenRegister\\AppHost\\Observability\\HealthCheckExecutor')
- );
- }
- );
+ /**
+ * 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);
- // Metrics controller (admin-only — the subclass omits #[NoAdminRequired]).
- $context->registerService(
- \OCA\LaunchPad\Controller\MetricsController::class,
- static function (\Psr\Container\ContainerInterface $c): \OCA\LaunchPad\Controller\MetricsController {
- return new \OCA\LaunchPad\Controller\MetricsController(
- appName: self::APP_ID,
- request: $c->get(\OCP\IRequest::class),
- manifestLoader: $c->get('OCA\\OpenRegister\\AppHost\\Observability\\ManifestLoader'),
- engine: $c->get('OCA\\OpenRegister\\AppHost\\Observability\\MetricsEngine')
- );
- }
- );
- }//end registerObservability()
+ // `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()
- /**
- * App initialization after all apps are registered.
- *
- * @param IBootContext $context The boot context.
- *
- * @return void
- */
- 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);
- }
+ /**
+ * 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')
+ );
+ }
+ );
- // App initialization after all apps are registered.
- \OCP\Util::addStyle(application: self::APP_ID, file: 'launchpad');
+ // 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()
- // 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()
+ /**
+ * 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()
+
+ /**
+ * 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');
+
+ // 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
new file mode 100644
index 00000000..39fe4380
--- /dev/null
+++ b/lib/BackgroundJob/HealthPingRefreshJob.php
@@ -0,0 +1,104 @@
+` and
+ * installed via `OCA\LaunchPad\Repair\RegisterBackgroundJobs`.
+ *
+ * @category BackgroundJob
+ * @package OCA\LaunchPad\BackgroundJob
+ * @author Conduction b.v.
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace OCA\LaunchPad\BackgroundJob;
+
+use OCA\LaunchPad\AppInfo\Application;
+use OCA\LaunchPad\Service\HealthPingService;
+use OCP\AppFramework\Utility\ITimeFactory;
+use OCP\BackgroundJob\IJob;
+use OCP\BackgroundJob\TimedJob;
+use Psr\Log\LoggerInterface;
+use Throwable;
+
+/**
+ * Refreshes due, ping-enabled placements' cached health badge every tick.
+ *
+ * @SuppressWarnings(PHPMD.UnusedFormalParameter) — $argument required by TimedJob interface.
+ * @spec openspec/specs/service-health-ping/spec.md
+ */
+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;
+
+ /**
+ * 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;
+ }
+
+ $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 3fdb1141..a8815a26 100644
--- a/lib/BackgroundJob/OrphanedDataCleanupJob.php
+++ b/lib/BackgroundJob/OrphanedDataCleanupJob.php
@@ -21,8 +21,8 @@
* @version GIT:auto
* @link https://conduction.nl
*
- * SPDX-FileCopyrightText: 2026 LaunchPad Contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
+ * SPDX-FileCopyrightText: 2024 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
*/
declare(strict_types=1);
@@ -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 d2fbcb91..2b95b052 100644
--- a/lib/BackgroundJob/PurgeViewsJob.php
+++ b/lib/BackgroundJob/PurgeViewsJob.php
@@ -8,6 +8,11 @@
* window is 365 days; admin override via
* `launchpad.analytics_retention_days` is clamped to `[30, 3650]`.
*
+ * Extended by the tile usage-analytics capability (REQ-TANLT-005) to
+ * also purge `oc_launchpad_tile_clicks` rows older than the SAME
+ * cutoff in the same run — no second purge job is introduced, per the
+ * "reuse, don't reinvent" contract for that capability.
+ *
* Logging is intentionally aggregate-only: row count + cutoff date
* — never any user-attributable identifiers (REQ-ANLT-009 scenario
* "Purge logs execution"). The job is registered via
@@ -21,8 +26,8 @@
* @version GIT:auto
* @link https://conduction.nl
*
- * SPDX-FileCopyrightText: 2026 LaunchPad Contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
+ * SPDX-FileCopyrightText: 2024 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
*/
declare(strict_types=1);
@@ -30,6 +35,7 @@
namespace OCA\LaunchPad\BackgroundJob;
use OCA\LaunchPad\Db\DashboardViewMapper;
+use OCA\LaunchPad\Db\TileClickMapper;
use OCA\LaunchPad\Service\AnalyticsService;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\BackgroundJob\TimedJob;
@@ -41,55 +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 LoggerInterface $logger PSR logger.
- */
- public function __construct(
- ITimeFactory $time,
- private readonly AnalyticsService $analyticsService,
- private readonly DashboardViewMapper $viewMapper,
- 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.
- *
- * @param mixed $argument Required by the base class; unused.
- *
- * @return void
- *
- * @spec openspec/specs/dashboard-view-analytics/spec.md
- */
- protected function run($argument): void
- {
- $cutoff = $this->analyticsService->getPurgeCutoffDate();
- $deleted = $this->viewMapper->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 '.$deleted.' rows older than '.$cutoff,
- context: [
- 'rows' => $deleted,
- '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 6cfe9e45..b12f4dd4 100644
--- a/lib/BackgroundJob/SaltRotationJob.php
+++ b/lib/BackgroundJob/SaltRotationJob.php
@@ -26,8 +26,8 @@
* @version GIT:auto
* @link https://conduction.nl
*
- * SPDX-FileCopyrightText: 2026 LaunchPad Contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
+ * SPDX-FileCopyrightText: 2024 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
*/
declare(strict_types=1);
@@ -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
new file mode 100644
index 00000000..e8e13df0
--- /dev/null
+++ b/lib/BackgroundJob/TemplateResyncJob.php
@@ -0,0 +1,121 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace OCA\LaunchPad\BackgroundJob;
+
+use OCA\LaunchPad\Service\TemplateResyncService;
+use OCP\AppFramework\Utility\ITimeFactory;
+use OCP\BackgroundJob\QueuedJob;
+use Psr\Log\LoggerInterface;
+use Throwable;
+
+/**
+ * One-off async apply for a large-target-group template re-sync.
+ *
+ * @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()
+
+ /**
+ * 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;
+ }
+
+ 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()
+}//end class
diff --git a/lib/Command/CleanupPurgeCommand.php b/lib/Command/CleanupPurgeCommand.php
index 87331ad9..f372b2ef 100644
--- a/lib/Command/CleanupPurgeCommand.php
+++ b/lib/Command/CleanupPurgeCommand.php
@@ -23,14 +23,15 @@
* @version GIT:auto
* @link https://conduction.nl
*
- * SPDX-FileCopyrightText: 2026 LaunchPad Contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
+ * SPDX-FileCopyrightText: 2024 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
*/
declare(strict_types=1);
namespace OCA\LaunchPad\Command;
+use OCA\LaunchPad\Db\CleanupResult;
use OCA\LaunchPad\Service\Cleanup\CategoryRegistryService;
use OCA\LaunchPad\Service\OrphanedDataCleanupService;
use Symfony\Component\Console\Command\Command;
@@ -43,161 +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()
-
- /**
- * 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');
-
- $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;
- }
-
- $categoryNames = [$categoryOption];
- }
-
- $effectiveCategories = $categoryNames;
- if (count(value: $effectiveCategories) === 0) {
- $effectiveCategories = $this->registry->getCategoryNames();
- }
-
- if ($assumeYes === false) {
- $helper = $this->getHelper(name: 'question');
- if ($helper instanceof QuestionHelper) {
- $question = new ConfirmationQuestion(
- question: sprintf(
- 'Delete orphaned data in categories: [%s]? (y/N) ',
- implode(separator: ', ', array: $effectiveCategories)
- ),
- default: false
- );
-
- if ($helper->ask(input: $input, output: $output, question: $question) === false) {
- $output->writeln(messages: 'Purge cancelled.');
- return 0;
- }
- }
- }
-
- $result = $this->cleanupService->purge(
- categoryNames: $categoryNames,
- dryRun: $dryRun,
- userId: null,
- source: 'cli',
- );
-
- $prefix = 'Purged';
- if ($dryRun === true) {
- $prefix = 'DRY-RUN: Would purge';
- }
-
- $summaryMessage = sprintf(
- '%s %d items across %d categories in %dms. ',
- $prefix,
- $result->getTotalRows(),
- count(value: $result->getByCategory()),
- $result->getDurationMs()
- );
- if (count(value: $categoryNames) === 1) {
- $summaryMessage = sprintf(
- '%s %d items from category \'%s\' in %dms. ',
- $prefix,
- $result->getTotalRows(),
- $categoryNames[0],
- $result->getDurationMs()
- );
- }
-
- $output->writeln(messages: $summaryMessage);
-
- $skipped = $result->getSkipped();
- if (count(value: $skipped) > 0) {
- $output->writeln(
- messages: sprintf(
- 'Skipped categories: %s ',
- implode(separator: ', ', array: $skipped)
- )
- );
- }
-
- return 0;
- }//end execute()
+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()
+
+ /**
+ * 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())
+ )
+ );
+
+ return 1;
+ }
+
+ $categoryNames = [$categoryOption];
+ }
+
+ $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;
+ }
+
+ $result = $this->cleanupService->purge(
+ categoryNames: $categoryNames,
+ dryRun: $dryRun,
+ userId: null,
+ source: 'cli',
+ );
+
+ $output->writeln(
+ messages: $this->formatSummary(
+ result: $result,
+ categoryNames: $categoryNames,
+ dryRun: $dryRun
+ )
+ );
+
+ $this->writeSkipped(output: $output, skipped: $result->getSkipped());
+
+ 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;
+ }
+
+ $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
+ );
+
+ 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';
+ }
+
+ 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()
+
+ /**
+ * 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()
}//end class
diff --git a/lib/Command/CleanupScanCommand.php b/lib/Command/CleanupScanCommand.php
index 63debab3..4cc6cc85 100644
--- a/lib/Command/CleanupScanCommand.php
+++ b/lib/Command/CleanupScanCommand.php
@@ -19,8 +19,8 @@
* @version GIT:auto
* @link https://conduction.nl
*
- * SPDX-FileCopyrightText: 2026 LaunchPad Contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
+ * SPDX-FileCopyrightText: 2024 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
*/
declare(strict_types=1);
@@ -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 f4fc6d76..bd015c9e 100644
--- a/lib/Command/CommandBase.php
+++ b/lib/Command/CommandBase.php
@@ -25,8 +25,8 @@
* @version GIT:auto
* @link https://conduction.nl
*
- * SPDX-FileCopyrightText: 2026 LaunchPad Contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
+ * SPDX-FileCopyrightText: 2024 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
*/
declare(strict_types=1);
@@ -45,334 +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)
- */
- 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 01409b06..e2cf540d 100644
--- a/lib/Command/DashboardDebugShareCommand.php
+++ b/lib/Command/DashboardDebugShareCommand.php
@@ -15,8 +15,8 @@
* @version GIT:auto
* @link https://conduction.nl
*
- * SPDX-FileCopyrightText: 2026 LaunchPad Contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
+ * SPDX-FileCopyrightText: 2024 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
*/
declare(strict_types=1);
@@ -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 4a6045cf..e930eae2 100644
--- a/lib/Command/DashboardDeleteCommand.php
+++ b/lib/Command/DashboardDeleteCommand.php
@@ -16,8 +16,8 @@
* @version GIT:auto
* @link https://conduction.nl
*
- * SPDX-FileCopyrightText: 2026 LaunchPad Contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
+ * SPDX-FileCopyrightText: 2024 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
*/
declare(strict_types=1);
@@ -44,167 +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()
-
- /**
- * 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');
-
- 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]
- );
- }
-
- if ($this->isNoInteraction(input: $input) === false
- && $this->isJson(input: $input) === false
- ) {
- $helper = new QuestionHelper();
- $question = new ConfirmationQuestion(
- question: sprintf(
- 'Delete dashboard "%s" (%s)? [y/N] ',
- (string) $dashboard->getName(),
- $uuid
- ),
- 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: 'Deletion aborted by user.'
- );
- }
- }
-
- if ($cascade === true) {
- $this->treeService->deleteSubtree(dashboard: $dashboard);
- }
-
- if ($cascade === false) {
- $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 !== '') {
- $this->eventDispatcher->dispatchTyped(
- new DashboardDeletedEvent(
- dashboardUuid: $uuid,
- ownerUserId: (string) ($dashboard->getUserId() ?? ''),
- type: (string) ($dashboard->getType() ?? Dashboard::TYPE_USER),
- deletedAt: new DateTimeImmutable()
- )
- );
- }
- }
-
- $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
- );
-
- return CommandService::EXIT_SUCCESS;
- }//end handle()
+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()
+
+ /**
+ * 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]
+ );
+ }
+
+ $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.'
+ );
+ }
+
+ $this->applyDeletion(
+ dashboard: $dashboard,
+ uuid: $uuid,
+ cascade: $cascade
+ );
+
+ $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
+ );
+
+ 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;
+ }
+
+ $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()
+
+ /**
+ * 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);
+
+ // 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()
}//end class
diff --git a/lib/Command/DashboardListCommand.php b/lib/Command/DashboardListCommand.php
index a07f85d4..184cea38 100644
--- a/lib/Command/DashboardListCommand.php
+++ b/lib/Command/DashboardListCommand.php
@@ -15,8 +15,8 @@
* @version GIT:auto
* @link https://conduction.nl
*
- * SPDX-FileCopyrightText: 2026 LaunchPad Contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
+ * SPDX-FileCopyrightText: 2024 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
*/
declare(strict_types=1);
@@ -35,251 +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');
-
- 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]
- );
- }
-
- $userArg = null;
- if ($user !== null) {
- $userArg = (string) $user;
- }
-
- $groupArg = null;
- if ($group !== null) {
- $groupArg = (string) $group;
- }
-
- $statusArg = null;
- if ($status !== null) {
- $statusArg = (string) $status;
- }
-
- $dashboards = $this->collect(
- user: $userArg,
- group: $groupArg,
- status: $statusArg
- );
-
- $rows = 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
- );
-
- if ($this->isJson(input: $input) === true) {
- $this->emitSuccess(
- input: $input,
- output: $output,
- data: ['dashboards' => $rows, 'count' => count(value: $rows)]
- );
- return CommandService::EXIT_SUCCESS;
- }
-
- if ($this->isQuiet(input: $input) === false && count(value: $rows) === 0) {
- $output->writeln(messages: 'No dashboards match the supplied filters.');
- }
-
- if ($this->isQuiet(input: $input) === false && count(value: $rows) > 0) {
- $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']
- )
- );
- }
- }
-
- return CommandService::EXIT_SUCCESS;
- }//end handle()
-
- /**
- * 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 = [];
-
- if ($user !== null) {
- foreach ($this->dashboardMapper->findByUserId(userId: $user) as $dashboard) {
- $dashboards[] = $dashboard;
- }
- }
-
- if ($user === null && $group !== null) {
- foreach ($this->dashboardMapper->findByGroup(groupId: $group) as $dashboard) {
- $dashboards[] = $dashboard;
- }
- }
-
- if ($user === null && $group === null) {
- 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;
- }
- }
- }
-
- 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()
+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 27412bc3..dd20205d 100644
--- a/lib/Command/DashboardShowCommand.php
+++ b/lib/Command/DashboardShowCommand.php
@@ -14,8 +14,8 @@
* @version GIT:auto
* @link https://conduction.nl
*
- * SPDX-FileCopyrightText: 2026 LaunchPad Contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
+ * SPDX-FileCopyrightText: 2024 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
*/
declare(strict_types=1);
@@ -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 45cd2d4b..f3d6aba6 100644
--- a/lib/Command/DemoShowcasesInstallCommand.php
+++ b/lib/Command/DemoShowcasesInstallCommand.php
@@ -17,8 +17,8 @@
* @version GIT:auto
* @link https://conduction.nl
*
- * SPDX-FileCopyrightText: 2026 LaunchPad Contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
+ * SPDX-FileCopyrightText: 2024 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
*/
declare(strict_types=1);
@@ -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 c3f5aa62..a0417a60 100644
--- a/lib/Command/DemoShowcasesListCommand.php
+++ b/lib/Command/DemoShowcasesListCommand.php
@@ -16,8 +16,8 @@
* @version GIT:auto
* @link https://conduction.nl
*
- * SPDX-FileCopyrightText: 2026 LaunchPad Contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
+ * SPDX-FileCopyrightText: 2024 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
*/
declare(strict_types=1);
@@ -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 9b177a94..15afeba2 100644
--- a/lib/Command/ExportCommand.php
+++ b/lib/Command/ExportCommand.php
@@ -14,8 +14,8 @@
* @version GIT:auto
* @link https://conduction.nl
*
- * SPDX-FileCopyrightText: 2024 LaunchPad Contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
+ * SPDX-FileCopyrightText: 2024 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
*/
declare(strict_types=1);
@@ -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 2a8ebb86..45448de6 100644
--- a/lib/Command/I18nCopyNavigationCommand.php
+++ b/lib/Command/I18nCopyNavigationCommand.php
@@ -16,8 +16,8 @@
* @version GIT:auto
* @link https://conduction.nl
*
- * SPDX-FileCopyrightText: 2026 LaunchPad Contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
+ * SPDX-FileCopyrightText: 2024 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
*/
declare(strict_types=1);
@@ -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 90b296c3..3f83e040 100644
--- a/lib/Command/I18nExportStringsCommand.php
+++ b/lib/Command/I18nExportStringsCommand.php
@@ -19,8 +19,8 @@
* @version GIT:auto
* @link https://conduction.nl
*
- * SPDX-FileCopyrightText: 2026 LaunchPad Contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
+ * SPDX-FileCopyrightText: 2024 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
*/
declare(strict_types=1);
@@ -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 59cf7b50..2f14fdf2 100644
--- a/lib/Command/I18nMigrateLanguageStructureCommand.php
+++ b/lib/Command/I18nMigrateLanguageStructureCommand.php
@@ -19,8 +19,8 @@
* @version GIT:auto
* @link https://conduction.nl
*
- * SPDX-FileCopyrightText: 2026 LaunchPad Contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
+ * SPDX-FileCopyrightText: 2024 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
*/
declare(strict_types=1);
@@ -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 47b1f8b3..f801c900 100644
--- a/lib/Command/ImportCommand.php
+++ b/lib/Command/ImportCommand.php
@@ -14,8 +14,8 @@
* @version GIT:auto
* @link https://conduction.nl
*
- * SPDX-FileCopyrightText: 2024 LaunchPad Contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
+ * SPDX-FileCopyrightText: 2024 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
*/
declare(strict_types=1);
@@ -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 28ccc5a8..48925ac8 100644
--- a/lib/Command/ImportConfluenceCommand.php
+++ b/lib/Command/ImportConfluenceCommand.php
@@ -15,8 +15,8 @@
* @version GIT:auto
* @link https://conduction.nl
*
- * SPDX-FileCopyrightText: 2026 LaunchPad Contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
+ * SPDX-FileCopyrightText: 2024 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
*/
declare(strict_types=1);
@@ -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/MigrateStorageToGroupFolder.php b/lib/Command/MigrateStorageToGroupFolder.php
deleted file mode 100644
index fceaeb25..00000000
--- a/lib/Command/MigrateStorageToGroupFolder.php
+++ /dev/null
@@ -1,193 +0,0 @@
-
- * @copyright 2026 Conduction B.V.
- * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
- *
- * @link https://conduction.nl
- *
- * @spec openspec/changes/groupfolder-storage-backend/tasks.md#task-9
- */
-
-declare(strict_types=1);
-
-namespace OCA\LaunchPad\Command;
-
-use OCA\LaunchPad\Db\DashboardMapper;
-use OCA\LaunchPad\Service\CommandService;
-use OCA\LaunchPad\Service\DashboardContentStorage\DashboardContentStorageException;
-use OCA\LaunchPad\Service\DashboardContentStorage\GroupFolderContentStorage;
-use OCP\IUserSession;
-use Symfony\Component\Console\Input\InputInterface;
-use Symfony\Component\Console\Input\InputOption;
-use Symfony\Component\Console\Output\OutputInterface;
-
-/**
- * One-time migration command from DB to GroupFolder storage (REQ-GFSB-008).
- *
- * @spec openspec/changes/groupfolder-storage-backend/tasks.md#task-9
- */
-class MigrateStorageToGroupFolder extends CommandBase
-{
- /**
- * Constructor.
- *
- * @param CommandService $commandService Shared CLI helper.
- * @param IUserSession $userSession Caller resolution.
- * @param DashboardMapper $dashboardMapper Dashboard mapper.
- * @param GroupFolderContentStorage $groupFolderStorage GroupFolder backend.
- *
- * @spec openspec/changes/groupfolder-storage-backend/tasks.md#task-9
- */
- public function __construct(
- CommandService $commandService,
- IUserSession $userSession,
- private readonly DashboardMapper $dashboardMapper,
- private readonly GroupFolderContentStorage $groupFolderStorage,
- ) {
- parent::__construct(
- commandService: $commandService,
- userSession: $userSession
- );
- }//end __construct()
-
- /**
- * Wire command name, description, and options.
- *
- * @return void
- *
- * @spec openspec/changes/groupfolder-storage-backend/tasks.md#task-9
- */
- protected function configureCommand(): void
- {
- $this->setName(name: 'launchpad:storage:migrate-to-groupfolder')
- ->setDescription(description: 'Migrate dashboard content from DB to GroupFolder (REQ-GFSB-008).')
- ->setHelp(
- help: implode(
- separator: "\n",
- array: [
- 'Copies all dashboard content blobs from the `content` column in',
- '`launchpad_dashboards` to the configured GroupFolder backend.',
- '',
- 'The command is idempotent: dashboards already present in the',
- 'GroupFolder are skipped. Re-run safely after partial failures.',
- '',
- 'After a successful migration, switch the active backend via:',
- ' php occ launchpad:storage:toggle-backend groupfolder',
- '',
- 'Use --prune-source to remove DB content for successfully migrated',
- 'dashboards (default: DB content is kept for rollback safety).',
- '',
- 'Run launchpad:storage:migrate-to-groupfolder --help for more details.',
- ]
- )
- )
- ->addOption(
- name: 'prune-source',
- shortcut: null,
- mode: InputOption::VALUE_NONE,
- description: 'Remove DB content for successfully migrated dashboards (REQ-GFSB-008 design D3).'
- );
- }//end configureCommand()
-
- /**
- * Execute the migration.
- *
- * @param InputInterface $input CLI input.
- * @param OutputInterface $output CLI output.
- *
- * @return int Exit code (0 = success, 1 = partial failure).
- *
- * @spec openspec/changes/groupfolder-storage-backend/tasks.md#task-9
- */
- protected function handle(InputInterface $input, OutputInterface $output): int
- {
- $pruneSource = (bool) $input->getOption(name: 'prune-source');
-
- $dashboards = $this->dashboardMapper->findAll();
- $total = count($dashboards);
-
- if ($total === 0) {
- $output->writeln(messages: 'No dashboards found — nothing to migrate.');
- return CommandService::EXIT_SUCCESS;
- }
-
- $migrated = 0;
- $skipped = 0;
- $errors = 0;
-
- foreach ($dashboards as $i => $dashboard) {
- $uuid = (string) $dashboard->getUuid();
- $current = $i + 1;
-
- if ($uuid === '') {
- $output->writeln(
- messages: " [{$current}/{$total}] Dashboard id={$dashboard->getId()} has no UUID, skipping."
- );
- $skipped++;
- continue;
- }
-
- // Idempotent: skip when content already exists in GroupFolder.
- if ($this->groupFolderStorage->exists(uuid: $uuid) === true) {
- $output->writeln(
- messages: " [{$current}/{$total}] {$uuid} already migrated, skipping."
- );
- $skipped++;
- continue;
- }
-
- // phpcs:disable CustomSniffs.Functions.NamedParameters.RequireNamedParameters
- $rawContent = $dashboard->getContent();
- // phpcs:enable
-
- $contentArray = [];
- if ($rawContent !== null && $rawContent !== '') {
- $decoded = json_decode(json: $rawContent, associative: true);
- if (is_array($decoded) === true) {
- $contentArray = $decoded;
- }
- }
-
- try {
- $this->groupFolderStorage->write(uuid: $uuid, content: $contentArray);
- $migrated++;
- $output->writeln(messages: " [{$current}/{$total}] Migrated {$uuid}.");
-
- // Optionally clear DB content after successful migration (design D3).
- if ($pruneSource === true) {
- // phpcs:disable CustomSniffs.Functions.NamedParameters.RequireNamedParameters
- $dashboard->setContent(null);
- // phpcs:enable
- $this->dashboardMapper->update(entity: $dashboard);
- }
- } catch (DashboardContentStorageException $e) {
- $errors++;
- $output->writeln(
- messages: " [{$current}/{$total}] ERROR for {$uuid}: ".$e->getMessage()
- );
- }//end try
- }//end foreach
-
- $output->writeln(
- messages: "\nMigration complete: {$migrated}/{$total} migrated, {$skipped} skipped, {$errors} errors."
- );
-
- if ($errors > 0) {
- return CommandService::EXIT_ERROR;
- }
-
- return CommandService::EXIT_SUCCESS;
- }//end handle()
-}//end class
diff --git a/lib/Command/SetupCommand.php b/lib/Command/SetupCommand.php
index ea0b4677..f462f93d 100644
--- a/lib/Command/SetupCommand.php
+++ b/lib/Command/SetupCommand.php
@@ -17,8 +17,8 @@
* @version GIT:auto
* @link https://conduction.nl
*
- * SPDX-FileCopyrightText: 2026 LaunchPad Contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
+ * SPDX-FileCopyrightText: 2024 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
*/
declare(strict_types=1);
@@ -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/Command/ToggleStorageSetting.php b/lib/Command/ToggleStorageSetting.php
deleted file mode 100644
index 0156781e..00000000
--- a/lib/Command/ToggleStorageSetting.php
+++ /dev/null
@@ -1,156 +0,0 @@
-
- * @copyright 2026 Conduction B.V.
- * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
- *
- * @link https://conduction.nl
- *
- * @spec openspec/changes/groupfolder-storage-backend/tasks.md#task-10
- */
-
-declare(strict_types=1);
-
-namespace OCA\LaunchPad\Command;
-
-use OCA\LaunchPad\Service\CommandService;
-use OCA\LaunchPad\Service\SetupWizardService;
-use OCP\IUserSession;
-use Symfony\Component\Console\Input\InputArgument;
-use Symfony\Component\Console\Input\InputInterface;
-use Symfony\Component\Console\Output\OutputInterface;
-
-/**
- * Toggle the content storage backend via the CLI (REQ-GFSB-010).
- *
- * @spec openspec/changes/groupfolder-storage-backend/tasks.md#task-10
- */
-class ToggleStorageSetting extends CommandBase
-{
- /**
- * Constructor.
- *
- * @param CommandService $commandService Shared CLI helper.
- * @param IUserSession $userSession Caller resolution.
- * @param SetupWizardService $wizardService Admin setting writer.
- *
- * @spec openspec/changes/groupfolder-storage-backend/tasks.md#task-10
- */
- public function __construct(
- CommandService $commandService,
- IUserSession $userSession,
- private readonly SetupWizardService $wizardService,
- ) {
- parent::__construct(
- commandService: $commandService,
- userSession: $userSession
- );
- }//end __construct()
-
- /**
- * Wire command name, description, and arguments.
- *
- * @return void
- *
- * @spec openspec/changes/groupfolder-storage-backend/tasks.md#task-10
- */
- protected function configureCommand(): void
- {
- $this->setName(name: 'launchpad:storage:toggle-backend')
- ->setDescription(description: 'Change the active content storage backend (db|groupfolder).')
- ->setHelp(
- help: implode(
- separator: "\n",
- array: [
- 'Changes the `launchpad.content_storage` admin setting.',
- '',
- 'Valid backends: db, groupfolder',
- '',
- ' db — Store dashboard content in the database (default).',
- ' groupfolder — Store dashboard content in the "LaunchPad" GroupFolder.',
- '',
- 'WARNING: Switching back from groupfolder to db does NOT auto-copy',
- 'GroupFolder data back to the database. Run the migration first or',
- 'ensure DB content is intact (migration keeps DB copies by default).',
- '',
- 'Run php occ launchpad:storage:migrate-to-groupfolder before switching',
- 'to groupfolder to ensure all existing dashboards are available.',
- ]
- )
- )
- ->addArgument(
- name: 'backend',
- mode: InputArgument::REQUIRED,
- description: 'Target backend: "db" or "groupfolder".'
- );
- }//end configureCommand()
-
- /**
- * Execute the backend toggle.
- *
- * @param InputInterface $input CLI input.
- * @param OutputInterface $output CLI output.
- *
- * @return int Exit code (0 = success, 1 = invalid argument).
- *
- * @spec openspec/changes/groupfolder-storage-backend/tasks.md#task-10
- */
- protected function handle(InputInterface $input, OutputInterface $output): int
- {
- $requested = (string) $input->getArgument(name: 'backend');
- $current = $this->wizardService->getContentStorage();
-
- // Map CLI aliases to internal constants.
- $targetMap = [
- 'db' => SetupWizardService::STORAGE_DATABASE,
- 'database' => SetupWizardService::STORAGE_DATABASE,
- 'groupfolder' => SetupWizardService::STORAGE_GROUPFOLDER,
- ];
-
- if (array_key_exists(key: $requested, array: $targetMap) === false) {
- $output->writeln(
- messages: 'Invalid backend: "'.$requested.'". Use "db" or "groupfolder". '
- );
- return CommandService::EXIT_ERROR;
- }
-
- $target = $targetMap[$requested];
-
- if ($target === $current) {
- $output->writeln(messages: 'Backend is already set to "'.$current.'". No change.');
- return CommandService::EXIT_SUCCESS;
- }
-
- // Warn when switching back from groupfolder to DB.
- if ($current === SetupWizardService::STORAGE_GROUPFOLDER
- && $target === SetupWizardService::STORAGE_DATABASE
- ) {
- $output->writeln(
- messages: implode(
- separator: "\n",
- array: [
- 'WARNING: Switching from groupfolder to db. ',
- 'GroupFolder content is NOT auto-copied back to the database. ',
- 'Ensure database content is intact before proceeding. ',
- ]
- )
- );
- }
-
- $this->wizardService->setContentStorage(value: $target);
-
- $output->writeln(messages: 'Storage backend changed from "'.$current.'" to "'.$target.'".');
- return CommandService::EXIT_SUCCESS;
- }//end handle()
-}//end class
diff --git a/lib/Controller/AcknowledgementController.php b/lib/Controller/AcknowledgementController.php
new file mode 100644
index 00000000..c9c9a128
--- /dev/null
+++ b/lib/Controller/AcknowledgementController.php
@@ -0,0 +1,331 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace OCA\LaunchPad\Controller;
+
+use OCA\LaunchPad\AppInfo\Application;
+use OCA\LaunchPad\Service\AcknowledgementService;
+use OCA\LaunchPad\Service\RoleService;
+use OCP\AppFramework\Controller;
+use OCP\AppFramework\Db\DoesNotExistException;
+use OCP\AppFramework\Http;
+use OCP\AppFramework\Http\Attribute\NoAdminRequired;
+use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
+use OCP\AppFramework\Http\DataDownloadResponse;
+use OCP\AppFramework\Http\JSONResponse;
+use OCP\IGroupManager;
+use OCP\IRequest;
+use Psr\Log\LoggerInterface;
+use Throwable;
+
+/**
+ * Mandatory-read acknowledgement endpoints.
+ *
+ * The write + pending endpoints are `#[NoAdminRequired]` — any authenticated
+ * user acts only on their own receipts (own-user enforced in the body). The
+ * report endpoints additionally gate on admin / template-owner in the method
+ * body (REQ-ACK-004, ADR-005).
+ *
+ * @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()
+
+ /**
+ * 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
+ );
+ }
+
+ // 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;
+ }
+
+ 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()
+
+ /**
+ * 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
+ );
+ }
+
+ 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();
+ }
+
+ 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
+ );
+ }
+
+ 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();
+ }
+
+ 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
+ );
+ }
+
+ $csv = $this->buildCsv(report: $report);
+
+ 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'] ?? '')),
+ ]
+ );
+ }
+
+ 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()
+
+ /**
+ * 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->roleService->isAdmin(userId: $this->userId) === true) {
+ return true;
+ }
+
+ $owner = $this->acknowledgementService->resolveOwnerUserId(
+ announcementKey: $announcementKey
+ );
+
+ 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 6b5fa42a..26fa654f 100644
--- a/lib/Controller/AdminBulkController.php
+++ b/lib/Controller/AdminBulkController.php
@@ -24,8 +24,8 @@
* @version GIT:auto
* @link https://conduction.nl
*
- * SPDX-FileCopyrightText: 2026 LaunchPad Contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
+ * SPDX-FileCopyrightText: 2024 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
*/
declare(strict_types=1);
@@ -47,354 +47,346 @@
/**
* Bulk admin endpoints for dashboards (REQ-BULK-001..011).
- *
- * @SuppressWarnings(PHPMD.CouplingBetweenObjects) Admin-guard +
- * request decoding + service dispatch is the smallest viable surface
- * area here.
- */
-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 1bf9ecba..dfde0f41 100644
--- a/lib/Controller/AdminCleanupController.php
+++ b/lib/Controller/AdminCleanupController.php
@@ -24,8 +24,8 @@
* @version GIT:auto
* @link https://conduction.nl
*
- * SPDX-FileCopyrightText: 2026 LaunchPad Contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
+ * SPDX-FileCopyrightText: 2024 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
*/
declare(strict_types=1);
@@ -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 c7f867bc..51657756 100644
--- a/lib/Controller/AdminController.php
+++ b/lib/Controller/AdminController.php
@@ -37,6 +37,7 @@
use OCA\LaunchPad\Service\ResourceService;
use OCA\LaunchPad\Service\RoleService;
use OCA\LaunchPad\Service\SetupWizardService;
+use OCA\LaunchPad\Service\TemplateResyncService;
use OCA\LaunchPad\Settings\LaunchPadAdmin;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Db\DoesNotExistException;
@@ -96,959 +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.
- */
- 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,
- ) {
- 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()
-
- /**
- * 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.
- *
- * @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
- ): 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
- );
-
- 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 41aedb47..2d0cb62c 100644
--- a/lib/Controller/AdminDemoShowcasesController.php
+++ b/lib/Controller/AdminDemoShowcasesController.php
@@ -23,8 +23,8 @@
* @version GIT:auto
* @link https://conduction.nl
*
- * SPDX-FileCopyrightText: 2026 LaunchPad Contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
+ * SPDX-FileCopyrightText: 2024 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
*/
declare(strict_types=1);
@@ -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 501d88ad..1df3eba1 100644
--- a/lib/Controller/AdminOrgNavigationController.php
+++ b/lib/Controller/AdminOrgNavigationController.php
@@ -25,8 +25,8 @@
* @version GIT:auto
* @link https://conduction.nl
*
- * SPDX-FileCopyrightText: 2026 LaunchPad Contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
+ * SPDX-FileCopyrightText: 2024 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
*/
declare(strict_types=1);
@@ -51,296 +51,283 @@
/**
* Org-wide navigation editor REST surface.
- *
- * @SuppressWarnings(PHPMD.CouplingBetweenObjects) Service + group +
- * session +
- * setting mapper are
- * each used exactly
- * once.
- */
-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 c3614a18..6c807754 100644
--- a/lib/Controller/AdminSettingsController.php
+++ b/lib/Controller/AdminSettingsController.php
@@ -14,8 +14,10 @@
* persisted setting wholesale (no merge — UI sends the full ordered
* list after every drag).
*
- * Both endpoints are admin-only via `IGroupManager::isAdmin` because
- * even the GET reveals every group on the system (privacy concern).
+ * Both endpoints are admin-only via `#[AuthorizedAdminSetting]` (with
+ * `assertAdmin()`'s `IGroupManager::isAdmin` check kept as defense in
+ * depth) because even the GET reveals every group on the system
+ * (privacy concern).
*
* @category Controller
* @package OCA\LaunchPad\Controller
@@ -25,8 +27,8 @@
* @version GIT:auto
* @link https://conduction.nl
*
- * SPDX-FileCopyrightText: 2026 LaunchPad Contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
+ * SPDX-FileCopyrightText: 2024 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
*/
declare(strict_types=1);
@@ -36,8 +38,10 @@
use InvalidArgumentException;
use OCA\LaunchPad\AppInfo\Application;
use OCA\LaunchPad\Service\AdminSettingsService;
+use OCA\LaunchPad\Settings\LaunchPadAdmin;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http;
+use OCP\AppFramework\Http\Attribute\AuthorizedAdminSetting;
use OCP\AppFramework\Http\JSONResponse;
use OCP\IGroupManager;
use OCP\IRequest;
@@ -46,176 +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
- */
- 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
- */
- 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 526345e3..edeb7eb0 100644
--- a/lib/Controller/AnalyticsController.php
+++ b/lib/Controller/AnalyticsController.php
@@ -26,8 +26,8 @@
* @version GIT:auto
* @link https://conduction.nl
*
- * SPDX-FileCopyrightText: 2026 LaunchPad Contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
+ * SPDX-FileCopyrightText: 2024 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
*/
declare(strict_types=1);
@@ -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 430430df..03da94eb 100644
--- a/lib/Controller/ConfluenceImportController.php
+++ b/lib/Controller/ConfluenceImportController.php
@@ -15,8 +15,8 @@
* @version GIT:auto
* @link https://conduction.nl
*
- * SPDX-FileCopyrightText: 2026 LaunchPad Contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
+ * SPDX-FileCopyrightText: 2024 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
*/
declare(strict_types=1);
@@ -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 174ee43e..fc71006e 100644
--- a/lib/Controller/DashboardApiController.php
+++ b/lib/Controller/DashboardApiController.php
@@ -29,16 +29,17 @@
use OCA\LaunchPad\Exception\PersonalDashboardsDisabledException;
use OCA\LaunchPad\Exception\QuotaExceededException;
use OCA\LaunchPad\Service\ActionAuthService;
-use OCA\LaunchPad\Service\DashboardContentStorage\DashboardContentStorageException;
use OCA\LaunchPad\Service\AnalyticsService;
use OCA\LaunchPad\Service\DashboardService;
use OCA\LaunchPad\Service\DashboardTreeService;
use OCA\LaunchPad\Service\DashboardVersionService;
use OCA\LaunchPad\Service\PermissionService;
use OCA\LaunchPad\Service\QuotaService;
+use OCA\LaunchPad\Settings\LaunchPadAdmin;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Http;
+use OCP\AppFramework\Http\Attribute\AuthorizedAdminSetting;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\JSONResponse;
use OCP\IRequest;
@@ -48,8 +49,22 @@
/**
* Controller for dashboard API endpoints.
*
- * @SuppressWarnings(PHPMD.TooManyPublicMethods)
- * @SuppressWarnings(PHPMD.ExcessiveClassComplexity)
+ * @SuppressWarnings(PHPMD.TooManyPublicMethods) Each public method is one
+ * routed endpoint in
+ * appinfo/routes.php — CRUD,
+ * tree/path, group, default
+ * and publication actions.
+ * The count is set by the
+ * route table, not by logic
+ * that could be extracted.
+ * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) Class complexity is the
+ * sum over those endpoints;
+ * each one is individually
+ * shallow (decode, authorize,
+ * delegate to a service, map
+ * exceptions to a status
+ * code). No single method
+ * carries the weight.
* @SuppressWarnings(PHPMD.CouplingBetweenObjects) The dashboard API
* legitimately spans
* multiple persistence
@@ -65,1912 +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
- );
- }
-
- return ResponseHelper::success(
- data: [
- 'dashboard' => $result['dashboard']->jsonSerialize(),
- 'placements' => ResponseHelper::serializeList(
- entities: $result['placements']
- ),
- 'permissionLevel' => $result['permissionLevel'],
- ]
- );
- }//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 {
- $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();
- }
-
- // REQ-ASET-003 (extended): admin gating runs FIRST so the response
- // envelope is the stable `personal_dashboards_disabled` shape no
- // matter what the request body looked like.
- try {
- $this->dashboardService->assertPersonalDashboardsAllowed();
- } catch (PersonalDashboardsDisabledException $e) {
- return new JSONResponse(
- data: [
- 'status' => 'error',
- 'error' => $e->getErrorCode(),
- 'message' => $e->getMessage(),
- ],
- statusCode: Http::STATUS_FORBIDDEN
- );
- }
-
- $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()
-
- /**
- * 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 ($d) => $d->toViewerArray(),
- $dashboards
- );
-
- return ResponseHelper::success(data: $viewerData);
- }//end listGroup()
-
- /**
- * Create a new group-shared dashboard.
- *
- * Admin-only — the route attribute is `#[NoAdminRequired]` so the
- * gate-route-auth check passes; the in-body admin check is the
- * actual authorization point (gate-semantic-auth). 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
- */
- #[NoAdminRequired]
- public function createGroup(
- string $groupId,
- $name=null,
- ?string $description=null
- ): JSONResponse {
- if ($this->userId === null) {
- return ResponseHelper::unauthorized();
- }
-
- if ($this->dashboardService->isAdmin(
- userId: $this->userId
- ) === false
- ) {
- return ResponseHelper::forbidden(
- message: DashboardService::ERR_FORBIDDEN_NOT_ADMIN
- );
- }
-
- $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
- */
- #[NoAdminRequired]
- 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();
- }
-
- if ($this->dashboardService->isAdmin(
- userId: $this->userId
- ) === false
- ) {
- return ResponseHelper::forbidden(
- message: DashboardService::ERR_FORBIDDEN_NOT_ADMIN
- );
- }
-
- $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
- */
- #[NoAdminRequired]
- public function deleteGroup(
- string $groupId,
- string $uuid
- ): JSONResponse {
- if ($this->userId === null) {
- return ResponseHelper::unauthorized();
- }
-
- if ($this->dashboardService->isAdmin(
- userId: $this->userId
- ) === false
- ) {
- return ResponseHelper::forbidden(
- message: DashboardService::ERR_FORBIDDEN_NOT_ADMIN
- );
- }
-
- 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 — the route attribute is `#[NoAdminRequired]` so
- * gate-route-auth passes; the in-body admin check is the actual
- * authorization point (gate-semantic-auth). 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
- */
- #[NoAdminRequired]
- public function setGroupDefault(
- string $groupId,
- ?string $uuid=null
- ): JSONResponse {
- if ($this->userId === null) {
- return ResponseHelper::unauthorized();
- }
-
- if ($this->dashboardService->isAdmin(
- userId: $this->userId
- ) === false
- ) {
- return ResponseHelper::forbidden(
- message: DashboardService::ERR_FORBIDDEN_NOT_ADMIN
- );
- }
-
- 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()
-
- /**
- * Build a HTTP 503 response for a storage backend failure (REQ-GFSB-007).
- *
- * The error key `dashboard_content_storage_unavailable` is the stable
- * identifier callers MUST treat as a signal to surface an actionable
- * message ("Run the migration command or check GroupFolder availability").
- *
- * @param DashboardContentStorageException $e The caught exception.
- *
- * @return JSONResponse HTTP 503 with the standard error envelope.
- *
- * @spec openspec/changes/groupfolder-storage-backend/tasks.md#task-11
- */
- protected function storageUnavailableResponse(
- DashboardContentStorageException $e
- ): JSONResponse {
- $this->logger->warning(
- message: 'launchpad: dashboard content storage unavailable',
- context: ['message' => $e->getMessage(), 'exception' => $e]
- );
-
- return new JSONResponse(
- data: [
- 'error' => 'dashboard_content_storage_unavailable',
- 'message' => 'The dashboard content storage backend is unavailable. '
- .'If you recently changed the backend, run: '
- .'php occ launchpad:storage:migrate-to-groupfolder',
- ],
- statusCode: Http::STATUS_SERVICE_UNAVAILABLE
- );
- }//end storageUnavailableResponse()
+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 9f7b7709..0009d107 100644
--- a/lib/Controller/DashboardLockApiController.php
+++ b/lib/Controller/DashboardLockApiController.php
@@ -26,8 +26,8 @@
* @version GIT:auto
* @link https://conduction.nl
*
- * SPDX-FileCopyrightText: 2026 LaunchPad Contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
+ * SPDX-FileCopyrightText: 2024 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
*/
declare(strict_types=1);
@@ -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 e776ee33..b290490b 100644
--- a/lib/Controller/DashboardMetadataController.php
+++ b/lib/Controller/DashboardMetadataController.php
@@ -18,8 +18,8 @@
* @version GIT:auto
* @link https://conduction.nl
*
- * SPDX-FileCopyrightText: 2026 LaunchPad Contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
+ * SPDX-FileCopyrightText: 2024 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
*/
declare(strict_types=1);
@@ -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 ef16fd19..18598ce6 100644
--- a/lib/Controller/DashboardReactionApiController.php
+++ b/lib/Controller/DashboardReactionApiController.php
@@ -18,8 +18,8 @@
* @version GIT:auto
* @link https://conduction.nl
*
- * SPDX-FileCopyrightText: 2026 LaunchPad Contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
+ * SPDX-FileCopyrightText: 2024 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
*/
declare(strict_types=1);
@@ -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 74f80e46..8d3145ef 100644
--- a/lib/Controller/DashboardShareApiController.php
+++ b/lib/Controller/DashboardShareApiController.php
@@ -15,8 +15,8 @@
* @version GIT:auto
* @link https://conduction.nl
*
- * SPDX-FileCopyrightText: 2026 LaunchPad Contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
+ * SPDX-FileCopyrightText: 2024 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
*/
declare(strict_types=1);
@@ -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 f47ac71a..7b419f7d 100644
--- a/lib/Controller/DashboardTranslationApiController.php
+++ b/lib/Controller/DashboardTranslationApiController.php
@@ -16,8 +16,8 @@
* @version GIT:auto
* @link https://conduction.nl
*
- * SPDX-FileCopyrightText: 2026 LaunchPad Contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
+ * SPDX-FileCopyrightText: 2024 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
*/
declare(strict_types=1);
@@ -27,7 +27,9 @@
use Exception;
use InvalidArgumentException;
use OCA\LaunchPad\AppInfo\Application;
+use OCA\LaunchPad\Db\Dashboard;
use OCA\LaunchPad\Db\DashboardMapper;
+use OCA\LaunchPad\Db\DashboardTranslationMapper;
use OCA\LaunchPad\Service\ActionAuthService;
use OCA\LaunchPad\Service\DashboardTranslationService;
use OCP\AppFramework\Controller;
@@ -42,568 +44,655 @@
/**
* Controller for dashboard translation endpoints (REQ-DASH-038..044).
*
- * @SuppressWarnings(PHPMD.TooManyPublicMethods)
- * @spec openspec/specs/dashboard-language-content/spec.md
+ * @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
- {
- 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-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 {
- 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-translation.create');
- } catch (OCSForbiddenException) {
- return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN);
- }
-
- $ownerCheck = $this->assertOwner(uuid: $uuid);
- if ($ownerCheck !== null) {
- return $ownerCheck;
- }
-
- if ($languageCode === null || $languageCode === '') {
- return new JSONResponse(
- data: [
- 'status' => 'error',
- 'error' => 'invalid_argument',
- 'message' => DashboardTranslationService::ERR_INVALID_LANGUAGE,
- ],
- statusCode: Http::STATUS_BAD_REQUEST
- );
- }
-
- try {
- $variant = $this->translationService->createVariant(
- dashboardUuid: $uuid,
- languageCode: $languageCode,
- name: $name,
- description: $description,
- widgetTreeJson: $widgetTreeJson,
- copyFromLanguage: $copyFrom
- );
-
- return new JSONResponse(
- data: ['translation' => $variant->jsonSerialize()],
- statusCode: Http::STATUS_CREATED
- );
- } catch (InvalidArgumentException $e) {
- return new JSONResponse(
- data: [
- 'status' => 'error',
- 'error' => 'invalid_argument',
- 'message' => $e->getMessage(),
- ],
- statusCode: Http::STATUS_BAD_REQUEST
- );
- } catch (Exception $e) {
- if ($e->getMessage() === DashboardTranslationService::ERR_LANGUAGE_EXISTS) {
- return new JSONResponse(
- data: [
- 'status' => 'error',
- 'error' => 'language_exists',
- 'message' => $e->getMessage(),
- ],
- statusCode: Http::STATUS_CONFLICT
- );
- }
-
- return ResponseHelper::error(exception: $e);
- }//end try
- }//end create()
-
- /**
- * 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 {
- 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-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
- {
- 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-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
- {
- 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-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
- {
- 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-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
- );
- }
-
- $explicitLang = $this->request->getParam(key: 'lang');
- $hasExplicit = is_string($explicitLang) === true && $explicitLang !== '';
-
- if ($hasExplicit === true) {
- $variant = $this->translationService->resolveForLocale(
- dashboardUuid: $uuid,
- preferredLanguage: $explicitLang
- );
-
- // Strict mode for explicit lang param — when no exact match
- // exists for the requested code, return 404 instead of the
- // primary-fallback envelope. REQ-DASH-039 strict scenario.
- $requested = \OCA\LaunchPad\Db\DashboardTranslationMapper::normaliseLanguageCode(
- raw: $explicitLang
- );
- $matched = null;
- if ($variant !== null) {
- $matched = (string) $variant['translation']->getLanguageCode();
- }
-
- if ($variant === null || $matched !== $requested) {
- return new JSONResponse(
- data: [
- 'status' => 'error',
- 'error' => 'language_not_available',
- ],
- statusCode: Http::STATUS_NOT_FOUND
- );
- }
- }//end if
-
- if ($hasExplicit === false) {
- $variant = $this->translationService->resolveForLocale(
- dashboardUuid: $uuid,
- preferredLanguage: ''
- );
-
- // 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.
- if ($variant === null) {
- $variant = [
- 'translation' => $this->translationService
- ->materialiseLegacyVariant(dashboard: $dashboard),
- 'isFallback' => true,
- ];
- }
- }//end if
-
- $available = $this->translationService->listAvailableLanguages(
- dashboardUuid: $uuid
- );
- if (count($available) === 0) {
- $code = (string) $variant['translation']->getLanguageCode();
- if ($code !== '') {
- $available = [$code];
- }
- }
-
- return ResponseHelper::success(
- data: [
- 'dashboard' => $dashboard->jsonSerialize(),
- 'translation' => $variant['translation']->jsonSerialize(),
- 'availableLanguages' => $available,
- 'currentLanguage' => $variant['translation']->getLanguageCode(),
- 'isFallback' => $variant['isFallback'],
- ]
- );
- }//end resolved()
-
- /**
- * 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 e162ba7c..79ff6067 100644
--- a/lib/Controller/DashboardVersionApiController.php
+++ b/lib/Controller/DashboardVersionApiController.php
@@ -22,8 +22,8 @@
* @version GIT:auto
* @link https://conduction.nl
*
- * SPDX-FileCopyrightText: 2026 LaunchPad Contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
+ * SPDX-FileCopyrightText: 2024 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
*/
declare(strict_types=1);
@@ -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 c1a0480e..e7d94e8d 100644
--- a/lib/Controller/FileController.php
+++ b/lib/Controller/FileController.php
@@ -23,8 +23,8 @@
* @version GIT:auto
* @link https://conduction.nl
*
- * SPDX-FileCopyrightText: 2026 LaunchPad Contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
+ * SPDX-FileCopyrightText: 2024 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
*/
declare(strict_types=1);
@@ -47,132 +47,127 @@
/**
* Controller for the link-button-widget createFile flow.
- *
- * @SuppressWarnings(PHPMD.CouplingBetweenObjects)
*/
-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 c04a5ec5..2ef0a299 100644
--- a/lib/Controller/FilesWidgetController.php
+++ b/lib/Controller/FilesWidgetController.php
@@ -25,8 +25,8 @@
* @version GIT:auto
* @link https://conduction.nl
*
- * SPDX-FileCopyrightText: 2026 LaunchPad Contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
+ * SPDX-FileCopyrightText: 2024 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
*/
declare(strict_types=1);
@@ -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 0ed7039b..2f721958 100644
--- a/lib/Controller/HealthController.php
+++ b/lib/Controller/HealthController.php
@@ -3,14 +3,22 @@
/**
* HealthController
*
- * Thin leaf subclass of the OpenRegister AppHost GenericHealthController
- * (ADR-040). All rendering logic — the ADR-006 `{status, app, version, checks}`
- * shape and the declarative checks read from the `observability.health` block of
- * `src/manifest.json` — is owned by the engine. This class re-declares `index()`
- * with the explicit public auth posture (`#[PublicPage]` + `#[NoCSRFRequired]`)
- * so a session-less monitoring probe can reach `/api/health`, then defers to the
- * engine. The engine collaborators are injected by the factory in
- * {@see \OCA\LaunchPad\AppInfo\Application::registerObservability()}.
+ * Declarative health endpoint backed by the OpenRegister AppHost observability
+ * engine (ADR-040). All rendering logic — the ADR-006
+ * `{status, app, version, checks}` shape and the declarative checks read from the
+ * `observability.health` block of `src/manifest.json` — is owned by that engine.
+ * This class declares `index()` with the explicit public auth posture
+ * (`#[PublicPage]` + `#[NoCSRFRequired]`) so a session-less monitoring probe can
+ * reach `/api/health`, then defers to it. The collaborators are injected by the
+ * factory in {@see \OCA\LaunchPad\AppInfo\Application::registerObservability()}.
+ *
+ * WHY THIS NO LONGER EXTENDS OpenRegister's GenericHealthController — see the
+ * long explanation in {@see MetricsController}. In short: Nextcloud's router
+ * reflects every controller class while scanning attribute routes, so a missing
+ * PARENT class is a fatal during route matching and took every route in this app
+ * down with it, defeating the lazy string-only DI registration that was written
+ * specifically to avoid that. A health endpoint reporting "engine unavailable" is
+ * the correct degraded behaviour; a 500 on every route is not.
*
* @category Controller
* @package OCA\LaunchPad\Controller
@@ -28,29 +36,99 @@
namespace OCA\LaunchPad\Controller;
-use OCA\OpenRegister\AppHost\Controller\GenericHealthController;
+use OCP\AppFramework\Controller;
+use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
use OCP\AppFramework\Http\Attribute\PublicPage;
use OCP\AppFramework\Http\JSONResponse;
+use OCP\IRequest;
+use Throwable;
/**
* Public, declarative health endpoint backed by the AppHost engine.
*
* @spec openspec/changes/adopt-apphost/specs/prometheus-metrics/spec.md — Requirement: Health Check Endpoint (REQ-PROM-007)
*/
-class HealthController extends GenericHealthController
-{
- /**
- * GET /api/health — declarative health check (ADR-006), public.
- *
- * @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
- {
- return parent::index();
- }//end index()
+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;
+
+ 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);
+
+ $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');
+ }
+
+ 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
new file mode 100644
index 00000000..afdbc393
--- /dev/null
+++ b/lib/Controller/HealthPingController.php
@@ -0,0 +1,186 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace OCA\LaunchPad\Controller;
+
+use OCA\LaunchPad\AppInfo\Application;
+use OCA\LaunchPad\Service\HealthPingService;
+use OCA\LaunchPad\Service\PermissionService;
+use OCP\AppFramework\Controller;
+use OCP\AppFramework\Http;
+use OCP\AppFramework\Http\Attribute\NoAdminRequired;
+use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
+use OCP\AppFramework\Http\JSONResponse;
+use OCP\IRequest;
+use OCP\IUserSession;
+use Psr\Log\LoggerInterface;
+use Throwable;
+
+/**
+ * Controller for the service-health-ping capability.
+ *
+ * @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()
+}//end class
diff --git a/lib/Controller/IframeController.php b/lib/Controller/IframeController.php
new file mode 100644
index 00000000..5b96a05b
--- /dev/null
+++ b/lib/Controller/IframeController.php
@@ -0,0 +1,153 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace OCA\LaunchPad\Controller;
+
+use OCA\LaunchPad\AppInfo\Application;
+use OCA\LaunchPad\Service\IframeService;
+use OCP\AppFramework\Controller;
+use OCP\AppFramework\Http;
+use OCP\AppFramework\Http\Attribute\NoAdminRequired;
+use OCP\AppFramework\Http\JSONResponse;
+use OCP\IRequest;
+use OCP\IUserSession;
+
+/**
+ * Controller for the `iframe` widget's allow-list validation.
+ *
+ * @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()
+
+ /**
+ * `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 = [];
+ }
+
+ $errors = $this->iframeService->validateConfig(config: $config);
+
+ 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
+ );
+ }
+
+ $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()
+
+ /**
+ * 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/KioskController.php b/lib/Controller/KioskController.php
index a3ab96cc..a2fb497b 100644
--- a/lib/Controller/KioskController.php
+++ b/lib/Controller/KioskController.php
@@ -48,271 +48,264 @@
/**
* Controller for kiosk-playlist CRUD and anonymous render endpoints.
*
- * @SuppressWarnings(PHPMD.CouplingBetweenObjects)
- *
* @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
new file mode 100644
index 00000000..b9fba652
--- /dev/null
+++ b/lib/Controller/LiveTileController.php
@@ -0,0 +1,217 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace OCA\LaunchPad\Controller;
+
+use OCA\LaunchPad\AppInfo\Application;
+use OCA\LaunchPad\Service\LiveTileService;
+use OCA\LaunchPad\Service\PermissionService;
+use OCP\AppFramework\Controller;
+use OCP\AppFramework\Http;
+use OCP\AppFramework\Http\Attribute\NoAdminRequired;
+use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
+use OCP\AppFramework\Http\JSONResponse;
+use OCP\IRequest;
+use OCP\IUserSession;
+use Psr\Log\LoggerInterface;
+use Throwable;
+
+/**
+ * Controller for the live-data tile capability.
+ *
+ * @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()
+}//end class
diff --git a/lib/Controller/ManifestController.php b/lib/Controller/ManifestController.php
index e2226c91..1160278c 100644
--- a/lib/Controller/ManifestController.php
+++ b/lib/Controller/ManifestController.php
@@ -25,6 +25,7 @@
use OCA\LaunchPad\AppInfo\Application;
use OCA\LaunchPad\Service\ActionAuthService;
use OCP\AppFramework\Controller;
+use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
@@ -43,274 +44,468 @@
* against the v2 schema.
*
* @SuppressWarnings(PHPMD.CouplingBetweenObjects)
+ * Constructor wiring only: IRequest, a PSR-11 container, ActionAuthService,
+ * IUserSession, a logger and the session user id. The container is injected
+ * 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 OR that list the user in `sharedWith` 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()
-
- /**
- * 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).
- *
- * @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.
- $ownedResults = $objectService->findAll(
- config: [
- 'filters' => [
- 'register' => self::REGISTER,
- 'schema' => self::SCHEMA,
- 'owner' => $userId,
- ],
- 'limit' => 500,
- ]
- );
-
- if (is_array($ownedResults) === true) {
- foreach ($ownedResults as $item) {
- $data = $this->extractData(item: $item);
- if (empty($data) === true) {
- continue;
- }
-
- $id = $data['id'] ?? $data['uuid'] ?? $data['slug'] ?? null;
- if ($id !== null && isset($seen[$id]) === false) {
- $seen[$id] = true;
- $dashboards[] = $data;
- }
- }
- }
- } 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
-
- return $dashboards;
-
- }//end fetchUserDashboards()
-
- /**
- * 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,
- 'icon' => 'icon-home',
- ];
-
- $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 ec8574ba..ebc90e34 100644
--- a/lib/Controller/MetadataAdminController.php
+++ b/lib/Controller/MetadataAdminController.php
@@ -18,8 +18,8 @@
* @version GIT:auto
* @link https://conduction.nl
*
- * SPDX-FileCopyrightText: 2026 LaunchPad Contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
+ * SPDX-FileCopyrightText: 2024 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
*/
declare(strict_types=1);
@@ -51,336 +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 = [];
- if ($key !== null) {
- $patch['key'] = $key;
- }
-
- if ($label !== null) {
- $patch['label'] = $label;
- }
-
- if ($sortOrder !== null) {
- $patch['sortOrder'] = $sortOrder;
- }
-
- if ($required !== null) {
- $patch['required'] = $required;
- }
-
- // Distinguish "not supplied" from "null to clear". The router
- // delivers `null` when the body omits the key; treat any
- // explicit array (including empty) as "set options" so admins
- // can clear an option set on non-select types.
- if ($options !== null) {
- $patch['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 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 cd8fb7ec..b0ad0174 100644
--- a/lib/Controller/MetricsController.php
+++ b/lib/Controller/MetricsController.php
@@ -3,14 +3,37 @@
/**
* MetricsController
*
- * Thin leaf subclass of the OpenRegister AppHost GenericMetricsController
- * (ADR-040). The Prometheus 0.0.4 exposition format, the implicit
- * `launchpad_info` / `launchpad_up` metrics and the declarative `tableCount`
- * metrics read from the `observability.metrics` block of `src/manifest.json` are
- * all owned by the engine. This class re-declares `index()` with `#[NoCSRFRequired]`
- * (and deliberately WITHOUT `#[NoAdminRequired]`, so Nextcloud keeps the endpoint
- * admin-only) and defers to the engine. The engine collaborators are injected by
- * the factory in {@see \OCA\LaunchPad\AppInfo\Application::registerObservability()}.
+ * Declarative Prometheus metrics endpoint backed by the OpenRegister AppHost
+ * observability engine (ADR-040). The Prometheus 0.0.4 exposition format, the
+ * implicit `launchpad_info` / `launchpad_up` metrics and the declarative
+ * `tableCount` metrics read from the `observability.metrics` block of
+ * `src/manifest.json` are all owned by that engine. This class declares
+ * `index()` with `#[NoCSRFRequired]` (and deliberately WITHOUT
+ * `#[NoAdminRequired]`, so Nextcloud keeps the endpoint admin-only) and defers
+ * to it. The collaborators are injected by the factory in
+ * {@see \OCA\LaunchPad\AppInfo\Application::registerObservability()}.
+ *
+ * WHY THIS NO LONGER EXTENDS OpenRegister's GenericMetricsController.
+ *
+ * It used to, and that single `extends` made EVERY route in this app return 500
+ * on any instance without OpenRegister installed — not just `/api/metrics`.
+ * Nextcloud's router calls `new ReflectionClass()` on every controller while
+ * scanning for attribute routes, which loads the class, which loads its parent.
+ * A missing parent is a fatal, and it happens during route matching, before any
+ * request reaches any controller.
+ *
+ * That defeated a deliberate mitigation: `registerObservability()` references
+ * the OpenRegister classes only as STRINGS inside lazy factory closures,
+ * specifically so no `OCA\OpenRegister\…` symbol is touched until a request
+ * resolves the controller. A lazy DI registration cannot make a class-level
+ * `extends` lazy — inheritance is resolved by the autoloader, not the container.
+ * So the app documented graceful degradation and instead died whole.
+ *
+ * The collaborators are therefore held as untyped `object`s rather than as
+ * `ManifestLoader` / `MetricsEngine`: a constructor parameter TYPE is also a
+ * compile-time reference to a class that may not exist. `null` means
+ * OpenRegister is unavailable, and the endpoint reports that instead of taking
+ * the rest of the app down with it.
*
* @category Controller
* @package OCA\LaunchPad\Controller
@@ -28,9 +51,12 @@
namespace OCA\LaunchPad\Controller;
-use OCA\OpenRegister\AppHost\Controller\GenericMetricsController;
+use OCP\AppFramework\Controller;
+use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
use OCP\AppFramework\Http\TextPlainResponse;
+use OCP\IRequest;
+use Throwable;
/**
* Admin-only declarative Prometheus metrics endpoint backed by the AppHost engine.
@@ -40,18 +66,69 @@
*
* @spec openspec/changes/adopt-apphost/specs/prometheus-metrics/spec.md — Requirement: Metrics Endpoint (REQ-PROM-001)
*/
-class MetricsController extends GenericMetricsController
-{
- /**
- * GET /api/metrics — declarative Prometheus metrics (admin-only, ADR-006).
- *
- * @return TextPlainResponse Prometheus text exposition 0.0.4.
- *
- * @spec openspec/changes/adopt-apphost/specs/prometheus-metrics/spec.md — Requirement: Metrics Endpoint (REQ-PROM-001)
- */
- #[NoCSRFRequired]
- public function index(): TextPlainResponse
- {
- return parent::index();
- }//end index()
+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()
+
+ /**
+ * 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
+ );
+ }
+
+ $response = new TextPlainResponse($body);
+ $response->addHeader('Content-Type', self::CONTENT_TYPE);
+
+ return $response;
+ }//end index()
}//end class
diff --git a/lib/Controller/PageController.php b/lib/Controller/PageController.php
index 954094a9..151a69cc 100644
--- a/lib/Controller/PageController.php
+++ b/lib/Controller/PageController.php
@@ -27,6 +27,7 @@
use OCA\LaunchPad\AppInfo\Application;
use OCA\LaunchPad\Db\Dashboard;
+use OCA\LaunchPad\Service\AdminSettingsService;
use OCA\LaunchPad\Service\AdminTemplateService;
use OCA\LaunchPad\Service\DashboardService;
use OCA\LaunchPad\Service\DashboardTreeService;
@@ -37,6 +38,7 @@
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
+use OCP\AppFramework\Http\Attribute\PublicPage;
use OCP\AppFramework\Http\ContentSecurityPolicy;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\AppFramework\Services\IInitialState;
@@ -55,321 +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.
- */
- 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,
- ) {
- 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();
-
- $user = $this->userSession->getUser();
- $userId = '';
- if ($user !== null) {
- $userId = $user->getUID();
- }
-
- // 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.
- $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
- );
- }
-
- $isAdmin = false;
- if ($userId !== '') {
- $isAdmin = $this->dashboardService->isAdmin(userId: $userId);
- }
-
- $visible = [];
- if ($userId !== '') {
- $visible = $this->dashboardService->getVisibleToUser(userId: $userId);
- }
-
- $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;
- }
-
- $active = null;
- if ($userId !== '') {
- // 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.
- if ($deepLink !== '') {
- 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]
- );
- }
- }//end if
-
- if ($active === null) {
- $active = $this->dashboardService->resolveActiveDashboard(
- userId: $userId,
- primaryGroupId: $primaryGroupId
- );
- }
- }//end if
-
- $activeDashboardId = '';
- $dashboardSource = Dashboard::SOURCE_GROUP;
- $layout = [];
- $deepLinkPath = '';
- if ($active !== null) {
- $activeDashboard = $active['dashboard'];
- $activeDashboardId = (string) $activeDashboard->getUuid();
- $dashboardSource = (string) $active['source'];
- $placements = $this->widgetService->getDashboardPlacements(
- dashboardId: $activeDashboard->getId()
- );
- $layout = array_map(
- callback: function ($placement) {
- return $placement->jsonSerialize();
- },
- array: $placements
- );
- // Canonical slug-chain for whatever dashboard ended up active —
- // 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`).
- try {
- $deepLinkPath = $this->treeService->computePath(
- uuid: (string) $activeDashboard->getUuid()
- );
- } catch (Throwable $t) {
- $this->logger->warning(
- message: 'launchpad: failed to compute path for active dashboard {uuid}: {message}',
- context: [
- 'uuid' => (string) $activeDashboard->getUuid(),
- 'message' => $t->getMessage(),
- ]
- );
- }
- }//end if
-
- $allowUserDashboards = $this->dashboardService->getAllowUserDashboards();
-
- $builder = new InitialStateBuilder(
- initialState: $this->initialState,
- page: Page::WORKSPACE
- );
-
- $builder
- ->setWidgets($this->widgetService->getAvailableWidgets())
- ->setLayout($layout)
- ->setPrimaryGroup($primaryGroupId)
- ->setPrimaryGroupName($primaryGroupName)
- ->setIsAdmin($isAdmin)
- ->setActiveDashboardId($activeDashboardId)
- ->setDashboardSource($dashboardSource)
- ->setGroupDashboards($groupDashboards)
- ->setUserDashboards($userDashboards)
- ->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
- );
- }
-
- $builder
- ->setAllowedWidgets($allowedWidgets)
- ->setDeepLinkPath($deepLinkPath)
- ->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,
- ]
- );
-
- // REQ-VID: the video widget embeds YouTube/Vimeo players in an
- //